From 05837cd846c11dd11d22a790ce6d64bca86b2d40 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sat, 5 Sep 2026 08:18:55 +0200 Subject: [PATCH] Phase 4: Formulardateien und Konvertierung --- PLAN.md | 4 +- crates/tb-cli/src/main.rs | 59 +- crates/tb-cli/tests/convert_frm.rs | 24 + crates/tb-frontend/src/forms.rs | 34 +- crates/tb-ui/src/forms.rs | 1 + crates/tb-ui/src/frm.rs | 2042 +++++++++++++ crates/tb-ui/src/frm_pcode.rs | 2687 +++++++++++++++++ crates/tb-ui/src/lib.rs | 2 + crates/tb-ui/tests/data/new.frm.hex | 16 + docs/dateiformate.md | 90 +- docs/forms-referenz.md | 11 +- docs/inventar.md | 9 +- openspec/changes/phase-4-frm/.openspec.yaml | 2 + .../changes/phase-4-frm/beispieldateien.md | 88 + openspec/changes/phase-4-frm/design.md | 88 + openspec/changes/phase-4-frm/proposal.md | 63 + .../specs/forms-dateiformat/spec.md | 70 + openspec/changes/phase-4-frm/tasks.md | 24 + 18 files changed, 5287 insertions(+), 27 deletions(-) create mode 100644 crates/tb-cli/tests/convert_frm.rs create mode 100644 crates/tb-ui/src/frm.rs create mode 100644 crates/tb-ui/src/frm_pcode.rs create mode 100644 crates/tb-ui/tests/data/new.frm.hex create mode 100644 openspec/changes/phase-4-frm/.openspec.yaml create mode 100644 openspec/changes/phase-4-frm/beispieldateien.md create mode 100644 openspec/changes/phase-4-frm/design.md create mode 100644 openspec/changes/phase-4-frm/proposal.md create mode 100644 openspec/changes/phase-4-frm/specs/forms-dateiformat/spec.md create mode 100644 openspec/changes/phase-4-frm/tasks.md diff --git a/PLAN.md b/PLAN.md index a78b900..7356f9d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -406,10 +406,10 @@ Maßgeblich ist seit 2026-09-03 das Inventar, nicht diese Liste. Timer — mit allen Eigenschaften/Methoden/Ereignissen des Vorbilds - [ ] Menüsystem (Menüleiste, Shortcuts, Access Keys) - [ ] Fokus-/Tab-Reihenfolge, Access-Keys, Maussteuerung -- [ ] `.FRM`-Textformat: Serialisierung **definieren** (kein Original- +- [x] `.FRM`-Textformat: Serialisierung **definieren** (kein Original- Beispiel verfügbar — Windows-1.0-Schema, siehe dateiformate.md), dokumentieren, dann lesen/schreiben implementieren -- [ ] Konvertierungstool binäre `.FRM` → unsere Text-Serialisierung +- [x] Konvertierungstool binäre `.FRM` → unsere Text-Serialisierung (Gegenstück zu FT.EXE des Vorbilds; Magic `FC 08 01 00`): als `tbc convert-frm`. Format per Reverse Engineering aus den Beispieldateien des Originalpakets und des cout/vbdos-Repos diff --git a/crates/tb-cli/src/main.rs b/crates/tb-cli/src/main.rs index c5c8b01..93e8f15 100644 --- a/crates/tb-cli/src/main.rs +++ b/crates/tb-cli/src/main.rs @@ -4,6 +4,7 @@ //! - `tbc run ` Kompilieren und sofort ausführen //! - `tbc build ` Kompilieren zu `datei.tbc` //! - `tbc check ` Nur Syntax-/Semantikprüfung +//! - `tbc convert-frm ` Binärformular in Text wandeln //! //! Exit-Codes von `run` (Entscheidung D6, docs/tbvm-design.md): //! 0 = END/SYSTEM/Programmende · 3 = STOP · 2 = Laufzeitfehler · @@ -21,20 +22,68 @@ fn main() -> ExitCode { Some("run") => cmd_run(&args[1..]), Some("build") => cmd_build(&args[1..]), Some("check") => cmd_check(&args[1..]), + Some("convert-frm") => cmd_convert_frm(&args[1..]), _ => { - eprintln!("Aufruf: tbc run|build|check "); + eprintln!( + "Aufruf: tbc run|build|check | tbc convert-frm " + ); ExitCode::from(1) } } } +fn cmd_convert_frm(args: &[String]) -> ExitCode { + if args.len() != 2 { + eprintln!("Aufruf: tbc convert-frm "); + return ExitCode::from(1); + } + let input = Path::new(&args[0]); + let output = Path::new(&args[1]); + let bytes = match std::fs::read(input) { + Ok(bytes) => bytes, + Err(error) => { + eprintln!("{}: {error}", input.display()); + return ExitCode::from(1); + } + }; + let converted = match tb_ui::frm::read_binary(&input.display().to_string(), &bytes) { + Ok(converted) => converted, + Err(error) => { + eprintln!("{error}"); + return ExitCode::from(1); + } + }; + let text = tb_ui::frm::write_text(&converted.form); + if let Err(error) = std::fs::write(output, text) { + eprintln!("{}: {error}", output.display()); + return ExitCode::from(1); + } + for warning in &converted.skipped { + eprintln!( + "{}: Byte 0x{:04x}: {} nicht übernommen", + input.display(), + warning.offset, + warning.name + ); + } + eprintln!( + "{}: {} nicht übernommene Binärangaben", + input.display(), + converted.skipped.len() + ); + println!("{}", output.display()); + ExitCode::SUCCESS +} + fn module_name(path: &Path) -> String { path.file_stem() .map(|s| s.to_string_lossy().to_uppercase()) .unwrap_or_else(|| "MODUL".into()) } -fn compile(path_arg: Option<&String>) -> Result<(PathBuf, tb_vm::bytecode::CompiledModule), ExitCode> { +fn compile( + path_arg: Option<&String>, +) -> Result<(PathBuf, tb_vm::bytecode::CompiledModule), ExitCode> { let Some(path) = path_arg else { eprintln!("Aufruf: tbc run|build|check "); return Err(ExitCode::from(1)); @@ -133,7 +182,11 @@ fn cmd_run(args: &[String]) -> ExitCode { eprintln!("STOP in line {line}"); ExitCode::from(3) } - RunEvent::Error { code, line, message } => { + RunEvent::Error { + code, + line, + message, + } => { eprintln!("Runtime error {code}: {message} in line {line}"); ExitCode::from(2) } diff --git a/crates/tb-cli/tests/convert_frm.rs b/crates/tb-cli/tests/convert_frm.rs new file mode 100644 index 0000000..4d8dc6a --- /dev/null +++ b/crates/tb-cli/tests/convert_frm.rs @@ -0,0 +1,24 @@ +use std::process::Command; + +#[test] +fn conversion_failure_leaves_no_output() { + let dir = std::env::temp_dir().join(format!("tbc-convert-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let input = dir.join("broken.frm"); + let output = dir.join("converted.frm"); + std::fs::write(&input, b"not a form").unwrap(); + + let result = Command::new(env!("CARGO_BIN_EXE_tbc")) + .args([ + "convert-frm", + input.to_str().unwrap(), + output.to_str().unwrap(), + ]) + .output() + .unwrap(); + + assert!(!result.status.success()); + assert!(!output.exists()); + assert!(String::from_utf8_lossy(&result.stderr).contains("0x0000")); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/tb-frontend/src/forms.rs b/crates/tb-frontend/src/forms.rs index 3a459af..1a5d264 100644 --- a/crates/tb-frontend/src/forms.rs +++ b/crates/tb-frontend/src/forms.rs @@ -68,6 +68,29 @@ impl ObjectClass { } } + pub fn display_name(self) -> &'static str { + match self { + Self::Form => "Form", + Self::CheckBox => "CheckBox", + Self::ComboBox => "ComboBox", + Self::CommandButton => "CommandButton", + Self::DirListBox => "DirListBox", + Self::DriveListBox => "DriveListBox", + Self::FileListBox => "FileListBox", + Self::Frame => "Frame", + Self::HScrollBar => "HScrollBar", + Self::Label => "Label", + Self::ListBox => "ListBox", + Self::Menu => "Menu", + Self::OptionButton => "OptionButton", + Self::PictureBox => "PictureBox", + Self::TextBox => "TextBox", + Self::Timer => "Timer", + Self::VScrollBar => "VScrollBar", + Self::Screen => "Screen", + } + } + pub fn parse(name: &str) -> Option { Self::ALL .into_iter() @@ -182,11 +205,12 @@ pub fn properties(class: ObjectClass) -> Vec { range("WIDTH", 1, 1, 254), ]); } - if !matches!( - class, - Form | Frame | Label | PictureBox | Timer | Menu | Screen - ) { - p.extend([int("INDEX", 0), int("TABINDEX", 0), boolp("TABSTOP", true)]); + if !matches!(class, Form | Timer | Menu | Screen) { + p.push(int("INDEX", 0)); + p.push(int("TABINDEX", 0)); + } + if !matches!(class, Form | Frame | Label | Timer | Menu | Screen) { + p.push(boolp("TABSTOP", true)); } if !matches!(class, Form | Timer | Menu | Screen) { p.push(string("CTLNAME", "")); diff --git a/crates/tb-ui/src/forms.rs b/crates/tb-ui/src/forms.rs index 0903617..ddc4b3d 100644 --- a/crates/tb-ui/src/forms.rs +++ b/crates/tb-ui/src/forms.rs @@ -30,6 +30,7 @@ impl PropertyValue { PropertyDefault::Boolean(v) => Self::Boolean(v), PropertyDefault::Empty if ty == PropertyType::Object => Self::Object(None), PropertyDefault::Empty if ty == PropertyType::String => Self::String(String::new()), + PropertyDefault::Empty if ty == PropertyType::Boolean => Self::Boolean(false), PropertyDefault::Empty => Self::Integer(0), } } diff --git a/crates/tb-ui/src/frm.rs b/crates/tb-ui/src/frm.rs new file mode 100644 index 0000000..ac11e12 --- /dev/null +++ b/crates/tb-ui/src/frm.rs @@ -0,0 +1,2042 @@ +//! Text- und Importformat fuer Visual-Basic-for-DOS-Formulare. + +use std::collections::{BTreeMap, HashSet}; +use std::fmt; + +use tb_frontend::forms::{self, ObjectClass, PropertyDefault, PropertySpec, PropertyType}; + +use crate::forms::PropertyValue; + +#[derive(Debug, Clone, PartialEq)] +pub struct FormNode { + pub class: ObjectClass, + pub name: String, + pub properties: BTreeMap, + pub children: Vec, +} + +#[derive(Debug, Clone)] +pub struct FormFile { + pub version: String, + pub root: FormNode, + pub code: String, + original: Option, +} + +#[derive(Debug, Clone)] +struct Original { + version: String, + root: FormNode, + code: String, + bytes: String, +} + +impl PartialEq for FormFile { + fn eq(&self, other: &Self) -> bool { + self.version == other.version && self.root == other.root && self.code == other.code + } +} + +impl FormFile { + pub fn new(version: impl Into, root: FormNode, code: impl Into) -> Self { + Self { + version: version.into(), + root, + code: code.into(), + original: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FormError { + pub file: String, + pub line: usize, + pub name: String, + pub message: String, +} + +impl fmt::Display for FormError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}:{}: {}: {}", + self.file, self.line, self.name, self.message + ) + } +} + +impl std::error::Error for FormError {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BinaryWarning { + pub offset: usize, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct BinaryRead { + pub form: FormFile, + pub skipped: Vec, +} + +pub(crate) fn error( + file: &str, + line: usize, + name: impl Into, + message: impl Into, +) -> FormError { + FormError { + file: file.into(), + line, + name: name.into(), + message: message.into(), + } +} + +pub fn read_text(file: &str, source: &str) -> Result { + let lines: Vec<&str> = source.split_inclusive('\n').collect(); + let first = lines.first().copied().unwrap_or(""); + let first = first.trim_end_matches(['\r', '\n']); + let Some(version) = first + .strip_prefix("VERSION ") + .or_else(|| first.strip_prefix("Version ")) + else { + return Err(error(file, 1, "VERSION", "VERSION-Zeile fehlt")); + }; + if version != "1.00" && version != "2.00" { + return Err(error( + file, + 1, + version, + format!("unbekannte Version {version}"), + )); + } + let mut at = 1; + let root = parse_node(file, &lines, &mut at)?; + if root.class != ObjectClass::Form { + return Err(error( + file, + 2, + root.class.display_name(), + "Wurzel ist kein Form-Block", + )); + } + if at < lines.len() && lines[at].trim().is_empty() { + at += 1; + } + let code = lines[at..].concat(); + let mut result = FormFile::new(version, root, code); + result.original = Some(Original { + version: result.version.clone(), + root: result.root.clone(), + code: result.code.clone(), + bytes: source.into(), + }); + Ok(result) +} + +fn parse_node(file: &str, lines: &[&str], at: &mut usize) -> Result { + let begin_line = *at + 1; + let raw = lines + .get(*at) + .copied() + .ok_or_else(|| error(file, begin_line, "Begin", "Block erwartet"))?; + let text = raw.trim(); + let mut words = text.split_whitespace(); + if !words + .next() + .is_some_and(|word| word.eq_ignore_ascii_case("BEGIN")) + { + return Err(error(file, begin_line, text, "Begin-Block erwartet")); + } + let class_name = words.next().unwrap_or(""); + let Some(class) = ObjectClass::parse(class_name) else { + return Err(error(file, begin_line, class_name, "unbekannte Klasse")); + }; + let name = words.next().unwrap_or(""); + if name.is_empty() || words.next().is_some() { + return Err(error( + file, + begin_line, + class_name, + "ungueltiger Begin-Block", + )); + } + *at += 1; + let mut node = FormNode { + class, + name: name.into(), + properties: BTreeMap::new(), + children: Vec::new(), + }; + while let Some(raw) = lines.get(*at).copied() { + let line_no = *at + 1; + let text = raw.trim(); + if text.eq_ignore_ascii_case("END") { + *at += 1; + return Ok(node); + } + if text + .split_whitespace() + .next() + .is_some_and(|word| word.eq_ignore_ascii_case("BEGIN")) + { + node.children.push(parse_node(file, lines, at)?); + continue; + } + let Some((property_name, raw_value)) = text.split_once('=') else { + return Err(error( + file, + line_no, + &node.name, + "Eigenschaft oder Begin/End erwartet", + )); + }; + let property_name = property_name.trim(); + let Some((id, spec)) = forms::property(class, property_name) else { + return Err(error( + file, + line_no, + property_name, + format!("unbekannte Eigenschaft fuer {}", class.display_name()), + )); + }; + let value = parse_value(file, line_no, &node.name, spec, raw_value.trim())?; + node.properties.insert(id, value); + *at += 1; + } + Err(error( + file, + begin_line, + &node.name, + format!("offener Begin-Block {}", node.name), + )) +} + +fn parse_value( + file: &str, + line: usize, + object: &str, + spec: PropertySpec, + raw: &str, +) -> Result { + let bad = |message: String| error(file, line, format!("{}.{}", object, spec.name), message); + match spec.ty { + PropertyType::String => { + if raw.len() < 2 || !raw.starts_with('"') || !raw.ends_with('"') { + return Err(bad("Zeichenkette in Anfuehrungszeichen erwartet".into())); + } + let mut value = String::new(); + let mut chars = raw[1..raw.len() - 1].chars(); + while let Some(ch) = chars.next() { + if ch == '"' && chars.next() != Some('"') { + return Err(bad("Anfuehrungszeichen muss verdoppelt werden".into())); + } + value.push(ch); + } + Ok(PropertyValue::String(value)) + } + PropertyType::Integer => { + let number = raw + .strip_prefix("Char(") + .or_else(|| raw.strip_prefix("QBColor(")) + .and_then(|value| value.strip_suffix(')')) + .unwrap_or(raw) + .parse::() + .map_err(|_| bad(format!("ungueltiger Wert {raw}")))?; + validate_range(file, line, object, spec, number)?; + Ok(PropertyValue::Integer(number)) + } + PropertyType::Single => raw + .parse::() + .map(PropertyValue::Single) + .map_err(|_| bad(format!("ungueltiger Wert {raw}"))), + PropertyType::Boolean => match raw.to_ascii_uppercase().as_str() { + "TRUE" | "-1" | "1" => Ok(PropertyValue::Boolean(true)), + "FALSE" | "0" => Ok(PropertyValue::Boolean(false)), + _ => Err(bad(format!("ungueltiger boolescher Wert {raw}"))), + }, + PropertyType::Object | PropertyType::IntegerArray => Err(bad(format!( + "Werttyp fuer {} ist im Dateiformat nicht schreibbar", + spec.name + ))), + } +} + +fn validate_range( + file: &str, + location: usize, + object: &str, + spec: PropertySpec, + number: i32, +) -> Result<(), FormError> { + if spec.min.is_some_and(|min| number < min) || spec.max.is_some_and(|max| number > max) { + return Err(error( + file, + location, + format!("{}.{}", object, spec.name), + format!("Wert {number} ausserhalb des Wertebereichs"), + )); + } + Ok(()) +} + +pub fn write_text(form: &FormFile) -> String { + if let Some(original) = &form.original { + if original.version == form.version + && original.root == form.root + && original.code == form.code + { + return original.bytes.clone(); + } + } + let mut out = format!("VERSION {}\n", form.version); + write_node(&form.root, 0, &mut out); + if !form.code.is_empty() { + out.push('\n'); + out.push_str(&form.code); + } + out +} + +pub fn read_binary(file: &str, bytes: &[u8]) -> Result { + if bytes.get(..4) != Some(&[0xfc, 0x08, 0x01, 0x00]) { + return Err(error(file, 0, "Byte 0x0000", "Kennung FC 08 01 00 fehlt")); + } + let object_len = read_u16(bytes, 0x1e) + .ok_or_else(|| error(file, 0x1e, "Objektbereich", "abgeschnittener Dateikopf"))? + as usize; + let object_end = 0x20usize + .checked_add(object_len) + .filter(|end| *end <= bytes.len()) + .ok_or_else(|| error(file, 0x1e, "Objektbereich", "ungueltige Laenge"))?; + let table_at = read_u16(bytes, 0x1c) + .map(|offset| offset as usize + 0x16) + .filter(|start| *start >= object_end && *start < bytes.len()) + .ok_or_else(|| error(file, 0x1c, "Objektkatalog", "ungueltiger Verweis"))?; + let symbols = read_symbol_table(bytes, table_at).map_err(|at| { + error( + file, + at, + format!("Byte 0x{at:04x}"), + "unerkannte Katalogstruktur", + ) + })?; + if symbols[0].class != ObjectClass::Form { + return Err(error( + file, + table_at, + symbols[0].class.display_name(), + "Wurzel ist kein Formular", + )); + } + let strings = read_string_table(bytes, object_end, table_at) + .ok_or_else(|| error(file, object_end, "Zeichenketten", "unerkannte Struktur"))?; + let object_bytes = &bytes[0x20..object_end]; + let mut root = None; + let mut skipped = None; + let mut first_layout_error = None; + read_object_records(&symbols, object_bytes, &mut |records| { + let mut decoded = records + .iter() + .map(|record| decode_object(&symbols[record.symbol], record, object_bytes)) + .collect::>(); + if let Err(failure) = validate_decoded(file, records, &decoded) { + first_layout_error.get_or_insert(failure); + return false; + } + let warnings = match assign_strings( + file, + &symbols, + records, + object_bytes, + &strings, + &mut decoded, + ) { + Ok(warnings) => warnings, + Err(failure) => { + first_layout_error.get_or_insert(failure); + return false; + } + }; + root = build_tree(decoded); + if root.is_none() { + first_layout_error.get_or_insert_with(|| { + error( + file, + 0x20, + "Containerverweise", + "unerkannte Containerstruktur", + ) + }); + } + if root.is_some() { + skipped = Some(warnings); + } + root.is_some() + }) + .ok_or_else(|| { + first_layout_error.unwrap_or_else(|| { + error( + file, + 0x20, + format!("{}.Datensatzstruktur", symbols[0].name), + "unerkannte Datensatzstruktur", + ) + }) + })?; + let catalog_end = symbols + .last() + .map(|symbol| symbol.offset + 4 + symbol.name.len()) + .unwrap_or(table_at); + let code = crate::frm_pcode::decode(file, bytes, catalog_end)?; + Ok(BinaryRead { + form: FormFile::new("1.00", root.unwrap(), code), + skipped: skipped.unwrap(), + }) +} + +fn set_property(node: &mut FormNode, name: &str, value: PropertyValue) { + if let Some((id, _)) = forms::property(node.class, name) { + node.properties.insert(id, value); + } +} + +fn validate_decoded( + file: &str, + records: &[BinaryRecord], + decoded: &[DecodedObject], +) -> Result<(), FormError> { + for (record, object) in records.iter().zip(decoded) { + let specs = forms::properties(object.node.class); + for (id, value) in &object.node.properties { + let Some(spec) = specs.get(*id as usize).copied() else { + continue; + }; + if let PropertyValue::Integer(number) = value { + validate_range(file, 0x20 + record.start, &object.node.name, spec, *number)?; + } + } + } + Ok(()) +} + +pub(crate) fn read_u16(bytes: &[u8], at: usize) -> Option { + Some(u16::from_le_bytes([*bytes.get(at)?, *bytes.get(at + 1)?])) +} + +#[derive(Debug)] +struct BinarySymbol { + offset: usize, + class: ObjectClass, + name: String, + is_array: bool, + unsupported: bool, +} + +fn read_symbol_table(bytes: &[u8], mut at: usize) -> Result, usize> { + let mut symbols = Vec::new(); + loop { + if at + 4 > bytes.len() { + return Err(at); + } + let reference = read_u16(bytes, at).ok_or(at)?; + let class_id = bytes[at + 2] & 0x7f; + let length = bytes[at + 3] as usize; + let class = ObjectClass::from_id(class_id).ok_or(at + 2)?; + if length == 0 || length > 40 || at + 4 + length > bytes.len() { + return Err(at + 3); + } + let name = &bytes[at + 4..at + 4 + length]; + if !name[0].is_ascii_alphabetic() + || !name + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_') + { + return Err(at + 4); + } + if symbols.is_empty() && class != ObjectClass::Form { + return Err(at + 2); + } + symbols.push(BinarySymbol { + offset: at, + class, + name: String::from_utf8_lossy(name).into_owned(), + is_array: bytes[at + 2] & 0x80 != 0, + unsupported: class_id == ObjectClass::Screen.id(), + }); + at += 4 + length; + if reference == 0 { + return Ok(symbols); + } + } +} + +#[derive(Debug)] +struct BinaryRecord { + symbol: usize, + start: usize, + end: usize, + class_flags: u8, + common_flags: u8, +} + +#[derive(Debug)] +struct BinaryString { + pointer: u16, + value: String, +} + +fn record_len(class: ObjectClass) -> usize { + match class { + ObjectClass::Form => 38, + ObjectClass::ComboBox => 39, + ObjectClass::DirListBox + | ObjectClass::DriveListBox + | ObjectClass::HScrollBar + | ObjectClass::ListBox + | ObjectClass::VScrollBar => 32, + ObjectClass::FileListBox => 36, + ObjectClass::PictureBox => 31, + ObjectClass::TextBox => 34, + ObjectClass::Menu | ObjectClass::Frame => 26, + ObjectClass::Screen => 80, + _ => 28, + } +} + +fn record_lengths(class: ObjectClass) -> &'static [usize] { + match class { + ObjectClass::Form => &[31, 33, 38], + ObjectClass::CommandButton => &[23, 28], + ObjectClass::CheckBox => &[28], + ObjectClass::ComboBox => &[33, 39], + ObjectClass::DirListBox | ObjectClass::DriveListBox => &[32], + ObjectClass::FileListBox => &[32, 36], + ObjectClass::ListBox => &[17, 28, 32], + ObjectClass::TextBox => &[28, 34, 39], + ObjectClass::Label => &[21, 26, 27, 28, 34, 36], + ObjectClass::Frame => &[26, 31], + ObjectClass::Menu => &[26, 28], + ObjectClass::OptionButton => &[21, 28], + ObjectClass::PictureBox => &[31], + ObjectClass::HScrollBar | ObjectClass::VScrollBar => &[32], + ObjectClass::Screen => &[73, 80], + ObjectClass::Timer => &[21, 28], + } +} + +fn read_object_records( + symbols: &[BinarySymbol], + bytes: &[u8], + validate: &mut impl FnMut(&[BinaryRecord]) -> bool, +) -> Option> { + let mut pointer_start = bytes.len(); + let mut first = usize::MAX; + while pointer_start >= 2 { + let value = read_u16(bytes, pointer_start - 2)? as usize; + if value == 0 || value >= first { + break; + } + first = value; + pointer_start -= 2; + } + let all_pointers = bytes[pointer_start..] + .chunks_exact(2) + .map(|value| u16::from_le_bytes([value[0], value[1]]) as usize) + .collect::>(); + if all_pointers + .iter() + .any(|value| *value == 0 || *value >= pointer_start + 3) + { + return None; + } + for dropped in 0..=all_pointers.len() { + let start = pointer_start + dropped * 2; + let data_end = start; + let pointers = &all_pointers[dropped..]; + let mut records = Vec::new(); + let mut seen = vec![0u8; symbols.len()]; + let mut failed = HashSet::new(); + let mut validate_with_pointers = |records: &[BinaryRecord]| { + pointers + .iter() + .all(|pointer| records.iter().any(|record| record.start + 3 == *pointer)) + && validate(records) + }; + if walk_object_records( + symbols, + bytes, + data_end, + 0, + 0, + 0, + 0, + &mut records, + &mut seen, + &mut failed, + &mut validate_with_pointers, + ) { + return Some(records); + } + } + None +} + +#[allow(clippy::too_many_arguments)] +fn walk_object_records( + symbols: &[BinarySymbol], + bytes: &[u8], + data_end: usize, + cursor: usize, + symbol: usize, + class_flags: u8, + common_flags: u8, + records: &mut Vec, + seen: &mut [u8], + failed: &mut HashSet<(usize, usize, Vec)>, + validate: &mut impl FnMut(&[BinaryRecord]) -> bool, +) -> bool { + let state = (cursor, symbol, seen.to_vec()); + if failed.contains(&state) + || cursor + 6 > data_end + || (seen[symbol] != 0 && !symbols[symbol].is_array) + { + return false; + } + let index = read_u16(bytes, cursor + 4); + if symbols[symbol].is_array + && records + .iter() + .any(|record| record.symbol == symbol && read_u16(bytes, record.start + 4) == index) + { + return false; + } + seen[symbol] = seen[symbol].saturating_add(1); + let mut distances = record_lengths(symbols[symbol].class).to_vec(); + distances.sort_by_key(|distance| distance.abs_diff(record_len(symbols[symbol].class))); + for distance in distances { + let next_start = cursor + distance; + let Some(header) = next_start.checked_sub(7) else { + continue; + }; + if next_start >= data_end || header < cursor + 6 || header + 7 > data_end { + continue; + } + let next_symbol = bytes[header] as usize; + if next_symbol >= symbols.len() + || bytes[header + 2] != 0 + || bytes[header + 1] & 0x7f != symbols[next_symbol].class.id() + { + continue; + } + records.push(BinaryRecord { + symbol, + start: cursor, + end: header, + class_flags, + common_flags, + }); + if walk_object_records( + symbols, + bytes, + data_end, + next_start, + next_symbol, + bytes[header + 3], + bytes[header + 4], + records, + seen, + failed, + validate, + ) { + return true; + } + records.pop(); + } + let terminal_len = data_end - cursor; + if !record_lengths(symbols[symbol].class).contains(&terminal_len) + && !record_lengths(symbols[symbol].class).contains(&(terminal_len + 7)) + { + seen[symbol] -= 1; + failed.insert(state); + return false; + } + records.push(BinaryRecord { + symbol, + start: cursor, + end: data_end, + class_flags, + common_flags, + }); + if seen.iter().all(|count| *count != 0) && validate(records) { + return true; + } + records.pop(); + seen[symbol] -= 1; + failed.insert(state); + false +} + +#[derive(Debug)] +struct DecodedObject { + node: FormNode, + start: usize, + parent: u16, + unsupported: bool, +} + +fn record_byte(bytes: &[u8], start: usize, end: usize, offset: usize) -> Option { + (start + offset < end).then(|| bytes[start + offset]) +} + +fn record_u16(bytes: &[u8], start: usize, end: usize, offset: usize) -> Option { + (start + offset + 2 <= end) + .then(|| read_u16(bytes, start + offset)) + .flatten() +} + +fn decode_object(symbol: &BinarySymbol, record: &BinaryRecord, bytes: &[u8]) -> DecodedObject { + let start = record.start; + let end = record.end; + let mut node = FormNode { + class: symbol.class, + name: symbol.name.clone(), + properties: BTreeMap::new(), + children: Vec::new(), + }; + if symbol.class == ObjectClass::Form { + let class_flags = record_byte(bytes, start, end, 3).unwrap_or(0); + let common_flags = record_byte(bytes, start, end, 4).unwrap_or(0); + set_property( + &mut node, + "CONTROLBOX", + PropertyValue::Boolean(class_flags & 0x20 != 0), + ); + set_property( + &mut node, + "AUTOREDRAW", + PropertyValue::Boolean(class_flags & 0x08 != 0), + ); + set_property( + &mut node, + "FORMTYPE", + PropertyValue::Integer(i32::from(class_flags & 0x02 != 0)), + ); + set_property( + &mut node, + "ENABLED", + PropertyValue::Boolean(common_flags & 0x01 != 0), + ); + set_property( + &mut node, + "MAXBUTTON", + PropertyValue::Boolean(common_flags & 0x02 != 0), + ); + set_property( + &mut node, + "MINBUTTON", + PropertyValue::Boolean(common_flags & 0x04 != 0), + ); + set_property( + &mut node, + "VISIBLE", + PropertyValue::Boolean(common_flags & 0x80 != 0), + ); + for (name, offset) in [ + ("TOP", 15), + ("LEFT", 16), + ("HEIGHT", 17), + ("WIDTH", 18), + ("CURRENTX", 19), + ("CURRENTY", 20), + ] { + if let Some(value) = record_byte(bytes, start, end, offset) { + set_property(&mut node, name, PropertyValue::Integer(value as i32)); + } + } + if let Some(value) = record_byte(bytes, start, end, 21) { + set_property(&mut node, "BACKCOLOR", PropertyValue::Integer(value as i32)); + } + if let Some(value) = record_byte(bytes, start, end, 22) { + set_property(&mut node, "FORECOLOR", PropertyValue::Integer(value as i32)); + } + if let Some(value) = record_byte(bytes, start, end, 26) { + set_property( + &mut node, + "BORDERSTYLE", + PropertyValue::Integer(value as i32), + ); + } + } else { + set_property( + &mut node, + "ENABLED", + PropertyValue::Boolean(record.common_flags & 0x01 != 0), + ); + set_property( + &mut node, + "VISIBLE", + PropertyValue::Boolean(record.common_flags & 0x80 != 0), + ); + set_property( + &mut node, + "TABSTOP", + PropertyValue::Boolean(record.common_flags & 0x40 != 0), + ); + if symbol.class == ObjectClass::CommandButton { + set_property( + &mut node, + "CANCEL", + PropertyValue::Boolean(record.common_flags & 0x10 != 0), + ); + set_property( + &mut node, + "DEFAULT", + PropertyValue::Boolean(record.class_flags & 0x04 != 0), + ); + } + if symbol.class == ObjectClass::Menu { + set_property( + &mut node, + "SEPARATOR", + PropertyValue::Boolean(record.class_flags & 0x01 != 0), + ); + set_property( + &mut node, + "CHECKED", + PropertyValue::Boolean(record.class_flags & 0x40 != 0), + ); + } + for (name, offset) in [("TOP", 8), ("LEFT", 9), ("HEIGHT", 10), ("WIDTH", 11)] { + if let Some(value) = record_byte(bytes, start, end, offset) { + set_property(&mut node, name, PropertyValue::Integer(value as i32)); + } + } + if let Some(value) = record_u16(bytes, start, end, 4) { + set_property( + &mut node, + "INDEX", + PropertyValue::Integer(value as i16 as i32), + ); + } + if let Some(value) = record_byte(bytes, start, end, 13) { + set_property(&mut node, "TABINDEX", PropertyValue::Integer(value as i32)); + } + if !matches!( + symbol.class, + ObjectClass::HScrollBar | ObjectClass::VScrollBar + ) { + for (name, offset) in [("BACKCOLOR", 14), ("FORECOLOR", 15)] { + if let Some(value) = record_byte(bytes, start, end, offset) { + set_property(&mut node, name, PropertyValue::Integer(value as i32)); + } + } + } + match symbol.class { + ObjectClass::ListBox => { + set_property( + &mut node, + "SORTED", + PropertyValue::Boolean(record.class_flags & 0x80 != 0), + ); + } + ObjectClass::TextBox => { + set_property( + &mut node, + "MULTILINE", + PropertyValue::Boolean(record.common_flags & 0x08 != 0), + ); + if let Some(value) = record_byte(bytes, start, end, 19) { + set_property( + &mut node, + "BORDERSTYLE", + PropertyValue::Integer(value as i32), + ); + } + if let Some(value) = record_byte(bytes, start, end, 20) { + set_property( + &mut node, + "SCROLLBARS", + PropertyValue::Integer(value as i32), + ); + } + } + ObjectClass::ComboBox => { + if let Some(value) = record_byte(bytes, start, end, 31) { + set_property(&mut node, "STYLE", PropertyValue::Integer(value as i32)); + } + } + ObjectClass::Label => { + set_property( + &mut node, + "AUTOSIZE", + PropertyValue::Boolean(record.class_flags & 0x20 != 0), + ); + for (name, offset) in [("BORDERSTYLE", 19), ("ALIGNMENT", 20)] { + if let Some(value) = record_byte(bytes, start, end, offset) { + set_property(&mut node, name, PropertyValue::Integer(value as i32)); + } + } + } + ObjectClass::OptionButton | ObjectClass::CheckBox => { + if let Some(value) = record_u16(bytes, start, end, 19) { + set_property( + &mut node, + "VALUE", + PropertyValue::Integer(value as i16 as i32), + ); + } + } + ObjectClass::PictureBox => { + set_property( + &mut node, + "AUTOREDRAW", + PropertyValue::Boolean(record.class_flags & 0x08 != 0), + ); + if let Some(value) = record_byte(bytes, start, end, 16) { + set_property( + &mut node, + "BORDERSTYLE", + PropertyValue::Integer(value as i32), + ); + } + for (name, offset) in [("CURRENTX", 17), ("CURRENTY", 19)] { + if let Some(value) = record_u16(bytes, start, end, offset) { + set_property(&mut node, name, PropertyValue::Integer(value as i16 as i32)); + } + } + } + ObjectClass::HScrollBar | ObjectClass::VScrollBar => { + if let Some(value) = record_byte(bytes, start, end, 14) { + set_property(&mut node, "ATTACHED", PropertyValue::Boolean(value == 0)); + } + for (name, offset) in [ + ("SMALLCHANGE", 17), + ("LARGECHANGE", 19), + ("MAX", 21), + ("MIN", 23), + ] { + if let Some(value) = record_u16(bytes, start, end, offset) { + set_property(&mut node, name, PropertyValue::Integer(value as i16 as i32)); + } + } + if let Some(value) = record_u16(bytes, start, end, 23) { + set_property( + &mut node, + "VALUE", + PropertyValue::Integer(value as i16 as i32), + ); + } + } + ObjectClass::Timer => { + if let Some(value) = record_u16(bytes, start, end, 17) { + set_property(&mut node, "INTERVAL", PropertyValue::Integer(value as i32)); + } + } + _ => {} + } + } + DecodedObject { + node, + start, + parent: record_u16(bytes, start, end, 0).unwrap(), + unsupported: symbol.unsupported, + } +} + +fn build_tree(mut objects: Vec) -> Option { + if objects.first()?.node.class != ObjectClass::Form { + return None; + } + for child in (1..objects.len()).rev() { + if objects[child].unsupported { + objects.remove(child); + continue; + } + let parent = if objects[child].parent == 10 { + 0 + } else { + objects[..child] + .iter() + .position(|object| object.start + 3 == objects[child].parent as usize)? + }; + let node = objects.remove(child).node; + objects[parent].node.children.insert(0, node); + } + Some(objects.remove(0).node) +} + +fn assign_strings( + file: &str, + symbols: &[BinarySymbol], + records: &[BinaryRecord], + bytes: &[u8], + strings: &[BinaryString], + decoded: &mut [DecodedObject], +) -> Result, FormError> { + let mut warnings = records + .iter() + .filter(|record| symbols[record.symbol].class == ObjectClass::Screen) + .map(|record| BinaryWarning { + offset: 0x20 + record.start, + name: format!("{}.CustomControl", symbols[record.symbol].name), + }) + .collect::>(); + warnings.extend(records.iter().filter_map(|record| { + let symbol = &symbols[record.symbol]; + (symbol.class == ObjectClass::Menu + && record.end - record.start >= 21 + && record_u16(bytes, record.start, record.end, 19).is_some_and(|value| value != 0)) + .then(|| BinaryWarning { + offset: 0x20 + record.start + 19, + name: format!("{}.Shortcut", symbol.name), + }) + })); + let by_pointer = strings + .iter() + .enumerate() + .map(|(index, string)| (string.pointer, (index, &string.value))) + .collect::>(); + let mut used = HashSet::new(); + for (offset, name) in [(9, "TAG"), (24, "CAPTION")] { + if let Some(pointer) = record_u16(bytes, records[0].start, records[0].end, offset) + .filter(|pointer| *pointer != 0) + { + assign_binary_string( + file, + symbols, + records, + decoded, + &by_pointer, + &mut used, + 0, + name, + pointer, + )?; + } + } + for (index, record) in records.iter().enumerate().skip(1) { + let class = symbols[record.symbol].class; + if let Some(pointer) = + record_u16(bytes, record.start, record.end, 2).filter(|pointer| *pointer != 0) + { + assign_binary_string( + file, + symbols, + records, + decoded, + &by_pointer, + &mut used, + index, + "TAG", + pointer, + )?; + } + let (offset, name) = match class { + ObjectClass::TextBox => (21, "TEXT"), + ObjectClass::ComboBox => (29, "TEXT"), + ObjectClass::FileListBox => (27, "PATTERN"), + ObjectClass::Label if record.end - record.start >= 34 => (21, "CAPTION"), + ObjectClass::CommandButton + | ObjectClass::CheckBox + | ObjectClass::OptionButton + | ObjectClass::Frame + | ObjectClass::Label + | ObjectClass::Menu => (17, "CAPTION"), + _ => continue, + }; + if let Some(pointer) = + record_u16(bytes, record.start, record.end, offset).filter(|pointer| *pointer != 0) + { + assign_binary_string( + file, + symbols, + records, + decoded, + &by_pointer, + &mut used, + index, + name, + pointer, + )?; + } + } + for record in records + .iter() + .filter(|record| symbols[record.symbol].unsupported) + { + for offset in [17, 28, 37, 39, 53, 55, 64, 69] { + let Some(pointer) = + record_u16(bytes, record.start, record.end, offset).filter(|pointer| *pointer != 0) + else { + continue; + }; + let (string_index, _) = by_pointer.get(&pointer).ok_or_else(|| { + error( + file, + 0x20 + record.start + offset, + format!("{}.CustomControl", symbols[record.symbol].name), + format!("ungueltiger Zeichenkettenverweis 0x{pointer:04x}"), + ) + })?; + used.insert(*string_index); + } + } + let remaining = strings.len() - used.len(); + if remaining != 0 { + return Err(error( + file, + 0x20, + "Zeichenketten", + format!("{remaining} nicht zugeordnete Eintraege"), + )); + } + Ok(warnings) +} + +#[allow(clippy::too_many_arguments)] +fn assign_binary_string( + file: &str, + symbols: &[BinarySymbol], + records: &[BinaryRecord], + decoded: &mut [DecodedObject], + by_pointer: &BTreeMap, + used: &mut HashSet, + index: usize, + name: &'static str, + pointer: u16, +) -> Result<(), FormError> { + let (string_index, value) = by_pointer.get(&pointer).ok_or_else(|| { + error( + file, + 0x20 + records[index].start, + format!("{}.{}", symbols[records[index].symbol].name, name), + format!("ungueltiger Zeichenkettenverweis 0x{pointer:04x}"), + ) + })?; + used.insert(*string_index); + set_property( + &mut decoded[index].node, + name, + PropertyValue::String((*value).clone()), + ); + Ok(()) +} + +fn read_string_table(bytes: &[u8], mut at: usize, end: usize) -> Option> { + let mut strings = Vec::new(); + while at < end { + let pointer = u16::try_from(at.checked_sub(0x16)?).ok()?; + let length = read_u16(bytes, at)? as usize; + at += 2; + let value = bytes.get(at..at.checked_add(length)?)?; + if at + length > end { + return None; + } + strings.push(BinaryString { + pointer, + value: decode_cp437(value), + }); + at += length; + } + (at == end).then_some(strings) +} + +pub(crate) fn decode_cp437(bytes: &[u8]) -> String { + const LOW: [char; 31] = [ + '☺', '☻', '♥', '♦', '♣', '♠', '•', '◘', '○', '◙', '♂', '♀', '♪', '♫', '☼', '►', '◄', '↕', + '‼', '¶', '§', '▬', '↨', '↑', '↓', '→', '←', '∟', '↔', '▲', '▼', + ]; + const HIGH: [char; 128] = [ + 'Ç', 'ü', 'é', 'â', 'ä', 'à', 'å', 'ç', 'ê', 'ë', 'è', 'ï', 'î', 'ì', 'Ä', 'Å', 'É', 'æ', + 'Æ', 'ô', 'ö', 'ò', 'û', 'ù', 'ÿ', 'Ö', 'Ü', '¢', '£', '¥', '₧', 'ƒ', 'á', 'í', 'ó', 'ú', + 'ñ', 'Ñ', 'ª', 'º', '¿', '⌐', '¬', '½', '¼', '¡', '«', '»', '░', '▒', '▓', '│', '┤', '╡', + '╢', '╖', '╕', '╣', '║', '╗', '╝', '╜', '╛', '┐', '└', '┴', '┬', '├', '─', '┼', '╞', '╟', + '╚', '╔', '╩', '╦', '╠', '═', '╬', '╧', '╨', '╤', '╥', '╙', '╘', '╒', '╓', '╫', '╪', '┘', + '┌', '█', '▄', '▌', '▐', '▀', 'α', 'ß', 'Γ', 'π', 'Σ', 'σ', 'µ', 'τ', 'Φ', 'Θ', 'Ω', 'δ', + '∞', 'φ', 'ε', '∩', '≡', '±', '≥', '≤', '⌠', '⌡', '÷', '≈', '°', '∙', '·', '√', 'ⁿ', '²', + '■', '\u{a0}', + ]; + bytes + .iter() + .map(|byte| match *byte { + 0 => '\0', + b'\t' => '\t', + 1..=31 => LOW[*byte as usize - 1], + 32..=126 => *byte as char, + 127 => '⌂', + _ => HIGH[*byte as usize - 128], + }) + .collect() +} + +fn write_node(node: &FormNode, depth: usize, out: &mut String) { + let indent = " ".repeat(depth); + out.push_str(&format!( + "{indent}Begin {} {}\n", + node.class.display_name(), + node.name + )); + let mut properties: Vec<_> = forms::properties(node.class) + .into_iter() + .enumerate() + .collect(); + properties.sort_by_key(|(_, spec)| spec.name); + for (id, spec) in properties { + let Some(value) = node.properties.get(&(id as u16)) else { + continue; + }; + if *value == default_value(spec) { + continue; + } + out.push_str(&" ".repeat(depth + 1)); + out.push_str(&format!( + "{:<16} = {}\n", + property_name(spec.name), + format_value(value) + )); + } + for child in &node.children { + write_node(child, depth + 1, out); + } + out.push_str(&format!("{indent}End\n")); +} + +fn default_value(spec: PropertySpec) -> PropertyValue { + match spec.default { + PropertyDefault::Integer(value) if spec.ty == PropertyType::IntegerArray => { + PropertyValue::IntegerArray(vec![value; 18]) + } + PropertyDefault::Integer(value) => PropertyValue::Integer(value), + PropertyDefault::Single(value) => PropertyValue::Single(value), + PropertyDefault::String(value) => PropertyValue::String(value.into()), + PropertyDefault::Boolean(value) => PropertyValue::Boolean(value), + PropertyDefault::Empty if spec.ty == PropertyType::Object => PropertyValue::Object(None), + PropertyDefault::Empty if spec.ty == PropertyType::String => { + PropertyValue::String(String::new()) + } + PropertyDefault::Empty if spec.ty == PropertyType::Boolean => PropertyValue::Boolean(false), + PropertyDefault::Empty => PropertyValue::Integer(0), + } +} + +fn format_value(value: &PropertyValue) -> String { + match value { + PropertyValue::Integer(value) => value.to_string(), + PropertyValue::Single(value) => value.to_string(), + PropertyValue::String(value) => format!("\"{}\"", value.replace('"', "\"\"")), + PropertyValue::Boolean(value) => if *value { "-1" } else { "0" }.into(), + PropertyValue::Object(_) | PropertyValue::IntegerArray(_) => "0".into(), + } +} + +fn property_name(name: &'static str) -> &'static str { + match name { + "AUTOREDRAW" => "AutoRedraw", + "BACKCOLOR" => "BackColor", + "BORDERSTYLE" => "BorderStyle", + "CONTROLBOX" => "ControlBox", + "CURRENTX" => "CurrentX", + "CURRENTY" => "CurrentY", + "FORMNAME" => "FormName", + "FORMTYPE" => "FormType", + "FORECOLOR" => "ForeColor", + "LARGECHANGE" => "LargeChange", + "LISTCOUNT" => "ListCount", + "LISTINDEX" => "ListIndex", + "MAXBUTTON" => "MaxButton", + "MINBUTTON" => "MinButton", + "MOUSEPOINTER" => "MousePointer", + "MULTILINE" => "MultiLine", + "READONLY" => "ReadOnly", + "SCALEHEIGHT" => "ScaleHeight", + "SCALEWIDTH" => "ScaleWidth", + "SCROLLBARS" => "ScrollBars", + "SELLENGTH" => "SelLength", + "SELSTART" => "SelStart", + "SELTEXT" => "SelText", + "SMALLCHANGE" => "SmallChange", + "TABINDEX" => "TabIndex", + "TABSTOP" => "TabStop", + "WINDOWSTATE" => "WindowState", + _ => match name { + "ALIGNMENT" => "Alignment", + "ARCHIVE" => "Archive", + "ATTACHED" => "Attached", + "AUTOSIZE" => "AutoSize", + "CANCEL" => "Cancel", + "CAPTION" => "Caption", + "CHECKED" => "Checked", + "CTLNAME" => "CtlName", + "DEFAULT" => "Default", + "DRAGMODE" => "DragMode", + "DRIVE" => "Drive", + "ENABLED" => "Enabled", + "FILENAME" => "FileName", + "HEIGHT" => "Height", + "HIDDEN" => "Hidden", + "INDEX" => "Index", + "INTERVAL" => "Interval", + "LEFT" => "Left", + "LIST" => "List", + "MAX" => "Max", + "MIN" => "Min", + "NORMAL" => "Normal", + "PARENT" => "Parent", + "PATH" => "Path", + "PATTERN" => "Pattern", + "SEPARATOR" => "Separator", + "SORTED" => "Sorted", + "STYLE" => "Style", + "SYSTEM" => "System", + "TAG" => "Tag", + "TEXT" => "Text", + "TOP" => "Top", + "VALUE" => "Value", + "VISIBLE" => "Visible", + "WIDTH" => "Width", + "ACTIVEFORM" => "ActiveForm", + "ACTIVECONTROL" => "ActiveControl", + "CONTROLPANEL" => "ControlPanel", + _ => name, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const EXAMPLE: &str = "VERSION 1.00\nBegin Form Form1\n Caption = \"Beispiel\"\n Height = 15\n Begin CommandButton cmdOK\n Caption = \"&OK\"\n End\nEnd\n\nSUB cmdOK_Click ()\n UNLOAD Form1\nEND SUB\n"; + + #[test] + fn reads_nested_form_and_preserves_source_exactly() { + let form = read_text("test.frm", EXAMPLE).unwrap(); + assert_eq!(form.root.children[0].class, ObjectClass::CommandButton); + assert_eq!(form.code, "SUB cmdOK_Click ()\n UNLOAD Form1\nEND SUB\n"); + assert_eq!(write_text(&form), EXAMPLE); + } + + #[test] + fn rejects_version_class_property_value_and_open_block_with_location() { + let cases = [ + ("VERSION 3.00\n", "3.00", 1), + ("VERSION 1.00\nBegin Knopf x\nEnd\n", "Knopf", 2), + ( + "VERSION 1.00\nBegin CommandButton x\n Farbe = 3\nEnd\n", + "Farbe", + 3, + ), + ( + "VERSION 1.00\nBegin CommandButton x\n Height = 0\nEnd\n", + "x.HEIGHT", + 3, + ), + ( + "VERSION 1.00\nBegin Form x\n Caption = \"a\"b\"\nEnd\n", + "x.CAPTION", + 3, + ), + ("VERSION 1.00\nBegin Form offen\n", "offen", 2), + ]; + for (source, name, line) in cases { + let failure = read_text("kaputt.frm", source).unwrap_err(); + assert_eq!(failure.file, "kaputt.frm"); + assert_eq!(failure.line, line); + assert!(failure.to_string().contains(name)); + } + } + + #[test] + fn writes_documented_d1_and_roundtrips_description() { + let mut root = FormNode { + class: ObjectClass::Form, + name: "Form1".into(), + properties: BTreeMap::new(), + children: vec![FormNode { + class: ObjectClass::CommandButton, + name: "cmdOK".into(), + properties: BTreeMap::new(), + children: Vec::new(), + }], + }; + root.properties.insert( + forms::property(ObjectClass::Form, "Caption").unwrap().0, + PropertyValue::String("Beispiel".into()), + ); + root.properties.insert( + forms::property(ObjectClass::Form, "Height").unwrap().0, + PropertyValue::Integer(15), + ); + root.children[0].properties.insert( + forms::property(ObjectClass::CommandButton, "Caption") + .unwrap() + .0, + PropertyValue::String("&OK".into()), + ); + let form = FormFile::new("1.00", root, ""); + let expected = "VERSION 1.00\nBegin Form Form1\n Caption = \"Beispiel\"\n Height = 15\n Begin CommandButton cmdOK\n Caption = \"&OK\"\n End\nEnd\n"; + let text = write_text(&form); + assert_eq!(text, expected); + assert_eq!(read_text("test.frm", &text).unwrap(), form); + } + + #[test] + fn writes_no_properties_for_default_only_control() { + let form = FormFile::new( + "1.00", + FormNode { + class: ObjectClass::Form, + name: "Form1".into(), + properties: BTreeMap::new(), + children: vec![FormNode { + class: ObjectClass::CommandButton, + name: "Command1".into(), + properties: BTreeMap::new(), + children: Vec::new(), + }], + }, + "", + ); + assert_eq!( + write_text(&form), + "VERSION 1.00\nBegin Form Form1\n Begin CommandButton Command1\n End\nEnd\n" + ); + } + + #[test] + fn writes_read_only_boolean_only_when_it_differs_from_false() { + let property = forms::property(ObjectClass::VScrollBar, "ATTACHED") + .unwrap() + .0; + let mut root = FormNode { + class: ObjectClass::VScrollBar, + name: "VScroll1".into(), + properties: BTreeMap::from([(property, PropertyValue::Boolean(false))]), + children: Vec::new(), + }; + assert!(!write_text(&FormFile::new("1.00", root.clone(), "")).contains("Attached")); + + root.properties + .insert(property, PropertyValue::Boolean(true)); + assert!(write_text(&FormFile::new("1.00", root, "")).contains("Attached = -1")); + } + + #[test] + fn reads_real_vbdos_form_with_complete_documented_description() { + let hex = include_str!("../tests/data/new.frm.hex"); + let bytes: Vec = hex + .split_whitespace() + .map(|byte| u8::from_str_radix(byte, 16).unwrap()) + .collect(); + let read = read_binary("new.frm", &bytes).unwrap(); + assert!(read.skipped.is_empty()); + assert_eq!( + write_text(&read.form), + "VERSION 1.00\nBegin Form New\n Caption = \"New\"\n FormType = 1\n Height = 17\n Left = 15\n MaxButton = 0\n Top = 3\n Visible = -1\n Width = 63\n Begin CommandButton Command1\n Caption = \"Command1\"\n Height = 3\n Left = 8\n Top = 11\n Width = 12\n End\nEnd\n" + ); + } + + #[test] + fn binary_reader_rejects_magic_and_unknown_structure_with_offset() { + assert!(read_binary("fremd.frm", b"kein formular") + .unwrap_err() + .to_string() + .contains("0x0000")); + let mut bytes = vec![0xfc, 0x08, 0x01, 0x00]; + bytes.resize(64, 0); + assert!(read_binary("kaputt.frm", &bytes) + .unwrap_err() + .to_string() + .contains("Objektkatalog")); + + let mut bytes: Vec = include_str!("../tests/data/new.frm.hex") + .split_whitespace() + .map(|byte| u8::from_str_radix(byte, 16).unwrap()) + .collect(); + bytes[0x1c..0x1e].copy_from_slice(&u16::MAX.to_le_bytes()); + let failure = read_binary("zeiger.frm", &bytes).unwrap_err(); + assert_eq!(failure.line, 0x1c); + assert!(failure.to_string().contains("Objektkatalog")); + + let mut bytes: Vec = include_str!("../tests/data/new.frm.hex") + .split_whitespace() + .map(|byte| u8::from_str_radix(byte, 16).unwrap()) + .collect(); + bytes[0x75] = 0x7f; + let failure = read_binary("mitten.frm", &bytes).unwrap_err(); + assert_eq!(failure.line, 0x75); + assert!(failure.to_string().contains("Katalogstruktur")); + + let mut bytes: Vec = include_str!("../tests/data/new.frm.hex") + .split_whitespace() + .map(|byte| u8::from_str_radix(byte, 16).unwrap()) + .collect(); + bytes[0x75] = ObjectClass::Screen.id(); + bytes[0x40] = ObjectClass::Screen.id(); + let failure = read_binary("klassenkonflikt.frm", &bytes).unwrap_err(); + assert_eq!(failure.line, 0x20); + + let mut bytes: Vec = include_str!("../tests/data/new.frm.hex") + .split_whitespace() + .map(|byte| u8::from_str_radix(byte, 16).unwrap()) + .collect(); + bytes[0x46..0x48].copy_from_slice(&999u16.to_le_bytes()); + let failure = read_binary("struktur.frm", &bytes).unwrap_err(); + assert_eq!(failure.line, 0x20); + assert_eq!(failure.name, "Containerverweise"); + } + + #[test] + fn binary_reader_rejects_out_of_range_property_with_offset() { + let mut bytes: Vec = include_str!("../tests/data/new.frm.hex") + .split_whitespace() + .map(|byte| u8::from_str_radix(byte, 16).unwrap()) + .collect(); + bytes[0x31] = 0; + + let failure = read_binary("bereich.frm", &bytes).unwrap_err(); + assert_eq!(failure.line, 0x20); + assert_eq!(failure.name, "New.HEIGHT"); + } + + #[test] + fn unsupported_custom_control_is_named_and_not_emitted_as_screen() { + let symbols = vec![ + BinarySymbol { + offset: 0, + class: ObjectClass::Form, + name: "Form1".into(), + is_array: false, + unsupported: false, + }, + BinarySymbol { + offset: 0, + class: ObjectClass::Screen, + name: "VSpin".into(), + is_array: false, + unsupported: true, + }, + ]; + let records = vec![ + BinaryRecord { + symbol: 0, + start: 0, + end: 31, + class_flags: 0, + common_flags: 0, + }, + BinaryRecord { + symbol: 1, + start: 38, + end: 111, + class_flags: 0, + common_flags: 0x81, + }, + ]; + let mut bytes = vec![0; 111]; + bytes[24..26].copy_from_slice(&100u16.to_le_bytes()); + bytes[38..40].copy_from_slice(&10u16.to_le_bytes()); + for (offset, pointer) in [17, 28, 37, 39, 53, 55, 64, 69].into_iter().zip(101u16..) { + bytes[38 + offset..40 + offset].copy_from_slice(&pointer.to_le_bytes()); + } + let strings = (100..=108) + .map(|pointer| BinaryString { + pointer, + value: if pointer == 100 { + "Form1".into() + } else { + format!("Custom{pointer}") + }, + }) + .collect::>(); + let mut decoded = records + .iter() + .map(|record| decode_object(&symbols[record.symbol], record, &bytes)) + .collect::>(); + + let warnings = assign_strings( + "custom.frm", + &symbols, + &records, + &bytes, + &strings, + &mut decoded, + ) + .unwrap(); + let root = build_tree(decoded).unwrap(); + + assert_eq!(warnings[0].offset, 0x46); + assert_eq!(warnings[0].name, "VSpin.CustomControl"); + assert!(root.children.is_empty()); + + let mut strings_with_orphan = (100..=108) + .map(|pointer| BinaryString { + pointer, + value: format!("String{pointer}"), + }) + .collect::>(); + strings_with_orphan.push(BinaryString { + pointer: 109, + value: "Orphan".into(), + }); + let mut decoded = records + .iter() + .map(|record| decode_object(&symbols[record.symbol], record, &bytes)) + .collect::>(); + assert!(assign_strings( + "custom.frm", + &symbols, + &records, + &bytes, + &strings_with_orphan, + &mut decoded, + ) + .unwrap_err() + .to_string() + .contains("nicht zugeordnete Eintraege")); + } + + #[test] + fn unsupported_binary_property_is_named_and_unclaimed_strings_still_fail() { + let symbols = vec![ + BinarySymbol { + offset: 0, + class: ObjectClass::Form, + name: "Form1".into(), + is_array: false, + unsupported: false, + }, + BinarySymbol { + offset: 0, + class: ObjectClass::Menu, + name: "mnuOpen".into(), + is_array: false, + unsupported: false, + }, + ]; + let records = vec![ + BinaryRecord { + symbol: 0, + start: 0, + end: 31, + class_flags: 0, + common_flags: 0, + }, + BinaryRecord { + symbol: 1, + start: 38, + end: 59, + class_flags: 0, + common_flags: 0x81, + }, + ]; + let mut bytes = vec![0; 59]; + bytes[57..59].copy_from_slice(&0x1234u16.to_le_bytes()); + let mut decoded = records + .iter() + .map(|record| decode_object(&symbols[record.symbol], record, &bytes)) + .collect::>(); + let warnings = + assign_strings("menu.frm", &symbols, &records, &bytes, &[], &mut decoded).unwrap(); + assert_eq!(warnings[0].offset, 0x59); + assert_eq!(warnings[0].name, "mnuOpen.Shortcut"); + + let strings = vec![BinaryString { + pointer: 100, + value: "orphan".into(), + }]; + assert!(assign_strings( + "menu.frm", + &symbols, + &records, + &bytes, + &strings, + &mut decoded, + ) + .unwrap_err() + .to_string() + .contains("nicht zugeordnete Eintraege")); + } + + #[test] + fn binary_strings_and_header_flags_map_to_the_named_properties() { + let symbols = [ + (ObjectClass::Form, "Form1"), + (ObjectClass::TextBox, "txtSearch"), + (ObjectClass::ListBox, "lstFound"), + (ObjectClass::CommandButton, "cmdSearch"), + ] + .into_iter() + .map(|(class, name)| BinarySymbol { + offset: 0, + class, + name: name.into(), + is_array: false, + unsupported: false, + }) + .collect::>(); + let records = vec![ + BinaryRecord { + symbol: 0, + start: 0, + end: 31, + class_flags: 0, + common_flags: 0, + }, + BinaryRecord { + symbol: 1, + start: 38, + end: 63, + class_flags: 0, + common_flags: 0xc1, + }, + BinaryRecord { + symbol: 2, + start: 70, + end: 93, + class_flags: 0, + common_flags: 0x41, + }, + BinaryRecord { + symbol: 3, + start: 100, + end: 123, + class_flags: 0x04, + common_flags: 0xd1, + }, + ]; + let mut bytes = vec![0; 123]; + bytes[3] = 0x28; + bytes[4] = 0x81; + bytes[19] = 2; + bytes[20] = 3; + bytes[22] = 7; + bytes[24..26].copy_from_slice(&100u16.to_le_bytes()); + for start in [38, 70, 100] { + bytes[start..start + 2].copy_from_slice(&10u16.to_le_bytes()); + } + bytes[40..42].copy_from_slice(&101u16.to_le_bytes()); + bytes[59..61].copy_from_slice(&102u16.to_le_bytes()); + bytes[72..74].copy_from_slice(&103u16.to_le_bytes()); + bytes[102..104].copy_from_slice(&104u16.to_le_bytes()); + bytes[117..119].copy_from_slice(&105u16.to_le_bytes()); + let strings = [ + (100, "Search"), + (101, ""), + (102, "*.*"), + (103, ""), + (104, ""), + (105, "&Search"), + ] + .into_iter() + .map(|(pointer, value)| BinaryString { + pointer, + value: value.into(), + }) + .collect::>(); + let mut decoded = records + .iter() + .map(|record| decode_object(&symbols[record.symbol], record, &bytes)) + .collect::>(); + assert!(assign_strings( + "search.frm", + &symbols, + &records, + &bytes, + &strings, + &mut decoded, + ) + .unwrap() + .is_empty()); + let root = build_tree(decoded).unwrap(); + + let value = |node: &FormNode, name| { + node.properties + .get(&forms::property(node.class, name).unwrap().0) + .cloned() + }; + assert_eq!( + value(&root, "CAPTION"), + Some(PropertyValue::String("Search".into())) + ); + assert_eq!( + value(&root, "AUTOREDRAW"), + Some(PropertyValue::Boolean(true)) + ); + assert_eq!(value(&root, "FORECOLOR"), Some(PropertyValue::Integer(7))); + assert_eq!(value(&root, "CURRENTX"), Some(PropertyValue::Integer(2))); + assert_eq!(value(&root, "CURRENTY"), Some(PropertyValue::Integer(3))); + assert_eq!( + value(&root.children[0], "TEXT"), + Some(PropertyValue::String("*.*".into())) + ); + assert_eq!( + value(&root.children[1], "VISIBLE"), + Some(PropertyValue::Boolean(false)) + ); + assert_eq!( + value(&root.children[2], "CAPTION"), + Some(PropertyValue::String("&Search".into())) + ); + assert_eq!( + value(&root.children[2], "DEFAULT"), + Some(PropertyValue::Boolean(true)) + ); + assert_eq!( + value(&root.children[2], "CANCEL"), + Some(PropertyValue::Boolean(true)) + ); + } + + #[test] + fn binary_class_specific_properties_match_the_reference_exports() { + let decode = |class, class_flags, bytes: Vec| { + decode_object( + &BinarySymbol { + offset: 0, + class, + name: "Control1".into(), + is_array: false, + unsupported: false, + }, + &BinaryRecord { + symbol: 0, + start: 0, + end: bytes.len(), + class_flags, + common_flags: 0xc1, + }, + &bytes, + ) + .node + }; + let value = |node: &FormNode, name| { + node.properties + .get(&forms::property(node.class, name).unwrap().0) + .cloned() + }; + + let decode_form = |class_flags| { + let mut bytes = vec![0; 31]; + bytes[3] = class_flags; + decode_object( + &BinarySymbol { + offset: 0, + class: ObjectClass::Form, + name: "Form1".into(), + is_array: false, + unsupported: false, + }, + &BinaryRecord { + symbol: 0, + start: 0, + end: bytes.len(), + class_flags: 0, + common_flags: 0, + }, + &bytes, + ) + .node + }; + let auto_redraw = decode_form(0x08); + assert_eq!( + value(&auto_redraw, "AUTOREDRAW"), + Some(PropertyValue::Boolean(true)) + ); + assert_eq!( + value(&auto_redraw, "FORMTYPE"), + Some(PropertyValue::Integer(0)) + ); + let mdi = decode_form(0x02); + assert_eq!( + value(&mdi, "AUTOREDRAW"), + Some(PropertyValue::Boolean(false)) + ); + assert_eq!(value(&mdi, "FORMTYPE"), Some(PropertyValue::Integer(1))); + + let mut label = vec![0; 21]; + label[19] = 1; + label[20] = 2; + let label = decode(ObjectClass::Label, 0, label); + assert_eq!( + value(&label, "BORDERSTYLE"), + Some(PropertyValue::Integer(1)) + ); + assert_eq!(value(&label, "ALIGNMENT"), Some(PropertyValue::Integer(2))); + + let mut picture = vec![0; 24]; + picture[14] = 7; + picture[17..19].copy_from_slice(&2i16.to_le_bytes()); + picture[19..21].copy_from_slice(&(-3i16).to_le_bytes()); + let picture = decode(ObjectClass::PictureBox, 0x08, picture); + assert_eq!( + value(&picture, "AUTOREDRAW"), + Some(PropertyValue::Boolean(true)) + ); + assert_eq!( + value(&picture, "BORDERSTYLE"), + Some(PropertyValue::Integer(0)) + ); + assert_eq!(value(&picture, "CURRENTX"), Some(PropertyValue::Integer(2))); + assert_eq!( + value(&picture, "CURRENTY"), + Some(PropertyValue::Integer(-3)) + ); + + let mut combo = vec![0; 33]; + combo[14] = 7; + combo[31] = 2; + let combo = decode(ObjectClass::ComboBox, 0, combo); + assert_eq!(value(&combo, "STYLE"), Some(PropertyValue::Integer(2))); + + let mut text = vec![0; 21]; + text[19] = 1; + text[20] = 3; + let text = decode_object( + &BinarySymbol { + offset: 0, + class: ObjectClass::TextBox, + name: "Text1".into(), + is_array: false, + unsupported: false, + }, + &BinaryRecord { + symbol: 0, + start: 0, + end: text.len(), + class_flags: 0, + common_flags: 0x08, + }, + &text, + ) + .node; + assert_eq!( + value(&text, "MULTILINE"), + Some(PropertyValue::Boolean(true)) + ); + assert_eq!(value(&text, "SCROLLBARS"), Some(PropertyValue::Integer(3))); + + let list = decode(ObjectClass::ListBox, 0x80, vec![0; 23]); + assert_eq!(value(&list, "SORTED"), Some(PropertyValue::Boolean(true))); + + let label = decode(ObjectClass::Label, 0x20, vec![0; 21]); + assert_eq!( + value(&label, "AUTOSIZE"), + Some(PropertyValue::Boolean(true)) + ); + + let separator = decode(ObjectClass::Menu, 0x01, vec![0; 19]); + assert_eq!( + value(&separator, "SEPARATOR"), + Some(PropertyValue::Boolean(true)) + ); + assert_eq!( + value(&separator, "CHECKED"), + Some(PropertyValue::Boolean(false)) + ); + let checked = decode(ObjectClass::Menu, 0x40, vec![0; 19]); + assert_eq!( + value(&checked, "SEPARATOR"), + Some(PropertyValue::Boolean(false)) + ); + assert_eq!( + value(&checked, "CHECKED"), + Some(PropertyValue::Boolean(true)) + ); + + let mut timer = vec![0; 19]; + timer[17..19].copy_from_slice(&1000u16.to_le_bytes()); + let timer = decode(ObjectClass::Timer, 0, timer); + assert_eq!( + value(&timer, "INTERVAL"), + Some(PropertyValue::Integer(1000)) + ); + + let mut scroll = vec![0; 25]; + scroll[14] = 1; + for (offset, setting) in [(17, 2i16), (19, 3), (21, 10), (23, -4)] { + scroll[offset..offset + 2].copy_from_slice(&setting.to_le_bytes()); + } + let scroll = decode(ObjectClass::VScrollBar, 0, scroll); + assert_eq!( + value(&scroll, "ATTACHED"), + Some(PropertyValue::Boolean(false)) + ); + assert_eq!( + value(&scroll, "SMALLCHANGE"), + Some(PropertyValue::Integer(2)) + ); + assert_eq!( + value(&scroll, "LARGECHANGE"), + Some(PropertyValue::Integer(3)) + ); + assert_eq!(value(&scroll, "MAX"), Some(PropertyValue::Integer(10))); + assert_eq!(value(&scroll, "MIN"), Some(PropertyValue::Integer(-4))); + assert_eq!(value(&scroll, "VALUE"), Some(PropertyValue::Integer(-4))); + assert_eq!(value(&scroll, "BACKCOLOR"), None); + + let mut option = vec![0; 21]; + option[19..21].copy_from_slice(&u16::MAX.to_le_bytes()); + let option = decode(ObjectClass::OptionButton, 0, option); + assert_eq!(value(&option, "VALUE"), Some(PropertyValue::Integer(-1))); + } + + #[test] + fn binary_layout_preserves_arrays_and_container_hierarchy() { + let symbols = vec![ + BinarySymbol { + offset: 0, + class: ObjectClass::Form, + name: "Form1".into(), + is_array: false, + unsupported: false, + }, + BinarySymbol { + offset: 0, + class: ObjectClass::Frame, + name: "Frame1".into(), + is_array: false, + unsupported: false, + }, + BinarySymbol { + offset: 0, + class: ObjectClass::PictureBox, + name: "Picture1".into(), + is_array: true, + unsupported: false, + }, + ]; + let mut bytes = vec![0; 126]; + bytes[31..38].copy_from_slice(&[1, ObjectClass::Frame.id(), 0, 0, 0x81, 0, 0]); + bytes[38..40].copy_from_slice(&10u16.to_le_bytes()); + bytes[57..64].copy_from_slice(&[2, ObjectClass::PictureBox.id(), 0, 0, 0xc1, 0, 0]); + bytes[64..66].copy_from_slice(&41u16.to_le_bytes()); + bytes[88..95].copy_from_slice(&[2, ObjectClass::PictureBox.id(), 0, 0, 0xc1, 0, 0]); + bytes[95..97].copy_from_slice(&41u16.to_le_bytes()); + bytes[68..70].copy_from_slice(&0u16.to_le_bytes()); + bytes[99..101].copy_from_slice(&1u16.to_le_bytes()); + + let records = read_object_records(&symbols, &bytes, &mut |_| true).unwrap(); + let decoded: Vec<_> = records + .iter() + .map(|record| decode_object(&symbols[record.symbol], record, &bytes)) + .collect(); + let root = build_tree(decoded).unwrap(); + assert_eq!(root.children[0].children.len(), 2); + assert_eq!( + root.children[0].children[1] + .properties + .get(&forms::property(ObjectClass::PictureBox, "INDEX").unwrap().0), + Some(&PropertyValue::Integer(1)) + ); + } + + #[test] + fn binary_strings_are_decoded_from_cp437() { + assert_eq!(decode_cp437(&[0x0e, b' ', 0x84]), "♫ ä"); + assert_eq!(decode_cp437(b"Cut\tCtrl+X"), "Cut\tCtrl+X"); + } +} diff --git a/crates/tb-ui/src/frm_pcode.rs b/crates/tb-ui/src/frm_pcode.rs new file mode 100644 index 0000000..533bcfe --- /dev/null +++ b/crates/tb-ui/src/frm_pcode.rs @@ -0,0 +1,2687 @@ +//! Leser fuer den tokenisierten BASIC-Codeteil binaerer VBDOS-Formulare. + +use crate::frm::{decode_cp437, error, read_u16, FormError}; + +fn decode_pcode_bytes(bytes: &[u8]) -> String { + bytes + .iter() + .map(|byte| { + if *byte < 0x80 { + char::from(*byte) + } else { + decode_cp437(&[*byte]).chars().next().unwrap() + } + }) + .collect() +} + +fn letter_ranges(mask: u32) -> String { + let mut ranges = Vec::new(); + let mut first = None; + for index in 0..=26 { + let set = index < 26 && mask & (1 << (31 - index)) != 0; + if set && first.is_none() { + first = Some(index); + } else if !set && first.is_some() { + let start = first.take().unwrap(); + let mut range = char::from(b'A' + start as u8).to_string(); + if index - start > 1 { + range.push('-'); + range.push(char::from(b'A' + index as u8 - 1)); + } + ranges.push(range); + } + } + ranges.join(", ") +} + +#[derive(Clone, Copy)] +struct TokenDef { + len: i16, + rules: &'static [&'static str], +} + +fn token_def(code: u16) -> Option { + match code { + 0x000 => Some(TokenDef { + len: 0, + rules: &["newline::=.{#newline}{#tabh}"], + }), + 0x001 => Some(TokenDef { + len: 2, + rules: &["newline::=.{#newline}{#tabi}"], + }), + 0x002 => Some(TokenDef { + len: 2, + rules: &["newline::=.{#newline-include}"], + }), + 0x003 => Some(TokenDef { + len: 4, + rules: &["newline::=.{#newline-include}{#indent:2} "], + }), + 0x004 => Some(TokenDef { + len: 4, + rules: &[".{#newline}{#thaddr:0}{#label:2}"], + }), + 0x005 => Some(TokenDef { + len: 6, + rules: &[".{#newline}{#thaddr:0}{#label:2} {#indent:4}"], + }), + 0x006 => Some(TokenDef { + len: 0, + rules: &[": "], + }), + 0x007 => Some(TokenDef { + len: 2, + rules: &[":{#tabi}"], + }), + 0x008 => Some(TokenDef { + len: 0, + rules: &["."], + }), + 0x009 => Some(TokenDef { + len: 0, + rules: &[""], + }), + 0x00a => Some(TokenDef { + len: -1, + rules: &["{#raw:2}"], + }), + 0x00b => Some(TokenDef { + len: 2, + rules: &["expr::={#id+}"], + }), + 0x00c => Some(TokenDef { + len: 2, + rules: &[ + "consts::={const:1} {#id+} = {0}", + "consts::={consts:1}, {#id+} = {0}", + "{#id+} = {0}", + ], + }), + 0x00d => Some(TokenDef { + len: 2, + rules: &[ + "decls::={decls:1}, {#id+:0} {astype:0}", + "decls::={decls:0}, {#id+:0}", + "decls::={decl:1} {#id+:0} {astype:0}", + "decls::={decl:0} {#id+:0}", + "{#id+:0} {astype:0}", + "{#id+:0}", + ], + }), + 0x00e => Some(TokenDef { + len: 4, + rules: &["expr::={##id(expr)}"], + }), + 0x00f => Some(TokenDef { + len: 4, + rules: &["{##id(expr)} = {$+0}"], + }), + 0x010 => Some(TokenDef { + len: 4, + rules: &["decls::={##id(decl)}"], + }), + 0x011 => Some(TokenDef { + len: 2, + rules: &["expr::={0}.{#id}"], + }), + 0x012 => Some(TokenDef { + len: 2, + rules: &["{0}.{#id} = {1}"], + }), + 0x013 => Some(TokenDef { + len: 4, + rules: &["expr::={0}.{#id:2}({1})"], + }), + 0x014 => Some(TokenDef { + len: 4, + rules: &["{0}.{#id:2}({1}) = {2}"], + }), + 0x015 => Some(TokenDef { + len: 4, + rules: &["astype::={#tabi:2}AS {#type:0}"], + }), + 0x016 => Some(TokenDef { + len: 4, + rules: &["astype::={#tabi:2}AS {#type:0}"], + }), + 0x017 => Some(TokenDef { + len: 0, + rules: &[""], + }), + 0x018 => Some(TokenDef { + len: 0, + rules: &[""], + }), + 0x019 => Some(TokenDef { + len: 2, + rules: &["{#id}"], + }), + 0x01a => Some(TokenDef { + len: 0, + rules: &["declmod::=SHARED"], + }), + 0x01b => Some(TokenDef { + len: 6, + rules: &["deftype::={#thaddr:0}{#DEFxxx}"], + }), + 0x01c => Some(TokenDef { + len: 0, + rules: &["{self:1}, {0}", "REDIM {declmod:1} {0}", "REDIM {0}"], + }), + 0x01d => Some(TokenDef { + len: 2, + rules: &["END TYPE"], + }), + 0x01e => Some(TokenDef { + len: 2, + rules: &["decl::=SHARED"], + }), + 0x01f => Some(TokenDef { + len: 2, + rules: &["decl::=STATIC"], + }), + 0x020 => Some(TokenDef { + len: 4, + rules: &["TYPE {#id:2}"], + }), + 0x021 => Some(TokenDef { + len: -1, + rules: &["$STATIC{#raw}"], + }), + 0x022 => Some(TokenDef { + len: -1, + rules: &["$DYNAMIC{#raw}"], + }), + 0x023 => Some(TokenDef { + len: 0, + rules: &["const::=CONST"], + }), + 0x024 => Some(TokenDef { + len: 0, + rules: &[""], + }), + 0x025 => Some(TokenDef { + len: 0, + rules: &["BYVAL {0}"], + }), + 0x026 => Some(TokenDef { + len: -1, + rules: &["{deffn:1} = {0}"], + }), + 0x027 => Some(TokenDef { + len: 0, + rules: &["COM({0})"], + }), + 0x028 => Some(TokenDef { + len: 2, + rules: &["ON {0} GOSUB {#id}"], + }), + 0x029 => Some(TokenDef { + len: 0, + rules: &["KEY({0})"], + }), + 0x02a => Some(TokenDef { + len: 0, + rules: &["{0} OFF"], + }), + 0x02b => Some(TokenDef { + len: 0, + rules: &["{0} ON"], + }), + 0x02c => Some(TokenDef { + len: 0, + rules: &["{0} STOP"], + }), + 0x02d => Some(TokenDef { + len: 0, + rules: &["PEN"], + }), + 0x02e => Some(TokenDef { + len: 0, + rules: &["PLAY"], + }), + 0x02f => Some(TokenDef { + len: 0, + rules: &["PLAY({0})"], + }), + 0x030 => Some(TokenDef { + len: 0, + rules: &["SIGNAL({0})"], + }), + 0x031 => Some(TokenDef { + len: 0, + rules: &["STRIG({0})"], + }), + 0x032 => Some(TokenDef { + len: 0, + rules: &["TIMER"], + }), + 0x033 => Some(TokenDef { + len: 0, + rules: &["TIMER({0})"], + }), + 0x034 => Some(TokenDef { + len: 4, + rules: &["newline::={#thaddr:0}{#label:2} "], + }), + 0x035 => Some(TokenDef { + len: 6, + rules: &["newline::={#thaddr:0}{#label:2} {#indent:4}"], + }), + 0x037 => Some(TokenDef { + len: 4, + rules: &["CALL {#id:2}{##call()}"], + }), + 0x038 => Some(TokenDef { + len: 4, + rules: &["{#id:2}{##call}"], + }), + 0x039 => Some(TokenDef { + len: 4, + rules: &["CALLS {#id:2}{##call()}"], + }), + 0x03a => Some(TokenDef { + len: 0, + rules: &["CASE ELSE"], + }), + 0x03b => Some(TokenDef { + len: 0, + rules: &["case::={case:1}, {0}", "case::=CASE {0}"], + }), + 0x03c => Some(TokenDef { + len: 0, + rules: &["case::={case:2}, {1} TO {0}", "case::=CASE {1} TO {0}"], + }), + 0x03d => Some(TokenDef { + len: 0, + rules: &["case::={case:1}, IS = {0}", "case::=CASE IS = {0}"], + }), + 0x03e => Some(TokenDef { + len: 0, + rules: &["case::={case:1}, IS < {0}", "case::=CASE IS < {0}"], + }), + 0x03f => Some(TokenDef { + len: 0, + rules: &["case::={case:1}, IS > {0}", "case::=CASE IS > {0}"], + }), + 0x040 => Some(TokenDef { + len: 0, + rules: &["case::={case:1}, IS <= {0}", "case::=CASE IS <= {0}"], + }), + 0x041 => Some(TokenDef { + len: 0, + rules: &["case::={case:1}, IS >= {0}", "case::=CASE IS >= {0}"], + }), + 0x042 => Some(TokenDef { + len: 0, + rules: &["case::={case:1}, IS <> {0}", "case::=CASE IS <> {0}"], + }), + 0x043 => Some(TokenDef { + len: 0, + rules: &["ON"], + }), + 0x044 => Some(TokenDef { + len: -1, + rules: &["DECLARE {#procdecl()}"], + }), + 0x045 => Some(TokenDef { + len: -1, + rules: &["deffn::={#procdecl:2}"], + }), + 0x046 => Some(TokenDef { + len: 0, + rules: &["DO"], + }), + 0x047 => Some(TokenDef { + len: 0, + rules: &["DO UNTIL {0}"], + }), + 0x048 => Some(TokenDef { + len: 2, + rules: &["DO WHILE {0}"], + }), + 0x049 => Some(TokenDef { + len: 2, + rules: &["{newline:0}ELSE ", " ELSE "], + }), + 0x04a => Some(TokenDef { + len: 2, + rules: &["{#id}"], + }), + 0x04c => Some(TokenDef { + len: 0, + rules: &[" ELSE "], + }), + 0x04d => Some(TokenDef { + len: 2, + rules: &["ELSEIF {0} THEN"], + }), + 0x04e => Some(TokenDef { + len: 0, + rules: &["END"], + }), + 0x04f => Some(TokenDef { + len: -1, + rules: &["END DEF"], + }), + 0x050 => Some(TokenDef { + len: 0, + rules: &["END IF"], + }), + 0x051 => Some(TokenDef { + len: 0, + rules: &["END {#proctype}"], + }), + 0x052 => Some(TokenDef { + len: 0, + rules: &["END SELECT"], + }), + 0x053 => Some(TokenDef { + len: 2, + rules: &["EXIT DO"], + }), + 0x054 => Some(TokenDef { + len: 2, + rules: &["EXIT FOR"], + }), + 0x055 => Some(TokenDef { + len: 2, + rules: &["EXIT {#proctype}"], + }), + 0x056 => Some(TokenDef { + len: 4, + rules: &["FOR {2} = {1} TO {0}"], + }), + 0x057 => Some(TokenDef { + len: 4, + rules: &["FOR {3} = {2} TO {1} STEP {0}"], + }), + 0x058 => Some(TokenDef { + len: -1, + rules: &["funcdef::={#procdecl}"], + }), + 0x059 => Some(TokenDef { + len: 2, + rules: &["GOSUB {#id}"], + }), + 0x05b => Some(TokenDef { + len: 2, + rules: &["GOTO {#id}"], + }), + 0x05d => Some(TokenDef { + len: 2, + rules: &["IF {0} THEN "], + }), + 0x05e => Some(TokenDef { + len: 2, + rules: &["IF {0} THEN {#id}"], + }), + 0x060 => Some(TokenDef { + len: 2, + rules: &["IF {0} GOTO {#id}"], + }), + 0x061 => Some(TokenDef { + len: 2, + rules: &["IF {0} THEN"], + }), + 0x062 => Some(TokenDef { + len: 2, + rules: &["LOOP"], + }), + 0x063 => Some(TokenDef { + len: 2, + rules: &["LOOP UNTIL {0}"], + }), + 0x064 => Some(TokenDef { + len: 2, + rules: &["LOOP WHILE {0}"], + }), + 0x065 => Some(TokenDef { + len: 4, + rules: &["NEXT"], + }), + 0x066 => Some(TokenDef { + len: 4, + rules: &["{self:1}, {0}", "NEXT {0}"], + }), + 0x067 => Some(TokenDef { + len: 2, + rules: &["ON ERROR GOTO {#id}"], + }), + 0x068 => Some(TokenDef { + len: -1, + rules: &["ON {0} GOSUB {#id-list}"], + }), + 0x069 => Some(TokenDef { + len: -1, + rules: &["ON {0} GOTO {#id-list}"], + }), + 0x06a => Some(TokenDef { + len: 0, + rules: &["RESTORE"], + }), + 0x06b => Some(TokenDef { + len: 2, + rules: &["RESTORE {#id}"], + }), + 0x06c => Some(TokenDef { + len: 0, + rules: &["RESUME"], + }), + 0x06d => Some(TokenDef { + len: 2, + rules: &["RESUME {#id}"], + }), + 0x06e => Some(TokenDef { + len: 0, + rules: &["RESUME NEXT"], + }), + 0x06f => Some(TokenDef { + len: 0, + rules: &["RETURN"], + }), + 0x070 => Some(TokenDef { + len: 2, + rules: &["RETURN {#id}"], + }), + 0x071 => Some(TokenDef { + len: 0, + rules: &["RUN {0}"], + }), + 0x072 => Some(TokenDef { + len: 2, + rules: &["RUN {#id}"], + }), + 0x073 => Some(TokenDef { + len: 0, + rules: &["RUN"], + }), + 0x074 => Some(TokenDef { + len: 2, + rules: &["SELECT CASE {0}"], + }), + 0x075 => Some(TokenDef { + len: 2, + rules: &["STOP"], + }), + 0x076 => Some(TokenDef { + len: -1, + rules: &["subdef::={#procdecl}"], + }), + 0x077 => Some(TokenDef { + len: 0, + rules: &["WAIT {1}, {0}"], + }), + 0x078 => Some(TokenDef { + len: 0, + rules: &["WAIT {2}, {1}, {0}"], + }), + 0x079 => Some(TokenDef { + len: 2, + rules: &["WEND"], + }), + 0x07a => Some(TokenDef { + len: 2, + rules: &["WHILE {0}"], + }), + 0x07b => Some(TokenDef { + len: 0, + rules: &[""], + }), + 0x07c => Some(TokenDef { + len: 0, + rules: &[""], + }), + 0x07d => Some(TokenDef { + len: 0, + rules: &["prnmod::={prnmod:1} {0},", "prnmod::=PRINT {0},"], + }), + 0x07e => Some(TokenDef { + len: 0, + rules: &["{0}"], + }), + 0x07f => Some(TokenDef { + len: 0, + rules: &["{0}"], + }), + 0x080 => Some(TokenDef { + len: 0, + rules: &["{0}"], + }), + 0x081 => Some(TokenDef { + len: 0, + rules: &["1st-coord::=({1}, {0})"], + }), + 0x082 => Some(TokenDef { + len: 0, + rules: &["1st-coord::=STEP({1}, {0})"], + }), + 0x083 => Some(TokenDef { + len: 0, + rules: &["{1st-coord:2}-({1}, {0})", "({1}, {0})"], + }), + 0x084 => Some(TokenDef { + len: 0, + rules: &["{1st-coord:2}-STEP({1}, {0})", "-STEP({1}, {0})"], + }), + 0x085 => Some(TokenDef { + len: 0, + rules: &["FIELD {0}"], + }), + 0x086 => Some(TokenDef { + len: 0, + rules: &[", {1} AS {0}"], + }), + 0x087 => Some(TokenDef { + len: 0, + rules: &["finput::=INPUT {0},"], + }), + 0x088 => Some(TokenDef { + len: 0, + rules: &["{input|finput:1} {inputs:0}"], + }), + 0x089 => Some(TokenDef { + len: -1, + rules: &["input::=INPUT {##input-args}"], + }), + 0x08a => Some(TokenDef { + len: 0, + rules: &["#{0}"], + }), + 0x08c => Some(TokenDef { + len: 2, + rules: &[""], + }), + 0x08f => Some(TokenDef { + len: 0, + rules: &[ + "prnsmc::={self|prncma|prnsrl:1} SPC({0});", + "prnsmc::=SPC({0});", + ], + }), + 0x090 => Some(TokenDef { + len: 0, + rules: &[ + "prnsmc::={self|prncma|prnsrl:1} TAB({0});", + "prnsmc::=TAB({0});", + ], + }), + 0x091 => Some(TokenDef { + len: 0, + rules: &["prncma::={self|prnsmc|prnsrl:0} ,", "prncma::=,"], + }), + 0x092 => Some(TokenDef { + len: 0, + rules: &[ + "prnsmc::={self:0}", + "prnsmc::={prncma|prnsrl:0} ;", + "prnsmc::=;", + ], + }), + 0x093 => Some(TokenDef { + len: 0, + rules: &[ + "{prnmod:2} {prnuse:1} {prnsrl|prnsmc|prncma:0}", + "{prnmod:1} {prnsrl|prnsmc|prncma:0}", + "{prnmod:1} {prnuse:0}", + "{prnmod:1}", + "PRINT {prnuse:1} {prnsrl|prnsmc|prncma:0}", + "PRINT {prnsrl|prnsmc|prncma:0}", + "PRINT {prnuse:0}", + "PRINT", + ], + }), + 0x094 => Some(TokenDef { + len: 0, + rules: &[ + "prnsrl::={prncma|prnsmc|self:1} {expr:0},", + "prnsrl::={expr:0},", + ], + }), + 0x095 => Some(TokenDef { + len: 0, + rules: &[ + "prnsrl::={prncma|prnsmc|self:1} {expr:0};", + "prnsrl::={expr:0};", + ], + }), + 0x096 => Some(TokenDef { + len: 0, + rules: &[ + "{prnmod:3} {prnuse:2} {prnsmc|prncma|prnsrl:1} {expr:0}", + "{prnmod:2} {prnsmc|prncma|prnsrl:1} {expr:0}", + "{prnmod:1} {prnsmc|prncma|prnsrl|expr:0}", + "PRINT {prnuse:2} {prnsmc|prncma|prnsrl:1} {expr:0}", + "PRINT {prnsmc|prncma|prnsrl:1} {expr:0}", + "PRINT {prnsmc|prncma|prnsrl|expr:0}", + ], + }), + 0x097 => Some(TokenDef { + len: -1, + rules: &["{#tabi:0}'{#raw:2}"], + }), + 0x099 => Some(TokenDef { + len: -1, + rules: &["$INCLUDE: '{#raw:0}"], + }), + 0x09a => Some(TokenDef { + len: 0, + rules: &["BEEP"], + }), + 0x09b => Some(TokenDef { + len: 0, + rules: &["BLOAD {0}"], + }), + 0x09c => Some(TokenDef { + len: 0, + rules: &["BLOAD {1}, {0}"], + }), + 0x09d => Some(TokenDef { + len: 0, + rules: &["BSAVE {2}, {1}, {0}"], + }), + 0x09e => Some(TokenDef { + len: 0, + rules: &["CHDIR {0}"], + }), + 0x09f => Some(TokenDef { + len: 0, + rules: &["CIRCLE {##circle-args}"], + }), + 0x0a0 => Some(TokenDef { + len: 0, + rules: &["CIRCLE {##circle-args}"], + }), + 0x0a1 => Some(TokenDef { + len: 2, + rules: &["CLEAR{##varargs}"], + }), + 0x0a2 => Some(TokenDef { + len: 2, + rules: &["CLOSE{##varargs}"], + }), + 0x0a3 => Some(TokenDef { + len: 0, + rules: &["CLS {expr:0}", "CLS "], + }), + 0x0a4 => Some(TokenDef { + len: 2, + rules: &["COLOR{##varargs}"], + }), + 0x0a5 => Some(TokenDef { + len: 4, + rules: &[ + "decl::=COMMON {declmod:0}{#blockname:2}", + "decl::=COMMON{#blockname:2}", + ], + }), + 0x0a6 => Some(TokenDef { + len: -1, + rules: &["DATA{#cstr:2}"], + }), + 0x0a7 => Some(TokenDef { + len: 0, + rules: &["DATE$ = {0}"], + }), + 0x0a8 => Some(TokenDef { + len: 0, + rules: &["DEF SEG"], + }), + 0x0a9 => Some(TokenDef { + len: 0, + rules: &["DEF SEG = {0}"], + }), + 0x0aa => Some(TokenDef { + len: 0, + rules: &["DRAW {0}"], + }), + 0x0ab => Some(TokenDef { + len: 0, + rules: &["ENVIRON {0}"], + }), + 0x0ac => Some(TokenDef { + len: 2, + rules: &["ERASE{##varargs}"], + }), + 0x0ad => Some(TokenDef { + len: 0, + rules: &["ERROR {0}"], + }), + 0x0ae => Some(TokenDef { + len: 0, + rules: &["FILES"], + }), + 0x0af => Some(TokenDef { + len: 0, + rules: &["FILES {0}"], + }), + 0x0b0 => Some(TokenDef { + len: 0, + rules: &["GET {0}"], + }), + 0x0b1 => Some(TokenDef { + len: 0, + rules: &["GET {1}, {0}"], + }), + 0x0b2 => Some(TokenDef { + len: 2, + rules: &["GET {1}, , {0}"], + }), + 0x0b3 => Some(TokenDef { + len: 2, + rules: &["GET {2}, {1}, {0}"], + }), + 0x0b4 => Some(TokenDef { + len: 0, + rules: &["GET {1}, {0}"], + }), + 0x0b5 => Some(TokenDef { + len: 2, + rules: &["PUT {1}, {0}, {#action-verb}"], + }), + 0x0b6 => Some(TokenDef { + len: 0, + rules: &["inputs::={inputs:1}, {0}", "inputs::={0}"], + }), + 0x0b7 => Some(TokenDef { + len: 0, + rules: &["IOCTL {1}, {0}"], + }), + 0x0b8 => Some(TokenDef { + len: 2, + rules: &["KEY {#keymode}"], + }), + 0x0b9 => Some(TokenDef { + len: 0, + rules: &["KEY {1}, {0}"], + }), + 0x0ba => Some(TokenDef { + len: 0, + rules: &["KILL {0}"], + }), + 0x0bb => Some(TokenDef { + len: 2, + rules: &["LINE {##line-args}"], + }), + 0x0bc => Some(TokenDef { + len: 2, + rules: &["LINE {##line-args}"], + }), + 0x0bd => Some(TokenDef { + len: 2, + rules: &["LINE {##line-args}"], + }), + 0x0be => Some(TokenDef { + len: 2, + rules: &["LINE {##line-args}"], + }), + 0x0bf => Some(TokenDef { + len: 0, + rules: &["LET "], + }), + 0x0c0 => Some(TokenDef { + len: 2, + rules: &[ + "input::=LINE {finput:1} {0}", + "input::=LINE INPUT {##input-args} {0}", + ], + }), + 0x0c1 => Some(TokenDef { + len: 2, + rules: &["LOCATE{##varargs}"], + }), + 0x0c2 => Some(TokenDef { + len: 2, + rules: &["LOCK {##lock-args}"], + }), + 0x0c3 => Some(TokenDef { + len: 0, + rules: &["prnmod::=LPRINT"], + }), + 0x0c4 => Some(TokenDef { + len: 0, + rules: &["LSET {0} = {1}"], + }), + 0x0c5 => Some(TokenDef { + len: 0, + rules: &["MID$({0}, {2}) = {1}"], + }), + 0x0c6 => Some(TokenDef { + len: 0, + rules: &["MID$({0}, {3}, {2}) = {1}"], + }), + 0x0c7 => Some(TokenDef { + len: 0, + rules: &["MKDIR {0}"], + }), + 0x0c8 => Some(TokenDef { + len: 0, + rules: &["NAME {1} AS {0}"], + }), + 0x0c9 => Some(TokenDef { + len: 2, + rules: &["OPEN {1} {#open-args} AS {0}"], + }), + 0x0ca => Some(TokenDef { + len: 2, + rules: &["OPEN {2} {#open-args} AS {1} LEN = {0}"], + }), + 0x0cb => Some(TokenDef { + len: 0, + rules: &["OPEN {2}, {1}, {0}"], + }), + 0x0cc => Some(TokenDef { + len: 0, + rules: &["OPEN {3}, {2}, {1}, {0}"], + }), + 0x0cd => Some(TokenDef { + len: 0, + rules: &["OPTION BASE 0"], + }), + 0x0ce => Some(TokenDef { + len: 0, + rules: &["OPTION BASE 1"], + }), + 0x0cf => Some(TokenDef { + len: 0, + rules: &["OUT {1}, {0}"], + }), + 0x0d0 => Some(TokenDef { + len: 0, + rules: &[ + "PAINT {2}{nularg:1}{nularg:0}", + "PAINT {2}, {nularg:1}, {0}", + "PAINT {2}, {1}{nularg:0}", + "PAINT {2}, {1}, {0}", + ], + }), + 0x0d1 => Some(TokenDef { + len: 0, + rules: &["PAINT {3}, {2}, {1}, {0}"], + }), + 0x0d2 => Some(TokenDef { + len: 0, + rules: &["PALETTE"], + }), + 0x0d3 => Some(TokenDef { + len: 0, + rules: &["PALETTE {1}, {0}"], + }), + 0x0d4 => Some(TokenDef { + len: 0, + rules: &["PALETTE {0}"], + }), + 0x0d5 => Some(TokenDef { + len: 0, + rules: &["PCOPY {1}, {0}"], + }), + 0x0d6 => Some(TokenDef { + len: 0, + rules: &["PLAY {0}"], + }), + 0x0d7 => Some(TokenDef { + len: 0, + rules: &["POKE {1}, {0}"], + }), + 0x0d8 => Some(TokenDef { + len: 0, + rules: &["PRESET {0}"], + }), + 0x0d9 => Some(TokenDef { + len: 0, + rules: &["PRESET {0}, {1}"], + }), + 0x0da => Some(TokenDef { + len: 0, + rules: &["PSET {0}"], + }), + 0x0db => Some(TokenDef { + len: 0, + rules: &["PSET {1}, {0}"], + }), + 0x0dd => Some(TokenDef { + len: 0, + rules: &["PUT {1}, {0}"], + }), + 0x0de => Some(TokenDef { + len: 2, + rules: &["PUT {1}, , {0}"], + }), + 0x0df => Some(TokenDef { + len: 2, + rules: &["PUT {2}, {1}, {0}"], + }), + 0x0e0 => Some(TokenDef { + len: 0, + rules: &["RANDOMIZE"], + }), + 0x0e1 => Some(TokenDef { + len: 0, + rules: &["RANDOMIZE {0}"], + }), + 0x0e2 => Some(TokenDef { + len: 0, + rules: &["{self:1}, {0}", "READ {0}"], + }), + 0x0e3 => Some(TokenDef { + len: -1, + rules: &["REM{#raw}"], + }), + 0x0e4 => Some(TokenDef { + len: 0, + rules: &["RESET"], + }), + 0x0e5 => Some(TokenDef { + len: 0, + rules: &["RMDIR {0}"], + }), + 0x0e6 => Some(TokenDef { + len: 0, + rules: &["RSET {0} = {1}"], + }), + 0x0e7 => Some(TokenDef { + len: 2, + rules: &["SCREEN{##varargs}"], + }), + 0x0e8 => Some(TokenDef { + len: 0, + rules: &["SEEK {1}, {0}"], + }), + 0x0e9 => Some(TokenDef { + len: 0, + rules: &["SHELL"], + }), + 0x0ea => Some(TokenDef { + len: 0, + rules: &["SHELL {0}"], + }), + 0x0eb => Some(TokenDef { + len: 0, + rules: &["SLEEP"], + }), + 0x0ec => Some(TokenDef { + len: 0, + rules: &["SOUND {1}, {0}"], + }), + 0x0ed => Some(TokenDef { + len: 2, + rules: &["SWAP {1}, {0}"], + }), + 0x0ee => Some(TokenDef { + len: 0, + rules: &["SYSTEM"], + }), + 0x0ef => Some(TokenDef { + len: 0, + rules: &["TIME$ = {0}"], + }), + 0x0f0 => Some(TokenDef { + len: 0, + rules: &["TROFF"], + }), + 0x0f1 => Some(TokenDef { + len: 0, + rules: &["TRON"], + }), + 0x0f2 => Some(TokenDef { + len: 2, + rules: &["UNLOCK {##lock-args}"], + }), + 0x0f3 => Some(TokenDef { + len: 0, + rules: &[ + "VIEW ({5}, {4})-({3}, {2}){nularg:1}{nularg:0}", + "VIEW ({5}, {4})-({3}, {2}), {nularg:1}, {0}", + "VIEW ({5}, {4})-({3}, {2}), {1}{nularg:0}", + "VIEW ({5}, {4})-({3}, {2})", + ], + }), + 0x0f4 => Some(TokenDef { + len: 0, + rules: &["VIEW"], + }), + 0x0f5 => Some(TokenDef { + len: 0, + rules: &["VIEW PRINT"], + }), + 0x0f6 => Some(TokenDef { + len: 0, + rules: &["VIEW PRINT {1} TO {0}"], + }), + 0x0f7 => Some(TokenDef { + len: 0, + rules: &[ + "VIEW SCREEN ({5}, {4})-({3}, {2}){nularg:1}{nularg:0}", + "VIEW SCREEN ({5}, {4})-({3}, {2}), {nularg:1}, {0}", + "VIEW SCREEN ({5}, {4})-({3}, {2}), {1}{nularg:0}", + "VIEW SCREEN ({5}, {4})-({3}, {2})", + ], + }), + 0x0f8 => Some(TokenDef { + len: 0, + rules: &["WIDTH {1}{nularg:0}", "WIDTH {1}, {0}"], + }), + 0x0f9 => Some(TokenDef { + len: 0, + rules: &["WIDTH LPRINT {0}"], + }), + 0x0fa => Some(TokenDef { + len: 0, + rules: &["WIDTH {1}, {0}"], + }), + 0x0fb => Some(TokenDef { + len: 0, + rules: &["WINDOW ({3}, {2})-({1}, {0})"], + }), + 0x0fc => Some(TokenDef { + len: 0, + rules: &["WINDOW"], + }), + 0x0fd => Some(TokenDef { + len: 0, + rules: &["WINDOW SCREEN ({3}, {2})-({1}, {0})"], + }), + 0x0fe => Some(TokenDef { + len: 0, + rules: &["prnmod::=WRITE"], + }), + 0x0ff => Some(TokenDef { + len: 0, + rules: &["prnuse::=USING {0};"], + }), + 0x100 => Some(TokenDef { + len: 0, + rules: &["expr::={1} + {0}"], + }), + 0x101 => Some(TokenDef { + len: 0, + rules: &["expr::={1} AND {0}"], + }), + 0x102 => Some(TokenDef { + len: 0, + rules: &["expr::={1} / {0}"], + }), + 0x103 => Some(TokenDef { + len: 0, + rules: &["expr::={1} = {0}"], + }), + 0x104 => Some(TokenDef { + len: 0, + rules: &["expr::={1} EQV {0}"], + }), + 0x105 => Some(TokenDef { + len: 0, + rules: &["expr::=ABS({0})"], + }), + 0x106 => Some(TokenDef { + len: 0, + rules: &["expr::=ASC({0})"], + }), + 0x107 => Some(TokenDef { + len: 0, + rules: &["expr::=ATN({0})"], + }), + 0x108 => Some(TokenDef { + len: 0, + rules: &["expr::=C{#type-abbr}({0})"], + }), + 0x109 => Some(TokenDef { + len: 0, + rules: &["expr::=CHR$({0})"], + }), + 0x10a => Some(TokenDef { + len: 0, + rules: &["expr::=COMMAND$"], + }), + 0x10b => Some(TokenDef { + len: 0, + rules: &["expr::=COS({0})"], + }), + 0x10c => Some(TokenDef { + len: 0, + rules: &["expr::=CSRLIN"], + }), + 0x10d => Some(TokenDef { + len: 0, + rules: &["expr::=CVD({0})"], + }), + 0x10e => Some(TokenDef { + len: 0, + rules: &["expr::=CVDMBD({0})"], + }), + 0x10f => Some(TokenDef { + len: 0, + rules: &["expr::=CVI({0})"], + }), + 0x110 => Some(TokenDef { + len: 0, + rules: &["expr::=CVL({0})"], + }), + 0x111 => Some(TokenDef { + len: 0, + rules: &["expr::=CVS({0})"], + }), + 0x112 => Some(TokenDef { + len: 0, + rules: &["expr::=CVSMBF({0})"], + }), + 0x113 => Some(TokenDef { + len: 0, + rules: &["expr::=DATE$"], + }), + 0x114 => Some(TokenDef { + len: 0, + rules: &["expr::=ENVIRON$({0})"], + }), + 0x115 => Some(TokenDef { + len: 0, + rules: &["expr::=EOF({0})"], + }), + 0x116 => Some(TokenDef { + len: 0, + rules: &["expr::=ERDEV"], + }), + 0x117 => Some(TokenDef { + len: 0, + rules: &["expr::=ERDEV$"], + }), + 0x118 => Some(TokenDef { + len: 0, + rules: &["expr::=ERL"], + }), + 0x119 => Some(TokenDef { + len: 0, + rules: &["expr::=ERR"], + }), + 0x11a => Some(TokenDef { + len: 0, + rules: &["expr::=EXP({0})"], + }), + 0x11b => Some(TokenDef { + len: 0, + rules: &["expr::=FILEATTR({1}, {0})"], + }), + 0x11c => Some(TokenDef { + len: 0, + rules: &["expr::=FIX({0})"], + }), + 0x11d => Some(TokenDef { + len: 0, + rules: &["expr::=FRE({0})"], + }), + 0x11e => Some(TokenDef { + len: 0, + rules: &["expr::=FREEFILE"], + }), + 0x11f => Some(TokenDef { + len: 0, + rules: &["expr::=HEX$({0})"], + }), + 0x120 => Some(TokenDef { + len: 0, + rules: &["expr::=INKEY$"], + }), + 0x121 => Some(TokenDef { + len: 0, + rules: &["expr::=INP({0})"], + }), + 0x122 => Some(TokenDef { + len: 0, + rules: &["expr::=INPUT$({0})"], + }), + 0x123 => Some(TokenDef { + len: 0, + rules: &["expr::=INPUT$({1}, {0})"], + }), + 0x124 => Some(TokenDef { + len: 0, + rules: &["expr::=INSTR({1}, {0})"], + }), + 0x125 => Some(TokenDef { + len: 0, + rules: &["expr::=INSTR({2}, {1}, {0})"], + }), + 0x126 => Some(TokenDef { + len: 0, + rules: &["expr::=INT({0})"], + }), + 0x127 => Some(TokenDef { + len: 0, + rules: &["expr::=IOCTL$({0})"], + }), + 0x128 => Some(TokenDef { + len: 0, + rules: &["expr::=LBOUND({0})"], + }), + 0x129 => Some(TokenDef { + len: 0, + rules: &["expr::=LBOUND({1}, {0})"], + }), + 0x12a => Some(TokenDef { + len: 0, + rules: &["expr::=LCASE$({0})"], + }), + 0x12b => Some(TokenDef { + len: 0, + rules: &["expr::=LTRIM$({0})"], + }), + 0x12c => Some(TokenDef { + len: 0, + rules: &["expr::=LEFT$({1}, {0})"], + }), + 0x12d => Some(TokenDef { + len: 2, + rules: &["expr::=LEN({0})"], + }), + 0x12e => Some(TokenDef { + len: 0, + rules: &["expr::=LOC({0})"], + }), + 0x12f => Some(TokenDef { + len: 0, + rules: &["expr::=LOF({0})"], + }), + 0x130 => Some(TokenDef { + len: 0, + rules: &["expr::=LOG({0})"], + }), + 0x131 => Some(TokenDef { + len: 0, + rules: &["expr::=LPOS({0})"], + }), + 0x132 => Some(TokenDef { + len: 0, + rules: &["expr::=MID$({1}, {0})"], + }), + 0x133 => Some(TokenDef { + len: 0, + rules: &["expr::=MID$({2}, {1}, {0})"], + }), + 0x134 => Some(TokenDef { + len: 0, + rules: &["expr::=MKD$({0})"], + }), + 0x135 => Some(TokenDef { + len: 0, + rules: &["expr::=MKDMBF$({0})"], + }), + 0x136 => Some(TokenDef { + len: 0, + rules: &["expr::=MKI$({0})"], + }), + 0x137 => Some(TokenDef { + len: 0, + rules: &["expr::=MKL$({0})"], + }), + 0x138 => Some(TokenDef { + len: 0, + rules: &["expr::=MKS$({0})"], + }), + 0x139 => Some(TokenDef { + len: 0, + rules: &["expr::=MKSMBF({0})"], + }), + 0x13a => Some(TokenDef { + len: 0, + rules: &["expr::=OCT$({0})"], + }), + 0x13b => Some(TokenDef { + len: 0, + rules: &["expr::=PEEK({0})"], + }), + 0x13c => Some(TokenDef { + len: 0, + rules: &["expr::=PEN"], + }), + 0x13d => Some(TokenDef { + len: 0, + rules: &["expr::=PLAY"], + }), + 0x13e => Some(TokenDef { + len: 0, + rules: &["expr::=PMAP({1}, {0})"], + }), + 0x13f => Some(TokenDef { + len: 0, + rules: &["expr::=POINT({0})"], + }), + 0x140 => Some(TokenDef { + len: 0, + rules: &["expr::=POINT({1}, {0})"], + }), + 0x141 => Some(TokenDef { + len: 0, + rules: &["expr::=POS({0})"], + }), + 0x142 => Some(TokenDef { + len: 0, + rules: &["expr::=RIGHT$({1}, {0})"], + }), + 0x143 => Some(TokenDef { + len: 0, + rules: &["expr::=RND"], + }), + 0x144 => Some(TokenDef { + len: 0, + rules: &["expr::=RND({0})"], + }), + 0x145 => Some(TokenDef { + len: 0, + rules: &["expr::=RTRIM$({0})"], + }), + 0x146 => Some(TokenDef { + len: 0, + rules: &["expr::=SADD({0})"], + }), + 0x147 => Some(TokenDef { + len: 0, + rules: &["expr::=SCREEN({1}, {0})"], + }), + 0x148 => Some(TokenDef { + len: 0, + rules: &["expr::=SCREEN({2}, {1}, {0})"], + }), + 0x149 => Some(TokenDef { + len: 0, + rules: &["expr::=SEEK({0})"], + }), + 0x14a => Some(TokenDef { + len: 0, + rules: &["expr::=SETMEM({0})"], + }), + 0x14b => Some(TokenDef { + len: 0, + rules: &["expr::=SGN({0})"], + }), + 0x14c => Some(TokenDef { + len: 0, + rules: &["expr::=SHELL({0})"], + }), + 0x14d => Some(TokenDef { + len: 0, + rules: &["expr::=SIN({0})"], + }), + 0x14e => Some(TokenDef { + len: 0, + rules: &["expr::=SPACE$({0})"], + }), + 0x14f => Some(TokenDef { + len: 0, + rules: &["expr::=SQR({0})"], + }), + 0x150 => Some(TokenDef { + len: 0, + rules: &["expr::=STICK({0})"], + }), + 0x151 => Some(TokenDef { + len: 0, + rules: &["expr::=STR$({0})"], + }), + 0x152 => Some(TokenDef { + len: 0, + rules: &["expr::=STRIG({0})"], + }), + 0x153 => Some(TokenDef { + len: 0, + rules: &["expr::=STRING$({1}, {0})"], + }), + 0x154 => Some(TokenDef { + len: 0, + rules: &["expr::=TAN({0})"], + }), + 0x155 => Some(TokenDef { + len: 0, + rules: &["expr::=TIME$"], + }), + 0x156 => Some(TokenDef { + len: 0, + rules: &["expr::=TIMER"], + }), + 0x157 => Some(TokenDef { + len: 0, + rules: &["expr::=UBOUND({0})"], + }), + 0x158 => Some(TokenDef { + len: 0, + rules: &["expr::=UBOUND({1}, {0})"], + }), + 0x159 => Some(TokenDef { + len: 0, + rules: &["expr::=UCASE$({0})"], + }), + 0x15a => Some(TokenDef { + len: 0, + rules: &["expr::=VAL({0})"], + }), + 0x15b => Some(TokenDef { + len: 0, + rules: &["expr::=VARPTR({0})"], + }), + 0x15c => Some(TokenDef { + len: 2, + rules: &["expr::=VARPTR$({0})"], + }), + 0x15d => Some(TokenDef { + len: 0, + rules: &["expr::=VARSEG({0})"], + }), + 0x15e => Some(TokenDef { + len: 0, + rules: &["expr::={1} >= {0}"], + }), + 0x15f => Some(TokenDef { + len: 0, + rules: &["expr::={1} > {0}"], + }), + 0x160 => Some(TokenDef { + len: 0, + rules: &["expr::={1} \\ {0}"], + }), + 0x161 => Some(TokenDef { + len: 0, + rules: &["expr::={1} IMP {0}"], + }), + 0x162 => Some(TokenDef { + len: 0, + rules: &["expr::={1} <= {0}"], + }), + 0x163 => Some(TokenDef { + len: 0, + rules: &["expr::={1} < {0}"], + }), + 0x164 => Some(TokenDef { + len: 0, + rules: &["expr::={#hprm}"], + }), + 0x165 => Some(TokenDef { + len: 2, + rules: &["expr::={#int}"], + }), + 0x166 => Some(TokenDef { + len: 4, + rules: &["expr::={#lng}"], + }), + 0x167 => Some(TokenDef { + len: 2, + rules: &["expr::={#int&h}"], + }), + 0x168 => Some(TokenDef { + len: 4, + rules: &["expr::={#lng&h}"], + }), + 0x169 => Some(TokenDef { + len: 2, + rules: &["expr::={#int&o}"], + }), + 0x16a => Some(TokenDef { + len: 4, + rules: &["expr::={#lng&o}"], + }), + 0x16b => Some(TokenDef { + len: 4, + rules: &["expr::={#sng}"], + }), + 0x16c => Some(TokenDef { + len: 8, + rules: &["expr::={#dbl}"], + }), + 0x16d => Some(TokenDef { + len: -1, + rules: &["expr::={#qstr}"], + }), + 0x16e => Some(TokenDef { + len: 0, + rules: &["expr::=({0})"], + }), + 0x16f => Some(TokenDef { + len: 0, + rules: &["expr::={1} MOD {0}"], + }), + 0x170 => Some(TokenDef { + len: 0, + rules: &["expr::={1} * {0}"], + }), + 0x171 => Some(TokenDef { + len: 0, + rules: &["expr::={1} <> {0}"], + }), + 0x172 => Some(TokenDef { + len: 0, + rules: &["expr::={#nul}"], + }), + 0x173 => Some(TokenDef { + len: 0, + rules: &["nularg::={#nul}"], + }), + 0x174 => Some(TokenDef { + len: 0, + rules: &["expr::=NOT {0}"], + }), + 0x175 => Some(TokenDef { + len: 0, + rules: &["expr::={1} OR {0}"], + }), + 0x176 => Some(TokenDef { + len: 0, + rules: &["expr::={1} ^ {0}"], + }), + 0x177 => Some(TokenDef { + len: 0, + rules: &["expr::={1} - {0}"], + }), + 0x178 => Some(TokenDef { + len: 0, + rules: &["expr::=-{0}"], + }), + 0x179 => Some(TokenDef { + len: 0, + rules: &["expr::={1} XOR {0}"], + }), + 0x17a => Some(TokenDef { + len: 0, + rules: &["UEVENT"], + }), + 0x17b => Some(TokenDef { + len: 0, + rules: &["SLEEP {0}"], + }), + 0x17c => Some(TokenDef { + len: 6, + rules: &["astype::={#tabi:4}AS STRING * {#int:2}"], + }), + 0x17d => Some(TokenDef { + len: 2, + rules: &["decl::=DIM {declmod:0}", "decl::=DIM"], + }), + 0x181 => Some(TokenDef { + len: 0, + rules: &["expr::=CURDIR$"], + }), + 0x183 => Some(TokenDef { + len: 0, + rules: &["expr::=ERROR$"], + }), + 0x184 => Some(TokenDef { + len: 0, + rules: &["expr::=DIR$({0})"], + }), + 0x186 => Some(TokenDef { + len: 0, + rules: &["expr::=BOF({0})"], + }), + 0x188 => Some(TokenDef { + len: 0, + rules: &["expr::=GETINDEX$({0})"], + }), + 0x194 => Some(TokenDef { + len: 0, + rules: &["expr::={0}"], + }), + 0x192 => Some(TokenDef { + len: 0, + rules: &["DELETE {0}"], + }), + 0x197 => Some(TokenDef { + len: 0, + rules: &["INSERT {1}, {0}"], + }), + 0x198 => Some(TokenDef { + len: 2, + rules: &[ + "{#method:0}MOVEFIRST {0}", + "{#method:4}MOVELAST {0}", + "{#method:8}MOVENEXT {0}", + "{#method:12}MOVEPREVIOUS {0}", + ], + }), + 0x199 => Some(TokenDef { + len: 2, + rules: &["ON LOCAL ERROR GOTO {#id}"], + }), + 0x19b => Some(TokenDef { + len: 2, + rules: &["RETRIEVE {1}, {0}"], + }), + 0x19f => Some(TokenDef { + len: 0, + rules: &["SEEKGT {1}, {0}"], + }), + 0x1a1 => Some(TokenDef { + len: 0, + rules: &["SETINDEX {1}, {0}"], + }), + 0x1a8 => Some(TokenDef { + len: 0, + rules: &["REDIM {0}"], + }), + 0x1a9 => Some(TokenDef { + len: 0, + rules: &["implicit-control::="], + }), + 0x1aa => Some(TokenDef { + len: 2, + rules: &["expr::=TYPEOF {0} IS {#id}"], + }), + 0x1ab => Some(TokenDef { + len: 0, + rules: &["LOAD {0}"], + }), + 0x1ac => Some(TokenDef { + len: 0, + rules: &["UNLOAD {0}"], + }), + 0x1ad => Some(TokenDef { + len: 0, + rules: &["expr::=DOEVENTS"], + }), + 0x1b0 => Some(TokenDef { + len: 0, + rules: &["expr::=ERROR$"], + }), + 0x1b2 => Some(TokenDef { + len: 0, + rules: &["expr::=MSGBOX({2}, {1}, {0})"], + }), + 0x1b3 => Some(TokenDef { + len: 0, + rules: &["expr::=FORMAT$({1}, {0})"], + }), + 0x1ba => Some(TokenDef { + len: 0, + rules: &["expr::=DATE$"], + }), + 0x1bc => Some(TokenDef { + len: 0, + rules: &["expr::=TIMEVALUE({0})"], + }), + 0x1c0 => Some(TokenDef { + len: 2, + rules: &[""], + }), + 0x1c1 => Some(TokenDef { + len: 0, + rules: &["expr::={0}"], + }), + 0x1c2 => Some(TokenDef { + len: -1, + rules: &[""], + }), + 0x1c3 => Some(TokenDef { + len: 0, + rules: &["expr::=INPUTBOX$({2}, {1}, {0})"], + }), + 0x1c5 => Some(TokenDef { + len: 0, + rules: &[""], + }), + 0x1c6 => Some(TokenDef { + len: 0, + rules: &[""], + }), + 0x1c7 | 0x1c8 => Some(TokenDef { + len: 0, + rules: &["nularg::="], + }), + 0x1c9 => Some(TokenDef { + len: 2, + rules: &[ + "controlstmt::={#method:1}{0}.CLS", + "controlstmt::={#method:2}{implicit-control:0}HIDE", + "controlstmt::={#method:2}{0}.HIDE", + "prnmod::={#method:4}{0}.PRINT", + "controlstmt::={#method:6}{0}.REFRESH", + "controlstmt::={#method:8}{0}.SETFOCUS", + "controlstmt::={#method:9}{implicit-control:0}SHOW", + "controlstmt::={#method:9}{0}.SHOW", + "controlstmt::={#method:14}{0}.ENDDOC", + "controlstmt::={#method:16}{0}.NEWPAGE", + ], + }), + 0x1ca => Some(TokenDef { + len: 2, + rules: &["{1}.ADDITEM {0}"], + }), + 0x1cb => Some(TokenDef { + len: 2, + rules: &["{1}.REMOVEITEM {0}"], + }), + 0x1cc => Some(TokenDef { + len: 2, + rules: &["{2}.MOVE {1}, {0}"], + }), + 0x1ce => Some(TokenDef { + len: 4, + rules: &["{4}.MOVE {3}, {2}, {1}, {0}"], + }), + 0x1d0 => Some(TokenDef { + len: 2, + rules: &["expr::={0}.GETTEXT"], + }), + 0x3ff => Some(TokenDef { + len: 0, + rules: &[""], + }), + _ => None, + } +} + +#[derive(Clone)] +struct Item { + kind: String, + text: String, +} + +struct Decoder<'a> { + file: &'a str, + sym: &'a [u8], + code: &'a [u8], + base: usize, + at: usize, + pcode: u16, + hparam: u16, + stack: Vec, + pending: String, + lines: Vec, + procedure_type: &'static str, + modified_defaults: u32, + return_name: Option, + return_suffix: &'static str, +} + +impl<'a> Decoder<'a> { + fn new(file: &'a str, sym: &'a [u8], code: &'a [u8], base: usize) -> Self { + Self { + file, + sym, + code, + base, + at: 0, + pcode: 0, + hparam: 0, + stack: Vec::new(), + pending: String::new(), + lines: Vec::new(), + procedure_type: "SUB", + modified_defaults: 0, + return_name: None, + return_suffix: "", + } + } + + fn fail(&self, at: usize, name: impl Into, message: impl Into) -> FormError { + error(self.file, self.base + at, name, message) + } + + fn ident(&self, offset: u16) -> Result { + if matches!(offset, u16::MAX | 0xfffe) { + return Ok("0".into()); + } + let at = offset as usize; + let header = self.sym.get(at..at + 4).ok_or_else(|| { + self.fail( + self.at, + format!("Symbol 0x{offset:04x}"), + "ungueltiger Symbolverweis", + ) + })?; + if header[2] & 2 != 0 { + return read_u16(self.sym, at + 4) + .map(|value| value.to_string()) + .ok_or_else(|| self.fail(self.at, "Symboltabelle", "abgeschnittene Zeilennummer")); + } + let len = header[3] as usize; + self.sym + .get(at + 4..at + 4 + len) + .map(decode_cp437) + .ok_or_else(|| self.fail(self.at, "Symboltabelle", "abgeschnittener Name")) + } + + fn suffix(kind: u16) -> &'static str { + match kind { + 1 => "%", + 2 => "&", + 3 => "!", + 4 => "#", + 5 => "@", + 7 => "$", + _ => "", + } + } + + fn type_name(&self, kind: u16) -> Result { + Ok(match kind { + 0 => "ANY".into(), + 1 => "INTEGER".into(), + 2 => "LONG".into(), + 3 => "SINGLE".into(), + 4 => "DOUBLE".into(), + 5 => "CURRENCY".into(), + 7 => "STRING".into(), + 9 => "CONTROL".into(), + // VBDOS verwendet fuer eingebaute Typen zusaetzlich die + // internen Kennungen des Editors. + 0x013e => "STRING".into(), + other => self.ident(other)?, + }) + } + + fn peek(&self, offset: usize) -> Option<&Item> { + self.stack.get(self.stack.len().checked_sub(offset + 1)?) + } + + fn split_tag<'b>(&self, tag: &'b str) -> (&'b str, isize, bool) { + if let Some(value) = tag.strip_prefix('$') { + return ("", value.parse().unwrap_or(0), true); + } + if let Some((kind, value)) = tag.rsplit_once(':') { + let relative = value.starts_with('$'); + let value = value.strip_prefix('$').unwrap_or(value); + return (kind, value.parse().unwrap_or(0), relative); + } + if let Ok(offset) = tag.parse() { + ("", offset, false) + } else { + (tag, 0, false) + } + } + + fn valid(&self, kind: &str, offset: isize, own: &str) -> bool { + let Some(item) = usize::try_from(offset) + .ok() + .and_then(|offset| self.peek(offset)) + else { + return kind.is_empty(); + }; + kind.is_empty() + || kind.split('|').any(|expected| { + expected == "*" || item.kind == if expected == "self" { own } else { expected } + }) + } + + fn word(data: &[u8], at: usize) -> Option { + read_u16(data, at) + } + + fn call(&self, data: &[u8], parentheses: bool) -> Result { + let count = Self::word(data, 0) + .ok_or_else(|| self.fail(self.at, "CALL", "Argumentzahl fehlt"))? + as usize; + let args = (0..count) + .rev() + .map(|index| format!("{{{index}}}")) + .collect::>() + .join(", "); + Ok(if args.is_empty() { + String::new() + } else if parentheses { + format!("({args})") + } else { + format!(" {args}") + }) + } + + fn proc_decl( + &mut self, + data: &[u8], + offset: usize, + parentheses: bool, + ) -> Result { + let id = + Self::word(data, offset).ok_or_else(|| self.fail(self.at, "Prozedur", "Name fehlt"))?; + let flags = Self::word(data, offset + 2) + .ok_or_else(|| self.fail(self.at, "Prozedur", "Flags fehlen"))?; + let count = Self::word(data, offset + 4) + .ok_or_else(|| self.fail(self.at, "Prozedur", "Argumentzahl fehlt"))? + as usize; + let mut name = self.ident(id)?; + let kind = match (flags & 0x300) >> 8 { + 1 => "SUB", + 2 => "FUNCTION", + 3 => "DEF", + _ => "", + }; + if !kind.is_empty() { + self.procedure_type = kind; + name = format!("{kind} {name}"); + } + if flags & 0x80 != 0 { + let suffix = Self::suffix(flags & 7); + if self.pcode == 0x058 { + self.return_name = Some(name.strip_prefix("FUNCTION ").unwrap_or(&name).into()); + self.return_suffix = suffix; + } + name.push_str(suffix); + } + let mut args = Vec::with_capacity(count); + for index in 1..=count { + let at = offset + 6 + (index - 1) * 8; + let mut arg = self.ident( + Self::word(data, at) + .ok_or_else(|| self.fail(self.at, "Prozedurargument", "Name fehlt"))?, + )?; + let arg_flags = Self::word(data, at + 2) + .ok_or_else(|| self.fail(self.at, "Prozedurargument", "Flags fehlen"))?; + let arg_type = Self::word(data, at + 4) + .ok_or_else(|| self.fail(self.at, "Prozedurargument", "Typ fehlt"))?; + if arg_flags & 0x200 != 0 { + arg.push_str(Self::suffix(arg_type)); + } + if arg_flags & 0x400 != 0 { + arg.push_str("()"); + } + if arg_flags & 0x800 != 0 { + arg = format!("SEG {arg}"); + } + if arg_flags & 0x1000 != 0 { + arg = format!("BYVAL {arg}"); + } + if arg_flags & 0x2000 != 0 { + let ty = self.type_name(arg_type)?; + arg.push_str(&format!(" AS {ty}")); + } + args.push(arg); + } + if flags & 0x8000 != 0 { + name.push_str(" CDECL"); + } + let alias_len = ((flags >> 10) & 0x1f) as usize; + let tail = offset + 6 + count * 8; + if alias_len != 0 { + name.push_str(" ALIAS "); + name.push_str(&decode_cp437( + data.get(tail..tail + alias_len) + .ok_or_else(|| self.fail(self.at, "ALIAS", "Name abgeschnitten"))?, + )); + } + if parentheses || !args.is_empty() { + name.push_str(" ("); + name.push_str(&args.join(", ")); + name.push(')'); + } + Ok(name) + } + + fn array(&self, data: &[u8], declaration: bool) -> Result { + let declaration = + declaration || read_u16(self.code, self.at).is_some_and(|word| word & 0x3ff == 0x1a8); + let count = Self::word(data, 0) + .ok_or_else(|| self.fail(self.at, "Feld", "Dimension fehlt"))? + as usize; + let mut name = self + .ident(Self::word(data, 2).ok_or_else(|| self.fail(self.at, "Feld", "Name fehlt"))?)?; + name.push_str(Self::suffix(self.hparam)); + let mut stack_offset = + usize::from(declaration && self.peek(0).is_some_and(|item| item.kind == "astype")); + if count != 0 { + let mut dimensions = String::new(); + if declaration { + let mut remaining = count; + while remaining != 0 { + remaining -= 1; + dimensions.insert_str(0, &format!("{{{stack_offset}}}")); + stack_offset += 1; + if remaining & 1 != 0 { + if self.peek(stack_offset).is_none_or(|item| item.kind != "24") { + dimensions.insert_str(0, " TO "); + } + } else if remaining != 0 { + dimensions.insert_str(0, ", "); + } + } + } else { + dimensions = (0..count) + .rev() + .map(|offset| format!("{{{offset}}}")) + .collect::>() + .join(", "); + stack_offset = count; + } + name.push('('); + name.push_str(&dimensions); + name.push(')'); + } else if !declaration { + name.push_str("()"); + } + if declaration && self.peek(0).is_some_and(|item| item.kind == "astype") { + name.push_str("{0}"); + } + if declaration { + if self + .peek(stack_offset) + .is_some_and(|item| item.kind == "decls") + { + return Ok(format!("{{{stack_offset}}}, {name}")); + } + if self + .peek(stack_offset) + .is_some_and(|item| item.kind == "decl") + { + return Ok(format!("{{{stack_offset}}} {name}")); + } + } + Ok(name) + } + + fn varargs(&self, data: &[u8]) -> Result { + let count = Self::word(data, 0) + .ok_or_else(|| self.fail(self.at, "Argumentliste", "Anzahl fehlt"))? + as usize; + let args = (0..count) + .rev() + .map(|index| format!("{{{index}}}")) + .collect::>() + .join(", "); + Ok(if args.is_empty() { + args + } else { + format!(" {args}") + }) + } + + fn data_tag(&mut self, tag: &str, offset: usize, data: &[u8]) -> Result { + let word = || { + Self::word(data, offset) + .ok_or_else(|| self.fail(self.at, tag, "Tokendaten abgeschnitten")) + }; + Ok(match tag.to_ascii_lowercase().as_str() { + "" | "nul" | "thaddr" | "optargs" => String::new(), + "newline" => "\r\u{1}".into(), + "newline-include" => "\r\u{2}".into(), + "tabh" => format!( + "\r\0{}{}", + char::from(self.hparam as u8), + char::from((self.hparam >> 8) as u8) + ), + "tabi" => { + let value = word()?; + format!( + "\r\0{}{}", + char::from(value as u8), + char::from((value >> 8) as u8) + ) + } + "indent" => " ".repeat(word()? as usize), + "raw" => decode_pcode_bytes(data.get(offset..).unwrap_or_default()), + "cstr" => decode_pcode_bytes( + data.get(offset..) + .unwrap_or_default() + .split(|byte| *byte == 0) + .next() + .unwrap_or_default(), + ), + "qstr" => format!( + "\"{}\"", + decode_pcode_bytes(data.get(offset..).unwrap_or_default()) + ), + "id" => self.ident(word()?)?, + "id+" => { + let name = self.ident(word()?)?; + let suffix = Self::suffix(self.hparam); + format!( + "{name}{}", + if suffix.is_empty() && self.return_name.as_deref() == Some(&name) { + self.return_suffix + } else { + suffix + } + ) + } + "id-list" => data + .get(offset..) + .unwrap_or_default() + .chunks_exact(2) + .map(|bytes| self.ident(u16::from_le_bytes([bytes[0], bytes[1]]))) + .collect::, _>>()? + .join(", "), + "type" => self.type_name(word()?)?, + "type-abbr" => ["", "INT", "LNG", "SNG", "DBL", "STR"] + .get(self.hparam as usize) + .copied() + .unwrap_or("") + .into(), + "hprm" => self.hparam.to_string(), + "int" => (word()? as i16).to_string(), + "int&h" => format!("&H{:X}", word()?), + "int&o" => format!("&O{:o}", word()?), + "lng" => i32::from_le_bytes( + data.get(offset..offset + 4) + .ok_or_else(|| self.fail(self.at, tag, "LONG abgeschnitten"))? + .try_into() + .unwrap(), + ) + .to_string(), + "lng&h" => format!( + "&H{:X}", + u32::from_le_bytes( + data.get(offset..offset + 4) + .ok_or_else(|| self.fail(self.at, tag, "LONG abgeschnitten"))? + .try_into() + .unwrap() + ) + ), + "sng" => f32::from_le_bytes( + data.get(offset..offset + 4) + .ok_or_else(|| self.fail(self.at, tag, "SINGLE abgeschnitten"))? + .try_into() + .unwrap(), + ) + .to_string(), + "dbl" => f64::from_le_bytes( + data.get(offset..offset + 8) + .ok_or_else(|| self.fail(self.at, tag, "DOUBLE abgeschnitten"))? + .try_into() + .unwrap(), + ) + .to_string(), + "label" => { + let name = self.ident(word()?)?; + if name.bytes().all(|byte| byte.is_ascii_digit()) { + format!("{name} ") + } else { + format!("{name}:") + } + } + "call" => self.call(data, false)?, + "call()" => self.call(data, true)?, + "procdecl" => self.proc_decl(data, offset, false)?, + "procdecl()" => self.proc_decl(data, offset, true)?, + "id(expr)" => self.array(data, false)?, + "id(decl)" => self.array(data, true)?, + "varargs" => self.varargs(data)?, + "proctype" => self.procedure_type.into(), + "blockname" => { + let id = word()?; + if id == u16::MAX { + String::new() + } else { + format!(" /{}/", self.ident(id)?) + } + } + "action-verb" => { + ["OR", "AND", "PRESET", "PSET", "XOR"][usize::from(word()?.min(4))].into() + } + "open-args" => { + let flags = word()?; + [ + match flags & 0x3f { + 1 => "FOR INPUT", + 2 => "FOR OUTPUT", + 4 => "FOR RANDOM", + 8 => "FOR APPEND", + 32 => "FOR BINARY", + _ => "", + }, + match (flags >> 8) & 3 { + 1 => "ACCESS READ", + 2 => "ACCESS WRITE", + 3 => "ACCESS READ WRITE", + _ => "", + }, + match (flags >> 12) & 7 { + 1 => "LOCK READ WRITE", + 2 => "LOCK WRITE", + 3 => "LOCK READ", + 4 => "SHARED", + _ => "", + }, + ] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join(" ") + } + "keymode" => match word()? { + 1 => "ON", + 2 => "LIST", + _ => "OFF", + } + .into(), + // Die im Corpus verwendeten Varianten haben genau diese Form; + // die Stack-Platzhalter werden anschliessend normal aufgeloest. + "line-args" | "lock-args" => "{0}".into(), + "circle-args" => "{1}, {0}".into(), + "input-args" => String::new(), + "defxxx" => { + let raw = data + .get(offset + 2..offset + 6) + .ok_or_else(|| self.fail(self.at, "DEF", "Tokendaten abgeschnitten"))?; + let mask = u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]); + let kind = match mask & 7 { + 1 => "INT", + 2 => "LNG", + 3 => "SNG", + 4 => "DBL", + 5 => "STR", + _ => "", + }; + let letters = mask & 0xffff_ffc0; + if mask & 7 == 3 { + self.modified_defaults &= !letters; + } else { + self.modified_defaults |= letters; + } + format!("DEF{kind} {}", letter_ranges(letters)) + } + other => { + return Err(self.fail( + self.at, + format!("Token-Tag {other}"), + "unbekannte P-Code-Struktur", + )) + } + }) + } + + fn execute(&mut self, mut rule: String, data: &[u8]) -> Result { + let mut kind = self.pcode.to_string(); + if let Some((left, right)) = rule.split_once("::=") { + kind = left.into(); + rule = right.into(); + } + if rule.starts_with('.') { + self.pending + .extend(self.stack.drain(..).map(|item| item.text)); + rule.remove(0); + } + let initial = self.stack.len(); + let mut deepest = None::; + let mut relative = Vec::<(String, String, isize)>::new(); + let mut out = String::new(); + let mut cursor = 0; + while let Some(open) = rule[cursor..].find('{').map(|value| cursor + value) { + let Some(close) = rule[open + 1..].find('}').map(|value| open + 1 + value) else { + return Err(self.fail(self.at, "Tokenregel", "offene Ersetzung")); + }; + out.push_str(&rule[cursor..open]); + let tag = &rule[open + 1..close]; + if let Some(tag) = tag.strip_prefix("##") { + let (tag, offset, _) = self.split_tag(tag); + let replacement = self.data_tag(tag, offset.max(0) as usize, data)?; + rule.replace_range(open..=close, &replacement); + cursor = open; + continue; + } + if let Some(tag) = tag.strip_prefix('#') { + let (tag, offset, _) = self.split_tag(tag); + if tag == "method" { + if Self::word(data, 0) != Some(offset as u16) { + return Ok(false); + } + cursor = close + 1; + continue; + } + out.push_str(&self.data_tag(tag, offset.max(0) as usize, data)?); + } else { + let (expected, offset, is_relative) = self.split_tag(tag); + if is_relative { + let marker = format!("\u{b}{}\u{c}", relative.len()); + out.push_str(&marker); + relative.push((marker, expected.into(), offset)); + } else { + if !self.valid(expected, offset, &kind) { + return Ok(false); + } + if let Some(item) = usize::try_from(offset) + .ok() + .and_then(|offset| self.peek(offset)) + { + out.push_str(&item.text); + } + deepest = deepest.max(usize::try_from(offset).ok()); + } + } + cursor = close + 1; + } + out.push_str(&rule[cursor..]); + let mut consume = deepest.map_or(0, |offset| offset + 1); + for (marker, expected, offset) in relative { + let actual = consume as isize + offset; + if !self.valid(&expected, actual, &kind) { + return Ok(false); + } + let text = self + .peek(actual as usize) + .map(|item| item.text.as_str()) + .unwrap_or(""); + out = out.replace(&marker, text); + consume = consume.max(actual as usize + 1); + } + self.stack.truncate(initial.saturating_sub(consume)); + self.stack.push(Item { kind, text: out }); + Ok(true) + } + + fn postprocess(&mut self, final_block: bool) { + while let Some(marker) = self.pending.find('\r') { + let kind = self.pending.as_bytes().get(marker + 1).copied(); + if kind == Some(0) && marker + 3 < self.pending.len() { + let bytes = self.pending.as_bytes(); + let column = u16::from_le_bytes([bytes[marker + 2], bytes[marker + 3]]) as usize; + let spaces = column.saturating_sub(marker).max(1); + self.pending + .replace_range(marker..marker + 4, &" ".repeat(spaces)); + continue; + } + if matches!(kind, Some(1 | 2)) { + let line = self.pending[..marker].trim_end().to_owned(); + self.lines.push(line); + self.pending.drain(..marker + 2); + continue; + } + if let (Some(count), Some(ch)) = ( + self.pending.as_bytes().get(marker + 1).copied(), + self.pending.as_bytes().get(marker + 2).copied(), + ) { + self.pending.replace_range( + marker..marker + 3, + &char::from(ch).to_string().repeat(count as usize), + ); + continue; + } + break; + } + if final_block && !self.pending.trim().is_empty() { + self.lines.push(self.pending.trim_end().into()); + self.pending.clear(); + } + } + + fn run(mut self) -> Result<(String, u32), FormError> { + while self.at < self.code.len() { + let token_at = self.at; + let word = read_u16(self.code, self.at) + .ok_or_else(|| self.fail(self.at, "P-Code", "Token abgeschnitten"))?; + self.at += 2; + self.pcode = word & 0x3ff; + self.hparam = word >> 10; + if self.pcode == 8 { + break; + } + let def = token_def(self.pcode).ok_or_else(|| { + self.fail( + token_at, + format!("Token 0x{:03x}", self.pcode), + "unbekannte P-Code-Struktur", + ) + })?; + let len = if def.len < 0 { + let len = read_u16(self.code, self.at) + .ok_or_else(|| self.fail(self.at, "P-Code", "variable Laenge fehlt"))? + as usize; + self.at += 2; + len + } else { + def.len as usize + }; + let data = self + .code + .get(self.at..self.at + len) + .ok_or_else(|| self.fail(self.at, "P-Code", "Tokendaten abgeschnitten"))? + .to_owned(); + self.at += (len + 1) & !1; + if matches!(self.pcode, 0x1c5 | 0x1c6) { + let first = self + .stack + .len() + .checked_sub(3) + .ok_or_else(|| self.fail(token_at, "MSGBOX", "Argumente fehlen"))?; + if self.stack[first..] + .iter() + .any(|item| !matches!(item.kind.as_str(), "expr" | "nularg")) + { + return Err(self.fail(token_at, "MSGBOX", "ungueltige Argumentstruktur")); + } + let mut args = self.stack.drain(first..).collect::>(); + while args.last().is_some_and(|item| item.kind == "nularg") { + args.pop(); + } + let call = format!( + "MSGBOX{}{}", + if self.pcode == 0x1c6 { "(" } else { " " }, + args.iter() + .map(|item| item.text.as_str()) + .collect::>() + .join(", ") + ) + if self.pcode == 0x1c6 { ")" } else { "" }; + self.stack.push(Item { + kind: if self.pcode == 0x1c6 { + "expr" + } else { + "statement" + } + .into(), + text: call, + }); + self.postprocess(false); + continue; + } + let mut accepted = false; + for rule in def.rules { + if self.execute((*rule).into(), &data)? { + accepted = true; + break; + } + } + if !accepted { + let stack = self + .stack + .iter() + .map(|item| item.kind.as_str()) + .collect::>() + .join(", "); + return Err(self.fail( + token_at, + format!("Token 0x{:03x}", self.pcode), + format!("keine passende P-Code-Regel; Stapel: {stack}"), + )); + } + self.postprocess(false); + } + self.pending + .extend(self.stack.drain(..).map(|item| item.text)); + self.postprocess(true); + let output = if self.lines.is_empty() { + String::new() + } else { + self.lines.join("\n") + "\n" + }; + Ok((output, self.modified_defaults)) + } +} + +type ModuleLayout = (usize, usize, Vec<(usize, usize)>); + +fn candidate_layout(bytes: &[u8], base: usize) -> Option { + let symbol_len = usize::from(read_u16(bytes, base)?); + let symbol_start = base + 2; + let main_header = symbol_start.checked_add(symbol_len)?; + let main_len = usize::from(read_u16(bytes, main_header)?); + if main_len & 1 != 0 { + return None; + } + let main_start = main_header + 2; + let main_end = main_start.checked_add(main_len)?; + if main_end + 16 > bytes.len() + || main_len != 0 && read_u16(bytes, main_end - 2).map(|word| word & 0x3ff) != Some(8) + { + return None; + } + let mut blocks = vec![(main_start, main_end)]; + let mut at = main_end + 16; + while at < bytes.len() { + let name_len = usize::from(read_u16(bytes, at + 1)?); + let name = bytes.get(at + 3..at + 3 + name_len)?; + if bytes.get(at) != Some(&0) + || !(1..=40).contains(&name_len) + || !name.first().is_some_and(u8::is_ascii_alphabetic) + || !name + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_') + { + return None; + } + let code_len = usize::from(read_u16(bytes, at + 6 + name_len)?); + let start = at + 8 + name_len; + let end = start.checked_add(code_len)?; + if code_len & 1 != 0 + || end + 16 > bytes.len() + || code_len != 0 && read_u16(bytes, end - 2).map(|word| word & 0x3ff) != Some(8) + { + return None; + } + blocks.push((start, end)); + at = end + 16; + } + (at == bytes.len()).then_some((symbol_start, main_header, blocks)) +} + +fn module_layout(bytes: &[u8], after_catalog: usize) -> Option { + for base in after_catalog..after_catalog.saturating_add(24).min(bytes.len()) { + if let Some(layout) = candidate_layout(bytes, base) { + return Some(layout); + } + } + None +} + +pub(super) fn decode(file: &str, bytes: &[u8], after_catalog: usize) -> Result { + let (symbol_start, symbol_end, blocks) = module_layout(bytes, after_catalog) + .ok_or_else(|| error(file, after_catalog, "Codeteil", "unerkannte Struktur"))?; + let sym = &bytes[symbol_start..symbol_end]; + let mut output = String::new(); + let mut pending_reset = 0; + for (start, end) in blocks { + let (block, block_reset) = Decoder::new(file, sym, &bytes[start..end], start).run()?; + if block.is_empty() { + pending_reset |= block_reset; + continue; + } + if !output.is_empty() && !block.is_empty() { + output.push('\n'); + if pending_reset != 0 { + output.push_str("DEFSNG "); + output.push_str(&letter_ranges(pending_reset)); + output.push('\n'); + } + } + output.push_str(&block); + pending_reset = block_reset; + } + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn symbols(names: &[&str]) -> (Vec, Vec) { + let mut table = Vec::new(); + let mut offsets = Vec::new(); + for name in names { + offsets.push(table.len() as u16); + table.extend_from_slice(&[0, 0, 0, name.len() as u8]); + table.extend_from_slice(name.as_bytes()); + } + (table, offsets) + } + + #[test] + fn decodes_vbdos_declarations_without_losing_type_or_bounds() { + let (sym, ids) = symbols(&["PrepVal", "StringToPrep", "Oldcontents"]); + let mut decoder = Decoder::new("test.frm", &sym, &[], 0); + let mut proc = Vec::new(); + for word in [ids[0], 0x02c7, 1, ids[1], 0x2000, 9, 0] { + proc.extend_from_slice(&word.to_le_bytes()); + } + decoder.pcode = 0x058; + assert_eq!( + decoder.proc_decl(&proc, 0, true).unwrap(), + "FUNCTION PrepVal$ (StringToPrep AS CONTROL)" + ); + decoder.hparam = 0; + assert_eq!( + decoder.data_tag("id+", 0, &ids[0].to_le_bytes()).unwrap(), + "PrepVal$" + ); + + let mut decoder = Decoder::new("test.frm", &sym, &[], 0); + decoder.stack = vec![ + Item { + kind: "expr".into(), + text: "0".into(), + }, + Item { + kind: "expr".into(), + text: "6".into(), + }, + ]; + let mut array = Vec::new(); + array.extend_from_slice(&2u16.to_le_bytes()); + array.extend_from_slice(&ids[2].to_le_bytes()); + assert_eq!( + decoder.array(&array, true).unwrap(), + "Oldcontents({1} TO {0})" + ); + + assert_eq!( + decoder.array(&array, false).unwrap(), + "Oldcontents({1}, {0})" + ); + + array[..2].copy_from_slice(&0u16.to_le_bytes()); + assert_eq!(decoder.array(&array, false).unwrap(), "Oldcontents()"); + } + + #[test] + fn decodes_defint_and_vbdos_isam_tokens() { + let mut decoder = Decoder::new("test.frm", &[], &[], 0); + assert_eq!( + decoder + .data_tag("defxxx", 0, &[0xff, 0xff, 0xc1, 0xff, 0xff, 0xff]) + .unwrap(), + "DEFINT A-Z" + ); + assert!(token_def(0x188) + .unwrap() + .rules + .contains(&"expr::=GETINDEX$({0})")); + assert!(token_def(0x181).unwrap().rules.contains(&"expr::=CURDIR$")); + assert!(token_def(0x184) + .unwrap() + .rules + .contains(&"expr::=DIR$({0})")); + assert!(token_def(0x1c3) + .unwrap() + .rules + .contains(&"expr::=INPUTBOX$({2}, {1}, {0})")); + assert!(token_def(0x1a8).unwrap().rules.contains(&"REDIM {0}")); + } + + #[test] + fn resets_block_local_default_types_before_the_next_block() { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&0u16.to_le_bytes()); + + let first = [ + 0x1b, 0x00, 0xff, 0xff, 0xc1, 0xff, 0xff, 0xff, 0x00, 0x00, 0x08, 0x00, + ]; + bytes.extend_from_slice(&(first.len() as u16).to_le_bytes()); + bytes.extend_from_slice(&first); + bytes.extend_from_slice(&[0; 16]); + + let second = [ + 0x0a, 0x00, 0x03, 0x00, 0x00, 0x00, b'X', 0x00, 0x00, 0x00, 0x08, 0x00, + ]; + bytes.extend_from_slice(&[0, 1, 0, b'P', 0, 0, 0]); + bytes.extend_from_slice(&(second.len() as u16).to_le_bytes()); + bytes.extend_from_slice(&second); + bytes.extend_from_slice(&[0; 16]); + + assert_eq!( + decode("test.frm", &bytes, 0).unwrap(), + "DEFINT A-Z\n\nDEFSNG A-Z\nX\n" + ); + } +} diff --git a/crates/tb-ui/src/lib.rs b/crates/tb-ui/src/lib.rs index b71df18..8e26c37 100644 --- a/crates/tb-ui/src/lib.rs +++ b/crates/tb-ui/src/lib.rs @@ -10,6 +10,8 @@ pub mod events; pub mod forms; +pub mod frm; +mod frm_pcode; pub mod host; // Terminal-Host: Anzeige + Tastatur-/Größenereignisse pub mod screen; pub mod signale; // Betriebssystemsignale als Ereignisquelle (SIGNAL) diff --git a/crates/tb-ui/tests/data/new.frm.hex b/crates/tb-ui/tests/data/new.frm.hex new file mode 100644 index 0000000..dacadc6 --- /dev/null +++ b/crates/tb-ui/tests/data/new.frm.hex @@ -0,0 +1,16 @@ +fc 08 01 00 0e 00 a8 01 c3 01 09 00 01 02 03 04 +06 05 08 0a 69 00 00 00 00 00 00 00 56 00 3d 00 +00 00 00 22 85 29 00 00 00 00 00 52 29 00 00 03 +0f 11 3f 00 00 07 00 00 47 00 02 00 00 0f 3d 01 +03 00 00 c1 00 00 0a 00 00 00 00 00 00 00 0b 08 +03 0c 00 00 07 00 00 4c 00 00 00 29 00 03 00 4e +65 77 08 00 43 6f 6d 6d 61 6e 64 31 5d 00 00 03 +4e 65 77 00 00 03 08 43 6f 6d 6d 61 6e 64 31 05 +01 ff ff 24 00 ff ff 56 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +00 00 00 00 00 00 00 00 00 00 00 56 00 00 00 04 +00 09 00 08 00 ff ff ff ff ff ff ff ff 00 00 00 +00 00 00 03 01 diff --git a/docs/dateiformate.md b/docs/dateiformate.md index c949a17..89d7cb0 100644 --- a/docs/dateiformate.md +++ b/docs/dateiformate.md @@ -2,7 +2,8 @@ Terminal Basic liest und schreibt die Textformate des Vorbilds, durchgängig in UTF-8 (Abweichung: das Vorbild nutzte die DOS-Codepage). Binäre -„Fast-Load"-Varianten des Vorbilds sind Nicht-Ziel — nur Textformate. +„Fast-Load"-Formulare des Vorbilds werden nur durch den Konverter gelesen, +aber nie geschrieben. ## Quelltext: `.BAS` @@ -13,9 +14,8 @@ Metabefehle in Kommentaren: `'$INCLUDE: 'datei.bi'`, `'$STATIC`, `'$DYNAMIC`. ## Formular: `.FRM` Das Vorbild kannte zwei Speicherformate: **binär** („Fast load and save", -Standard) und **Text** („Readable by other programs"). Terminal Basic -implementiert nur das Textformat; das Binärformat ist Nicht-Ziel (FT.EXE -des Vorbilds konvertierte zwischen beiden). +Standard) und **Text** („Readable by other programs"). Terminal Basic schreibt +das Textformat und liest das Binärformat ausschließlich zur Konvertierung. Textformat, zwei Abschnitte: Formular-Beschreibung, dann Code. Schema wie beim Windows-Schwesterprodukt: `VERSION`-Zeile, verschachtelte @@ -27,13 +27,13 @@ akzeptiert Version 1.00 und 2.00 (Import aus dem Windows-Produkt via ``` VERSION 1.00 Begin Form Form1 - Caption = "Beispiel" + Caption = "Beispiel" Height = 15 Left = 10 Top = 4 Width = 50 Begin CommandButton cmdOK - Caption = "&OK" + Caption = "&OK" Height = 1 Left = 18 Top = 11 @@ -49,11 +49,79 @@ END SUB - `VERSION`-Zeile, dann verschachtelte `Begin … End`-Blöcke mit `Eigenschaft = Wert`-Zeilen (Strings in `"…"`). - Danach normaler BASIC-Code des Formular-Moduls. -- TODO: Ein wörtliches Original-Beispiel einer Text-`.FRM` war online nicht - auffindbar (Beispieldateien des Originalpakets liegen alle binär vor). - Exakte Serialisierung (Kopfzeile, Einrückung, welche Eigenschaften - geschrieben werden) ist daher festzulegen: wir folgen dem - Windows-1.0-Schema und dokumentieren unsere Fassung als Referenz. +- Terminal Basic schreibt `VERSION` groß, Klassen- und Eigenschaftsnamen in der + Schreibweise der Forms-Referenz und drei Leerzeichen je Blockebene. +- Der Eigenschaftsname belegt 16 Spalten; danach folgen ` = ` und der Wert. +- Eigenschaften stehen in der alphabetischen Reihenfolge der Forms-Referenz + und werden nur geschrieben, wenn ihr Wert vom Vorgabewert der Klasse + abweicht. +- Unverändert gelesene Dateien werden bytegleich zurückgegeben; für neu + erzeugte oder veränderte Beschreibungen gilt die kanonische Form oben. +- Zeichenketten verdoppeln ein enthaltenes `"`. Wahrheitswerte werden als `-1` + und `0` geschrieben. + +Die minimale kanonische Referenz ist vollständig: + +``` +VERSION 1.00 +Begin Form Form1 + Caption = "Beispiel" + Height = 15 + Begin CommandButton cmdOK + Caption = "&OK" + End +End +``` + +### Binäres VBDOS-1.0-Formular + +Der Binärleser ist ausschließlich ein Importpfad. Er erkennt keine Datei an der +Endung, sondern an `FC 08 01 00`. Aus den Originaldateien und dem +`cout/vbdos`-Bestand ergibt sich folgender Aufbau: + +| Bereich | Kodierung | Bedeutung | +|---|---|---| +| `0x0000` | `FC 08 01 00` | Kennung und VBDOS-Formatversion | +| `0x001c` | `u16` little-endian + `0x16` | Dateiposition des Objektkatalogs | +| `0x001e` | `u16` little-endian | Länge des versionsgebundenen Objekt-/Eigenschaftsbereichs | +| `0x0020` | Wurzelkopf und klassenabhängiger Datensatz; danach je Objekt ein 7-Byte-Kopf und der klassenabhängige Datensatz | Der Objektkopf nennt Katalogindex, Klasse und Flags; der Datensatz enthält Eigenschaften, Array-Index und Containerverweis | +| danach | Folgen aus `u16 Länge` und CP437-Bytes | Zeichenkettenpool; ein Datensatzverweis `p` bezeichnet den Längeneintrag bei Dateiposition `p + 0x16`, die Bytes werden beim Import nach UTF-8 gewandelt | +| danach | `u16 Verweis`, `u8 Klasse`, `u8 Länge`, Name | Objektkatalog; Bit 7 der Klasse kennzeichnet ein Steuerelementfeld, die unteren sieben Bit entsprechen der Klassen-ID; `Verweis = 0` beendet den Katalog | +| Ende des Objektbereichs | aufsteigende `u16`-Verweise | Verweise auf Datensätze bei `Datensatzanfang + 3`, unter anderem für Feldinstanzen und physisch umgeordnete Objekte; jeder Verweis muss auf einen gelesenen Datensatz zeigen | +| Rest | Symboltabelle, Modulblöcke und tokenisierter BASIC-Code | Bezeichner und der in Text zurückübersetzte BASIC-Code | + +In den Datensätzen liegen Containerverweis, `Tag`-Verweis und Arrayindex bei +`+0`, `+2` und `+4`, die Geometrie bei `+8` bis `+11` und `TabIndex` bei +`+13`. Beim Form-Root liegen `CurrentX`/`CurrentY` bei `+19`/`+20` und +`BackColor`/`ForeColor` bei `+21`/`+22`. +Klassenabhängig folgen unter anderem `TextBox.BorderStyle`/`ScrollBars` bei +`+19`/`+20`; bei TextBoxen kodiert außerdem das Common-Flag `0x08` +`MultiLine`. `ComboBox.Style` liegt bei `+31`, +`Label.BorderStyle`/`Alignment` bei `+19`/`+20` +und `PictureBox.BorderStyle`/`CurrentX`/`CurrentY` bei `+16`/`+17`/`+19`; +`Timer.Interval` ist ein `u16` bei +`+17`. Das Common-Flag `0x10` eines CommandButton kodiert `Cancel`. +Das `AutoRedraw`-Bit `0x08` von Form +und PictureBox steht im Objektkopf; bei Forms kodiert `0x02` zusätzlich +`FormType = 1`. Dort kodiert `0x80` bei ListBoxen `Sorted` und `0x20` +bei Labels `AutoSize`; bei Menüs kodieren `0x01` und `0x40` `Separator` und +`Checked`. Scrollbars verwenden ab `+14` ein eigenes Layout: +`Attached`, `SmallChange`, `LargeChange`, `Max` und `Min`; ihr Anfangswert ist +`Min`. Diese Bytes sind ausdrücklich keine Farbwerte. + +Der Import ordnet jeden physischen Datensatz über dessen 7-Byte-Kopf dem +Katalogeintrag zu; die Katalogreihenfolge ist dafür ausdrücklich nicht +maßgeblich. Array-Indizes müssen eindeutig, Containerverweise auf bereits +gelesene Objekte gerichtet und Zeichenkettenverweise exakt auf einen +Pooleintrag auflösbar sein; auch bei Custom Controls darf kein Pooleintrag +unbelegt bleiben. Nicht unterstützte Custom Controls der binären Klasse 17 +werden nicht als `Screen` ausgegeben, sondern mit Objektname und tatsächlicher +Byteposition gemeldet. Eine gespeicherte, vom Objektmodell nicht unterstützte +Menü-Tastenkombination liegt im erweiterten Menüdatensatz als `u16` +little-endian bei `+19`; sie wird als `.Shortcut` mit ihrer +Byteposition gemeldet und übersprungen. Eine unbekannte Klasse, ein ungültiger Verweis, +ein unbelegter String oder ein abgeschnittener Bereich führt an der Fundstelle +zum Abbruch; eine Ausgabedatei wird erst nach erfolgreichem Lesen angelegt. ## Projekt: `.MAK` diff --git a/docs/forms-referenz.md b/docs/forms-referenz.md index e309eb4..fe7c846 100644 --- a/docs/forms-referenz.md +++ b/docs/forms-referenz.md @@ -71,11 +71,12 @@ Height, Index, Left, MousePointer, Parent, TabIndex, TabStop, Tag, Top, Value, Visible, Width. **Frame** — nur DragDrop/DragOver; DRAG, MOVE, REFRESH (kein SetFocus); -Container für Gruppierung (OptionButton-Gruppen). +Container für Gruppierung (OptionButton-Gruppen). `Index` und `TabIndex` +werden in den Originaldateien auch für Frames gespeichert. **Label** — Ereignisse: Change, Click, DblClick, Drag*, Mouse*. Methoden: -DRAG, MOVE, REFRESH. Eigenschaften: zusätzlich Alignment, AutoSize, -BorderStyle. +DRAG, MOVE, REFRESH. Eigenschaften: zusätzlich Index, TabIndex, Alignment, +AutoSize, BorderStyle. **HScrollBar/VScrollBar** — Ereignisse: Change, Drag*, GotFocus, Key*, LostFocus (**kein** separates Scroll-Ereignis). Eigenschaften: zusätzlich @@ -85,8 +86,8 @@ LargeChange, Min, Max, SmallChange, Value. **PictureBox** — Text-Zeichenfläche (PRINT/CLS aufs Control) und Container für OptionButton-Gruppen. Ereignisse: Click, DblClick, Drag*, GotFocus, Key*, LostFocus, Mouse*, Paint. Methoden: CLS, DRAG, MOVE, PRINT, REFRESH, -SETFOCUS, TEXTHEIGHT, TEXTWIDTH. Eigenschaften: zusätzlich AutoRedraw, -CurrentX, CurrentY, ScaleHeight, ScaleWidth. +SETFOCUS, TEXTHEIGHT, TEXTWIDTH. Eigenschaften: zusätzlich Index, TabIndex, +TabStop, AutoRedraw, CurrentX, CurrentY, ScaleHeight, ScaleWidth. **Timer** — Ereignis Timer, keine Methoden; Eigenschaften: CtlName, Enabled, Index, Interval (0 = aus … 65 535 ms), Parent, Tag. diff --git a/docs/inventar.md b/docs/inventar.md index ea2dde4..470ce9e 100644 --- a/docs/inventar.md +++ b/docs/inventar.md @@ -22,7 +22,7 @@ namentlich abgewiesen. `Non-Feature` = abgelehnt, gelistet unter **Fundstelle.** Bei `implementiert` das Modul, bei `Non-Feature` der Abschnitt der Sprachreferenz, bei `offen` ein Strich. -**Abdeckung.** implementiert 682 · offen 61 · Non-Feature 53 · gesamt 796 +**Abdeckung.** implementiert 689 · offen 61 · Non-Feature 53 · gesamt 803 | Name | Art | Gruppe | Status | Fundstelle | Quelle | |---|---|---|---|---|---| @@ -549,6 +549,8 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `FRAME.TOP` | Eigenschaft | Forms/FRAME | implementiert | tb-ui::forms | forms-referenz | | `FRAME.VISIBLE` | Eigenschaft | Forms/FRAME | implementiert | tb-ui::forms | forms-referenz | | `FRAME.WIDTH` | Eigenschaft | Forms/FRAME | implementiert | tb-ui::forms | forms-referenz | +| `FRAME.INDEX` | Eigenschaft | Forms/FRAME | implementiert | tb-ui::forms | forms-referenz | +| `FRAME.TABINDEX` | Eigenschaft | Forms/FRAME | implementiert | tb-ui::forms | forms-referenz | | `FRAME.CTLNAME` | Eigenschaft | Forms/FRAME | implementiert | tb-ui::forms | forms-referenz | | `FRAME.DRAGMODE` | Eigenschaft | Forms/FRAME | implementiert | tb-ui::forms | forms-referenz | | `FRAME.CAPTION` | Eigenschaft | Forms/FRAME | implementiert | tb-ui::forms | forms-referenz | @@ -600,6 +602,8 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `LABEL.TOP` | Eigenschaft | Forms/LABEL | implementiert | tb-ui::forms | forms-referenz | | `LABEL.VISIBLE` | Eigenschaft | Forms/LABEL | implementiert | tb-ui::forms | forms-referenz | | `LABEL.WIDTH` | Eigenschaft | Forms/LABEL | implementiert | tb-ui::forms | forms-referenz | +| `LABEL.INDEX` | Eigenschaft | Forms/LABEL | implementiert | tb-ui::forms | forms-referenz | +| `LABEL.TABINDEX` | Eigenschaft | Forms/LABEL | implementiert | tb-ui::forms | forms-referenz | | `LABEL.CTLNAME` | Eigenschaft | Forms/LABEL | implementiert | tb-ui::forms | forms-referenz | | `LABEL.FORECOLOR` | Eigenschaft | Forms/LABEL | implementiert | tb-ui::forms | forms-referenz | | `LABEL.DRAGMODE` | Eigenschaft | Forms/LABEL | implementiert | tb-ui::forms | forms-referenz | @@ -708,6 +712,9 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `PICTUREBOX.TOP` | Eigenschaft | Forms/PICTUREBOX | implementiert | tb-ui::forms | forms-referenz | | `PICTUREBOX.VISIBLE` | Eigenschaft | Forms/PICTUREBOX | implementiert | tb-ui::forms | forms-referenz | | `PICTUREBOX.WIDTH` | Eigenschaft | Forms/PICTUREBOX | implementiert | tb-ui::forms | forms-referenz | +| `PICTUREBOX.INDEX` | Eigenschaft | Forms/PICTUREBOX | implementiert | tb-ui::forms | forms-referenz | +| `PICTUREBOX.TABINDEX` | Eigenschaft | Forms/PICTUREBOX | implementiert | tb-ui::forms | forms-referenz | +| `PICTUREBOX.TABSTOP` | Eigenschaft | Forms/PICTUREBOX | implementiert | tb-ui::forms | forms-referenz | | `PICTUREBOX.CTLNAME` | Eigenschaft | Forms/PICTUREBOX | implementiert | tb-ui::forms | forms-referenz | | `PICTUREBOX.FORECOLOR` | Eigenschaft | Forms/PICTUREBOX | implementiert | tb-ui::forms | forms-referenz | | `PICTUREBOX.DRAGMODE` | Eigenschaft | Forms/PICTUREBOX | implementiert | tb-ui::forms | forms-referenz | diff --git a/openspec/changes/phase-4-frm/.openspec.yaml b/openspec/changes/phase-4-frm/.openspec.yaml new file mode 100644 index 0000000..1d9aeef --- /dev/null +++ b/openspec/changes/phase-4-frm/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-04 diff --git a/openspec/changes/phase-4-frm/beispieldateien.md b/openspec/changes/phase-4-frm/beispieldateien.md new file mode 100644 index 0000000..f937458 --- /dev/null +++ b/openspec/changes/phase-4-frm/beispieldateien.md @@ -0,0 +1,88 @@ +# Binäre Beispieldateien + +Alle Prüfsummen sind SHA-256 über die dekomprimierte `.FRM`-Datei. Die Dateien +werden wegen ihrer fremden Lizenz nicht in das Repository übernommen; der Test +enthält nur eine Hexdarstellung des 245-Byte-Belegs `new.frm`. + +## Microsoft Visual Basic for MS-DOS 1.00 Professional + +Quelle: WinWorld-Abbild „Microsoft Visual Basic 1.0 Professional for MS-DOS +(1992) (3.5-1.44mb)", Archiv-SHA-1 +`052ac72c28de119573611e09bb4011c9610efb10`; Dateien mit Microsofts +`DECOMP`-kompatiblem KWAJ-Verfahren aus `*.FR$` entpackt. + +| Datei | SHA-256 | +|---|---| +| BOOKCARD.FRM | `f24625a21098d02814bb5ecad2a9e655b4f6a3a352dd9af31d7a4e2d5797ad86` | +| BOOKLIST.FRM | `7f3c091a87c899d3df4cbeacd7a507bdcdd0379bc61e3cad29175c4bcb7e0cb2` | +| BOOKLOOK.FRM | `4e7356eecf5f378ae25ca1481260c048eda57614925ce5ecf8318378c8414cd8` | +| BOOKSRCH.FRM | `af19b7b1716efaa1f44990fa90cf94553028c6fed6e09e40db4c2a2963c82ffa` | +| BOOKSTCK.FRM | `7983beaddc47c819922d77c0bfdc269e1033c09887adbdbb37df75bdca670f7d` | +| CALC.FRM | `189eda4d341804c54fe24ac5df696f7d7ceadec936776e89258ce75e78636201` | +| CHECK.FRM | `ed552601aca93c2a773159cb3fe13719560924e9fdaa286468bf3b8e051e52c9` | +| CHRTATTR.FRM | `8b2c86fc0ec3cae126e0c1b4e67a494302b82ad380aae617b4e855e66c1327cf` | +| CHRTDATA.FRM | `64ba285c863cb685c981d13bf0211034140fa2f8cb8d99d6d0911060cb017de8` | +| CHRTDEMO.FRM | `3a24445bd8ca68bb35a3e7ff07595d13c275c4240b5839f8ec5d8b71c15586ea` | +| CHRTFONT.FRM | `99bef5c1d9f578f01bcdbbd84d17213a040311a78affa94b4c275a1d6232e2fc` | +| CHRTTYPE.FRM | `f4f566a96f97274b9b50c0970e33760e0d111656f4fa376660d3d72c517768e6` | +| CLOCK.FRM | `97f75993a7558bfb0dbcb7aa8908731a5537ea6542e36b5ee61a30e32b16d3b4` | +| CMNDLGF.FRM | `7273671ef4a938251472a3a8221347f7717d969a69b6b1bfb6fa27412e26c580` | +| CONTROLP.FRM | `a8bb99de1600d44f74922e1d493c9b54afd5cf08aadfde26b453eedf7695dcee` | +| DEBUG.FRM | `e08348af465f3f8c14187a3b4f79a210669170e84a5884c38ccdf672246ffb1c` | +| FONTDEMO.FRM | `90730abb9eac5d1b3c7a0ed47ebfff086b68942742e3dac1f561bfe64273cb6e` | +| GRAPHICS.FRM | `5890134890a950a34d62fa69e2cd013b5b8233c590d924f42d21c6959b4ecb0f` | +| HELPF.FRM | `4dea528924fdcfad4bd21fef3ff2b6508f010dcca71a8e0c012a1ee6924b06d9` | +| HELPUTIL.FRM | `fe49a8c6086b8f77e77004eb624849647ca740e86d5659dd4a8bf44d5146ea48` | +| NOTEPAD.FRM | `24580d5fb284dee839ca26b03cc755b46d574b32b34896d37479ad9ad4379d40` | +| QLBVIEW.FRM | `358e7babd6b9205341a2e79611fd79c5838b546a327fbb0eefa36c69ef38db97` | +| SEEK.FRM | `2c7bf1df44fe59feef40853fb722ff981824662327eea9c4f8722fe38efb1b68` | +| SETUPMSG.FRM | `4c0826c937a6c1b1737e38719dffc6a73e24a544d04b450fb2ad393b84eb4630` | +| SETUPOPT.FRM | `e832533cd06ddb745656a94b0bf7d04eee9cc8da208cf22ebd3ba52eaba74fc4` | +| SETUPPTH.FRM | `814eeb2c94f11c3ab30b45d9b56a4af8973e79f4b01cd6710653bdd5ac2d0bfe` | +| SETUPSTS.FRM | `56c051320317048fba758e65920dde9878c2bafd37ad3f459c8e7ffb015d9cfd` | +| SPINDEMO.FRM | `259e67a3ce0eb72c2cd19a72b957944f5e1a4269425a254f83861c6175f9c738` | +| TORUS.FRM | `fe645610b661d6cf946e47134602c8bece26d8ff8054d85fcd030c62fbd6c620` | + +## `github.com/cout/vbdos` + +Quelle: Commit `1cdd2b32b829fe1721d0b6aecc433abc47a96fb6`; die beiden +`misc/mdi/*.zip` wurden vor dem Prüfen entpackt. + +| Pfad | SHA-256 | +|---|---| +| graphics/graphics.frm | `5890134890a950a34d62fa69e2cd013b5b8233c590d924f42d21c6959b4ecb0f` | +| microsoft/check.frm | `ed552601aca93c2a773159cb3fe13719560924e9fdaa286468bf3b8e051e52c9` | +| microsoft/notepad.frm | `e11b76f60eb1f1e8fc7fdbc37acebfbae4b9d9d75f3dff77cc26e8cb33d43c97` | +| microsoft/qlbview.frm | `358e7babd6b9205341a2e79611fd79c5838b546a327fbb0eefa36c69ef38db97` | +| microsoft/seek.frm | `2c7bf1df44fe59feef40853fb722ff981824662327eea9c4f8722fe38efb1b68` | +| microsoft/spindemo.frm | `259e67a3ce0eb72c2cd19a72b957944f5e1a4269425a254f83861c6175f9c738` | +| misc/mentors/mentors.frm | `e3ac6a4ea998b6050f9baf066b2ce776dd9f2a7a276a87350a43243340e287fb` | +| misc/mdi/mdi.zip: bargraph.frm | `e44707a7b9ff918760729f520d828189e4bb5bc5eb7fbff0f01fc53ed8fe7530` | +| misc/mdi/mdi.zip: desktop.frm | `63c0ad0fb9f205e56d64879ccd6b00fc140eabc9d8804a10646e15abc6c10c6a` | +| misc/mdi/mdi.zip: draw.frm | `c59e4ccb540bd51f26c9021fa39554e094f70dbab373f21c89e2e62766a4ee6a` | +| misc/mdi/mdi.zip: graph.frm | `78065efb81d31cd4d5cb0e6a8c073686392d255c1142b66864092207b056d597` | +| misc/mdi/mdi.zip: icondraw.frm | `ebcfd115d5439bf3b3531516b2c843e862430b7fb4ea41763fa7767911489025` | +| misc/mdi/mdi.zip: new.frm | `a888365e84a1ed469e16b5aa82a7f09ff9145e83e7aac7ff1b615f3aac8634f4` | +| misc/mdi/mdi.zip: piano.frm | `371ba0ced7597cc3203e34f25b40870ef6da1fc9ab0ef619ce7855d18416112f` | +| misc/mdi/mdi.zip: pingpong.frm | `33e322462efa0b7e0855ad34a9682c5556b13dde18c27c443c4196327776ea0f` | +| misc/mdi/mdi.zip: scribble.frm | `e9aa68bba8e39dde7259c1f3ce1c6aed418ffb191a7c2fab9ca2be6c0edcacce` | +| misc/mdi/mdi2.zip: calc.frm | `b06bdf0baa104c19fc147e47397cd353aa1cfbfcab6989a606b190dda0a93fb4` | +| misc/mdi/mdi2.zip: clock.frm | `632393c79f8b895887f04680f68fbcca1bcd6d005009a7bd42e6d1d192f7351c` | +| misc/mdi/mdi2.zip: cmndlgf.frm | `7273671ef4a938251472a3a8221347f7717d969a69b6b1bfb6fa27412e26c580` | +| misc/mdi/mdi2.zip: controlp.frm | `d4654cd54596a536f16ed828882ff6e3bb231e00430a6037d72c449a71dc24b8` | +| misc/mdi/mdi2.zip: debug.frm | `e08348af465f3f8c14187a3b4f79a210669170e84a5884c38ccdf672246ffb1c` | +| misc/mdi/mdi2.zip: fastedit.frm | `5f5bc1abe48c1d97f864795df7ebbc81a1180cf246c223f94c8781ea1296da9c` | +| misc/mdi/mdi2.zip: mdi.frm | `af7a30b08d8e8d8634019173e7a55d9e27717d3a49f1cd8e2586021d3dcdca01` | +| misc/mdi/mdi2.zip: run.frm | `e7d5cb04168d3db60b3fe98327e206407a088c7d44f6cc73405c80b7644addff` | +| misc/mdi/mdi2.zip: scribble.frm | `c96250f8529dc875deab7bfcb27fd0cfbc46b0959d82ceffcc991ca5b6d9b0d7` | +| misc/mdi/mdi2.zip: wmaster.frm | `53d6caa956ffc6cc714ec13ca4cd76186a209857330eb35c1f4ccd7d463f0f9c` | +| misc/mdi/mdi2.zip: wmsetup.frm | `963f9e8eb45858afc84c4a530146c1da5047472542308c8f53a993067b775522` | + +## Konvertierungslauf + +`tbc convert-frm` wurde am 2026-09-04 über alle 56 oben aufgeführten Dateien +ausgeführt: 56 konvertiert, 0 strukturelle Abbrüche, 4 konkrete Warnungen. Die +Warnungen betreffen `VSpin.CustomControl` und `HSpin.CustomControl` in den zwei +aufgeführten Kopien von `SPINDEMO.FRM`; beide Objekte werden ausgelassen und +nicht als `Screen` fehlinterpretiert. Jede Warnung nennt den Objektnamen und +die tatsächliche Byteposition. diff --git a/openspec/changes/phase-4-frm/design.md b/openspec/changes/phase-4-frm/design.md new file mode 100644 index 0000000..d6d11dc --- /dev/null +++ b/openspec/changes/phase-4-frm/design.md @@ -0,0 +1,88 @@ +# Design — Formulardateien und Konvertierung + +## Context + +Siehe proposal.md — Why. Ausgangslage: `docs/dateiformate.md` hält das +Schema des Windows-Schwesterprodukts als Vorlage fest und markiert die +exakte Serialisierung als offen. Die Formularbeschreibung, die gelesen +und geschrieben wird, stammt aus `phase-4-objektmodell`; dieser Change +fügt nur Ein- und Ausgabe hinzu. + +## Goals / Non-Goals + +**Goals:** + +- Ein Format, das ein Mensch im Editor bearbeiten kann und das ein + Werkzeug reproduzierbar schreibt. +- Binäre Beispieldateien werden lesbar, ohne dass wir das Binärformat + jemals schreiben. + +**Non-Goals:** + +- Binärkompatibilität in Schreibrichtung. +- Verlustfreie Übernahme von Eigenschaften, die es bei uns nicht gibt + (etwa reine Windows-Eigenschaften aus Version 2.00). + +## Decisions + +### D1 — Wir legen die Serialisierung fest und dokumentieren sie als Referenz + +Es gibt keine Originalfassung zum Nachbilden. Festgelegt wird das +Sparsamste, das den Rundlauf trägt: + +``` +VERSION 1.00 +Begin Form Form1 + Caption = "Beispiel" + Height = 15 + Begin CommandButton cmdOK + Caption = "&OK" + End +End +``` + +- Drei Leerzeichen Einrückung je Ebene, Eigenschaftsname auf 16 Spalten + aufgefüllt, `=` mit zwei Leerzeichen davor und danach — die Optik des + Vorlagenschemas. +- Nur Eigenschaften, die vom Vorgabewert abweichen. Das hält Dateien + klein und macht Vorgabewertänderungen sichtbar, statt sie in + Altbeständen einzufrieren. +- Reihenfolge: alphabetisch wie in der Forms-Referenz je Klasse, nicht die + Einfügereihenfolge — sonst hängt die Datei von der Bedienung des + Designers ab. + +### D2 — Rundlauf ist die Prüfung, nicht der Vergleich mit einer Sollddatei + +Da das Format unsere Festlegung ist, prüft ein Vergleich gegen eine +handgeschriebene Solldatei nur uns selbst. Aussagekräftig ist der +Rundlauf: lesen, schreiben, byte-vergleichen — und zusätzlich +schreiben, lesen, Beschreibung vergleichen. Beide Richtungen fangen +verschiedene Fehler. + +### D3 — Der Binärleser bleibt eigenständig und liest nur + +Das Reverse Engineering erschließt den Aufbau aus den Beispieldateien. +Der Leser übersetzt in dieselbe Formularbeschreibung wie der Textleser +und teilt sich mit ihm die Prüfungen. Was er nicht erkennt, bricht ab — +eine „beste Vermutung" hinterließe ein Formular, das anders aussieht als +das Original, ohne dass jemand es merkt. + +Unbekannte Eigenschaften aus dem Windows-Erbe werden beim Konvertieren +namentlich gemeldet und übersprungen; die Zusammenfassung nennt sie am +Ende, damit sie nicht in der Ausgabeflut untergehen. + +## Risks / Trade-offs + +- **Das Binärformat ist nur teilweise erschließbar** → der Befehl bricht + ab, statt zu raten; die dokumentierte Fundstelle sagt, wo es endete. +- **„Nur Abweichungen schreiben" verliert Werte, wenn sich ein + Vorgabewert ändert** → die Vorgabewerte stehen im Inventar und in der + Forms-Referenz; eine Änderung dort ist ein bewusster Vorgang. +- **Version 2.00 kennt Eigenschaften, die wir nicht haben** → beim Lesen + namentlich gemeldet und übersprungen, nicht als Fehler behandelt. + +## Open Questions + +- Ob eine Formulardatei mehrere Formulare enthalten darf, klärt sich mit + der Projektverwaltung (`.MAK`, Phase 5); bis dahin gilt: ein Formular + je Datei. diff --git a/openspec/changes/phase-4-frm/proposal.md b/openspec/changes/phase-4-frm/proposal.md new file mode 100644 index 0000000..0e7c895 --- /dev/null +++ b/openspec/changes/phase-4-frm/proposal.md @@ -0,0 +1,63 @@ +# Phase 4 — Formulardateien (`.FRM`) und Konvertierung + +## Why + +Formulare müssen sich speichern und laden lassen, sonst gibt es weder +den Formular-Designer der Phase 5 noch den Kompatibilitätstest an den +Programmen des Vorbilds. Das Vorbild kannte zwei Formate: binär +(Standard) und Text. Terminal Basic implementiert nur das Textformat — +das Binärformat ist Nicht-Ziel, seine Dateien müssen aber lesbar werden, +denn die Beispielprojekte des Originalpakets und die Programme aus +`github.com/cout/vbdos` liegen **alle** binär vor. + +Ein wörtliches Original-Beispiel einer Text-`.FRM` war nicht auffindbar +(Befund in `docs/dateiformate.md`). Die Serialisierung ist deshalb +festzulegen und zu dokumentieren, statt sie zu rekonstruieren — der +einzige Punkt der Phase, an dem die Leitplanke „Referenzverhalten schlägt +Eleganz" mangels Referenz nicht greift. + +Dieser Change hängt weder an den Steuerelementen noch an der +Ereignisschleife: er beschreibt Formulare, er stellt sie nicht dar. + +## What Changes + +- **Textformat festlegen und dokumentieren**: `VERSION`-Zeile, + verschachtelte `Begin … End`-Blöcke, + `Eigenschaft = Wert`-Zeilen, danach der BASIC-Code des + Formularmoduls. Festgelegt werden Kopfzeile, Einrückung, Reihenfolge + und die Regel, welche Eigenschaften überhaupt geschrieben werden. + Angenommen werden die Versionen 1.00 und 2.00. +- **Lesen und Schreiben**: Eine `.FRM` wird in eine + Formularbeschreibung gelesen und aus ihr wieder geschrieben; das + erneute Schreiben einer gelesenen Datei MUST dieselbe Datei ergeben. +- **Fehlerhafte Dateien**: Unbekannte Klasse, unbekannte Eigenschaft, + unpassender Wert und unausgeglichene Blöcke werden mit Datei, Zeile + und Name gemeldet, nicht stillschweigend übergangen. +- **`tbc convert-frm`**: Konvertierung binärer `.FRM` (Magic + `FC 08 01 00`) in unser Textformat, als Gegenstück zum + Konvertierungswerkzeug des Vorbilds. Das Binärformat wird per Reverse + Engineering aus den Beispieldateien des Originalpakets und des + cout/vbdos-Repos erschlossen und dokumentiert. + +**Non-Goals:** Schreiben des Binärformats; Darstellung oder Ausführung +der beschriebenen Formulare (Changes `phase-4-objektmodell` und +`phase-4-steuerelemente`); der Formular-Designer (Phase 5). + +## Capabilities + +### New Capabilities + +- `forms-dateiformat`: Textformat der Formulardateien — Aufbau, Lesen, + Schreiben, Fehlermeldungen bei fehlerhaften Dateien — sowie die + Konvertierung binärer Formulardateien des Vorbilds. + +## Impact + +- `crates/tb-ui`: Leser und Schreiber des Textformats auf der + Formularbeschreibung aus `phase-4-objektmodell`. +- `crates/tb-cli`: Unterbefehl `convert-frm`. +- `docs/dateiformate.md`: Das TODO zur Serialisierung wird durch die + festgelegte Fassung ersetzt; das Binärformat wird beschrieben, soweit + erschlossen. +- `tests/`: Beispieldateien und ihre erwartete Formularbeschreibung. +- PLAN.md: Punkte „`.FRM`-Textformat" und „Konvertierungstool". diff --git a/openspec/changes/phase-4-frm/specs/forms-dateiformat/spec.md b/openspec/changes/phase-4-frm/specs/forms-dateiformat/spec.md new file mode 100644 index 0000000..890a56d --- /dev/null +++ b/openspec/changes/phase-4-frm/specs/forms-dateiformat/spec.md @@ -0,0 +1,70 @@ +## Purpose + +Das Formulardateiformat legt fest, wie ein Formular samt seiner +Steuerelemente und seines Codes als Textdatei abgelegt, wieder gelesen +und aus binären Dateien des Vorbilds übernommen wird — die Grundlage +dafür, dass Formulare überhaupt gespeichert und ausgetauscht werden +können. + +## ADDED Requirements + +### Requirement: Aufbau des Textformats +Eine Formulardatei SHALL aus einer `VERSION`-Zeile, einem +Beschreibungsteil und einem Codeteil bestehen. Der Beschreibungsteil +SHALL aus verschachtelten Blöcken `Begin ` … `End` mit +Zeilen `Eigenschaft = Wert` bestehen; Zeichenketten stehen in +Anführungszeichen. Der Codeteil SHALL gewöhnlicher Quelltext des +Formularmoduls sein. Die Versionen `1.00` und `2.00` SHALL angenommen +werden; eine andere Version MUST mit Nennung der vorgefundenen Version +abgewiesen werden. + +#### Scenario: Formular mit einem Steuerelement +- **WHEN** eine Datei ein `Form`-Blockelement mit einem eingebetteten `CommandButton`-Block und anschließendem `SUB`-Code enthält +- **THEN** entsteht daraus eine Formularbeschreibung mit einem Steuerelement und dem zugehörigen Quelltext + +#### Scenario: Unbekannte Version +- **WHEN** die Datei mit `VERSION 3.00` beginnt +- **THEN** wird sie abgewiesen und die Meldung nennt `3.00` + +### Requirement: Schreiben ist die Umkehrung des Lesens +Das Schreiben einer gelesenen Formularbeschreibung SHALL dieselbe Datei +ergeben. Geschrieben SHALL nur werden, was vom Vorgabewert abweicht; +Reihenfolge und Einrückung SHALL festgelegt und dokumentiert sein, damit +zwei Läufe dieselbe Datei erzeugen. + +#### Scenario: Rundlauf +- **WHEN** eine Formulardatei gelesen und unverändert wieder geschrieben wird +- **THEN** ist die geschriebene Datei byte-gleich zur gelesenen + +#### Scenario: Vorgabewerte werden nicht geschrieben +- **WHEN** ein Steuerelement nur Vorgabewerte trägt +- **THEN** enthält sein Block außer `Begin`/`End` keine Eigenschaftszeile + +### Requirement: Fehlerhafte Dateien werden benannt +Eine unbekannte Klasse, eine für die Klasse unbekannte Eigenschaft, ein +Wert außerhalb des Wertebereichs und ein unausgeglichener Block MUST je +mit Dateiname, Zeilennummer und dem betroffenen Namen gemeldet werden. +Eine solche Datei MUST NOT teilweise übernommen werden. + +#### Scenario: Unbekannte Eigenschaft +- **WHEN** ein `CommandButton`-Block die Zeile `Farbe = 3` enthält +- **THEN** nennt die Meldung Datei, Zeile, `CommandButton` und `Farbe` + +#### Scenario: Unausgeglichener Block +- **WHEN** einer Datei ein `End` fehlt +- **THEN** nennt die Meldung die Zeile des offenen `Begin`-Blocks + +### Requirement: Konvertierung binärer Formulardateien +Ein Unterbefehl SHALL eine binäre Formulardatei des Vorbilds (Kennung +`FC 08 01 00`) in das Textformat übersetzen. Eine nicht erkannte oder +beschädigte Datei MUST mit Nennung der Fundstelle abgewiesen werden; +eine Teilausgabe MUST NOT entstehen. Der erschlossene Aufbau des +Binärformats SHALL dokumentiert sein. + +#### Scenario: Bekannte Beispieldatei +- **WHEN** eine binäre Beispieldatei konvertiert wird +- **THEN** entsteht eine Textdatei, deren Lesen dieselbe Formularbeschreibung ergibt wie die dokumentierte Erwartung + +#### Scenario: Fremde Datei +- **WHEN** eine Datei ohne die Kennung übergeben wird +- **THEN** bricht der Befehl mit einer Meldung ab und schreibt keine Ausgabedatei diff --git a/openspec/changes/phase-4-frm/tasks.md b/openspec/changes/phase-4-frm/tasks.md new file mode 100644 index 0000000..d985b06 --- /dev/null +++ b/openspec/changes/phase-4-frm/tasks.md @@ -0,0 +1,24 @@ +## 1. Belege und Festlegung + +- [x] 1.1 Vorhandene binäre Beispieldateien aus dem Originalpaket und `github.com/cout/vbdos` sammeln und in `beispieldateien.md` dieses Changes mit Herkunft auflisten; verifiziert durch die Liste mit Prüfsumme je Datei +- [x] 1.2 Serialisierung nach D1 festlegen und in `docs/dateiformate.md` an die Stelle des TODO schreiben; verifiziert durch den Abschnitt mit vollständigem Beispiel + +## 2. Textformat lesen und schreiben + +- [x] 2.1 Leser für `VERSION`, verschachtelte `Begin`/`End`-Blöcke und Eigenschaftszeilen; verifiziert durch Unit-Tests für ein Formular mit eingebettetem Steuerelement und für eine unbekannte Version +- [x] 2.2 Prüfungen gegen Klasse, Eigenschaftsname und Wertebereich mit Datei, Zeile und Name in der Meldung; verifiziert durch Unit-Tests je Fehlerart, einschließlich unausgeglichenem Block +- [x] 2.3 Codeteil vom Beschreibungsteil trennen und unverändert durchreichen; verifiziert durch einen Test, der den Quelltext byte-gleich zurückgibt +- [x] 2.4 Schreiber nach D1 — Einrückung, Spaltenbreite, Reihenfolge, nur Abweichungen; verifiziert durch einen Test gegen das dokumentierte Beispiel +- [x] 2.5 Rundlauf in beiden Richtungen (D2); verifiziert durch Tests „lesen, schreiben, byte-gleich" und „schreiben, lesen, Beschreibung gleich" + +## 3. Binäre Dateien + +- [x] 3.1 Aufbau des Binärformats aus den Dateien aus 1.1 erschließen und in `docs/dateiformate.md` beschreiben; verifiziert durch die Beschreibung mit Feldtabelle und Kennung `FC 08 01 00` +- [x] 3.2 Binärleser auf dieselbe Formularbeschreibung wie der Textleser; verifiziert durch einen Test, der eine Beispieldatei liest und gegen die dokumentierte Erwartung prüft +- [x] 3.3 Unbekannte Eigenschaften namentlich melden und überspringen, unerkannte Struktur mit Fundstelle abbrechen; verifiziert durch Unit-Tests für beide Fälle +- [x] 3.4 Unterbefehl `tbc convert-frm` mit Abbruch ohne Teilausgabe; verifiziert durch einen Test, der nach dem Abbruch das Fehlen der Ausgabedatei prüft + +## 4. Abnahme + +- [x] 4.1 Alle Beispieldateien aus 1.1 konvertieren und die Zusammenfassung der übersprungenen Eigenschaften festhalten; verifiziert durch den Lauf über den gesamten Bestand +- [x] 4.2 `cargo test` grün und `openspec validate phase-4-frm --strict` ohne Befund; verifiziert durch beide Kommandos