diff --git a/apps/fparkan-cli/src/main.rs b/apps/fparkan-cli/src/main.rs index dbaf5dd..d3e1df5 100644 --- a/apps/fparkan-cli/src/main.rs +++ b/apps/fparkan-cli/src/main.rs @@ -245,6 +245,17 @@ struct ScriptHeaderWordCount { instructions: usize, } +#[derive(Serialize)] +struct VarSetInspectOutput<'a> { + schema_version: &'static str, + path: &'a str, + declarations: usize, + float_defaults: usize, + dword_defaults: usize, + first_name: Option<&'a str>, + last_name: Option<&'a str>, +} + fn main() { let args: Vec = std::env::args().skip(1).collect(); let result = run(&args); @@ -313,6 +324,10 @@ fn run(args: &[String]) -> Result<(), String> { let rest = strip_format_json(rest)?; inspect_script(&rest) } + [domain, command, rest @ ..] if domain == "varset" && command == "inspect" => { + let rest = strip_format_json(rest)?; + inspect_varset(&rest) + } _ => Err(usage()), } } @@ -364,6 +379,40 @@ fn script_inspect_json( }) } +fn inspect_varset(args: &[String]) -> Result<(), String> { + let path = parse_file_path(args, "varset inspect")?; + let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?; + let varset = + fparkan_script::parse_varset(&bytes).map_err(|err| format!("{}: {err}", path.display()))?; + let display_path = path.display().to_string(); + println!("{}", varset_inspect_json(&display_path, &varset)?); + Ok(()) +} + +fn varset_inspect_json(path: &str, varset: &fparkan_script::VarSet) -> Result { + let float_defaults = varset + .declarations + .iter() + .filter(|declaration| declaration.type_name == fparkan_script::VarSetType::Float) + .count(); + let dword_defaults = varset.declarations.len() - float_defaults; + serialize_json(&VarSetInspectOutput { + schema_version: "fparkan-varset-inspect-v1", + path, + declarations: varset.declarations.len(), + float_defaults, + dword_defaults, + first_name: varset + .declarations + .first() + .map(|declaration| declaration.name.as_str()), + last_name: varset + .declarations + .last() + .map(|declaration| declaration.name.as_str()), + }) +} + fn exit_code(result: &Result<(), String>) -> i32 { if result.is_ok() { 0 @@ -839,7 +888,7 @@ fn prototype_graph_requiredness_label( } fn usage() -> String { - "usage: fparkan corpus discover|validate --root [--format json] | archive inspect [--format json] | terrain inspect [--format json] | wear inspect --root --archive --resource [--format json] | model inspect --root --archive --resource [--format json] | script inspect [--format json] | prototype inspect --root --key [--format json] | mission graph|inspect --root --mission [--format json]".to_string() + "usage: fparkan corpus discover|validate --root [--format json] | archive inspect [--format json] | terrain inspect [--format json] | wear inspect --root --archive --resource [--format json] | model inspect --root --archive --resource [--format json] | script inspect [--format json] | varset inspect [--format json] | prototype inspect --root --key [--format json] | mission graph|inspect --root --mission [--format json]".to_string() } #[cfg(test)] @@ -878,6 +927,17 @@ mod tests { ); } + #[test] + fn varset_inspect_json_has_canonical_field_order() { + let varset = + fparkan_script::parse_varset(b"VAR( float, f0, 0)\nVAR( DWORD, d0, 0xffffffff)\n") + .expect("minimal varset"); + assert_eq!( + varset_inspect_json("varset.var", &varset), + Ok("{\"schema_version\":\"fparkan-varset-inspect-v1\",\"path\":\"varset.var\",\"declarations\":2,\"float_defaults\":1,\"dword_defaults\":1,\"first_name\":\"f0\",\"last_name\":\"d0\"}".to_string()) + ); + } + #[test] fn archive_json_has_schema_version() { let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true)) diff --git a/crates/fparkan-script/src/lib.rs b/crates/fparkan-script/src/lib.rs index 185356f..6ee666f 100644 --- a/crates/fparkan-script/src/lib.rs +++ b/crates/fparkan-script/src/lib.rs @@ -12,6 +12,129 @@ use std::sync::Arc; const INSTRUCTION_HEADER_BYTES: u64 = 28; const INSTRUCTION_WORDS: usize = 7; const GOG_HANDLER_COUNT: u32 = 73; +const MAX_VARSET_DECLARATIONS: usize = 4096; + +/// Parsed defaults from the text `varset.var` source shared by AI packages. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct VarSet { + /// Declarations in original source order. + pub declarations: Vec, +} + +/// One supported `VAR(...)` declaration from `varset.var`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VarSetDeclaration { + /// Variable type spelling used by the original source. + pub type_name: VarSetType, + /// ASCII variable name. + pub name: String, + /// Typed default value. + pub default_value: VarSetDefault, +} + +/// Numeric declaration types present in the shipped GOG `varset.var`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VarSetType { + /// `VAR(float, ...)`. + Float, + /// `VAR(DWORD, ...)`. + Dword, +} + +/// A lossless-in-meaning numeric default from `varset.var`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VarSetDefault { + /// IEEE-754 bits parsed from a `float` literal. + FloatBits(u32), + /// Unsigned 32-bit integer parsed from a decimal or hexadecimal `DWORD` literal. + Dword(u32), +} + +impl VarSetDefault { + /// Returns the float value when this is a `float` default. + #[must_use] + pub fn as_float(self) -> Option { + match self { + Self::FloatBits(bits) => Some(f32::from_bits(bits)), + Self::Dword(_) => None, + } + } + + /// Returns the integer value when this is a `DWORD` default. + #[must_use] + pub fn as_dword(self) -> Option { + match self { + Self::FloatBits(_) => None, + Self::Dword(value) => Some(value), + } + } +} + +/// Error while parsing the documented `varset.var` declaration subset. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum VarSetError { + /// A line beginning with `VAR(` did not have a closing parenthesis. + UnterminatedDeclaration { + /// One-based source line containing the declaration. + line: usize, + }, + /// A declaration omitted one of its first three fields. + MissingField { + /// One-based source line containing the declaration. + line: usize, + }, + /// A declaration type is not one of the observed GOG numeric types. + UnsupportedType { + /// One-based source line containing the declaration. + line: usize, + }, + /// A declaration name or numeric literal was not ASCII text. + NonAsciiField { + /// One-based source line containing the declaration. + line: usize, + }, + /// A `float` default was not a finite Rust-compatible decimal literal. + InvalidFloat { + /// One-based source line containing the declaration. + line: usize, + }, + /// A `DWORD` default was neither a decimal nor a hexadecimal `u32`. + InvalidDword { + /// One-based source line containing the declaration. + line: usize, + }, + /// The input exceeded the bounded declaration count. + TooManyDeclarations { + /// Maximum accepted declaration count. + limit: usize, + }, +} + +impl std::fmt::Display for VarSetError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnterminatedDeclaration { line } => { + write!(formatter, "unterminated VAR declaration at line {line}") + } + Self::MissingField { line } => write!(formatter, "missing VAR field at line {line}"), + Self::UnsupportedType { line } => { + write!(formatter, "unsupported VAR type at line {line}") + } + Self::NonAsciiField { line } => write!(formatter, "non-ASCII VAR field at line {line}"), + Self::InvalidFloat { line } => { + write!(formatter, "invalid float default at line {line}") + } + Self::InvalidDword { line } => { + write!(formatter, "invalid DWORD default at line {line}") + } + Self::TooManyDeclarations { limit } => { + write!(formatter, "VAR declaration count exceeds limit {limit}") + } + } + } +} + +impl std::error::Error for VarSetError {} /// A compiled `.scr` package. #[derive(Clone, Debug, Eq, PartialEq)] @@ -212,6 +335,114 @@ fn is_f32_zero_bits(bits: u32) -> bool { matches!(bits, 0 | 0x8000_0000) } +/// Parses the numeric `VAR(...)` declarations from a legacy `varset.var` file. +/// +/// Parsing is line-oriented and byte-safe: non-UTF-8 comments remain opaque, +/// while the declaration head, type, name, and default value must be ASCII. +/// `STRING(...)`, `FUNCTION(...)`, and all other non-`VAR` lines remain outside +/// this recovered numeric-default contract. +/// +/// # Errors +/// +/// Returns a typed error for malformed supported declarations or inputs above +/// the fixed declaration limit. +pub fn parse_varset(bytes: &[u8]) -> Result { + let mut declarations = Vec::new(); + for (line_index, raw_line) in bytes.split(|byte| *byte == b'\n').enumerate() { + let line = trim_ascii(strip_line_comment(raw_line)); + if !line.starts_with(b"VAR(") { + continue; + } + if declarations.len() == MAX_VARSET_DECLARATIONS { + return Err(VarSetError::TooManyDeclarations { + limit: MAX_VARSET_DECLARATIONS, + }); + } + declarations.push(parse_varset_declaration(line, line_index + 1)?); + } + Ok(VarSet { declarations }) +} + +fn strip_line_comment(line: &[u8]) -> &[u8] { + line.windows(2) + .position(|window| window == b"//") + .map_or(line, |index| &line[..index]) +} + +fn trim_ascii(bytes: &[u8]) -> &[u8] { + let start = bytes + .iter() + .position(|byte| !byte.is_ascii_whitespace()) + .unwrap_or(bytes.len()); + let end = bytes + .iter() + .rposition(|byte| !byte.is_ascii_whitespace()) + .map_or(start, |index| index + 1); + &bytes[start..end] +} + +fn parse_varset_declaration( + line: &[u8], + line_number: usize, +) -> Result { + let declaration = line + .strip_prefix(b"VAR(") + .and_then(|body| body.strip_suffix(b")")) + .or_else(|| { + line.strip_prefix(b"VAR(") + .and_then(|body| body.strip_suffix(b");")) + }) + .ok_or(VarSetError::UnterminatedDeclaration { line: line_number })?; + let mut fields = declaration.split(|byte| *byte == b',').map(trim_ascii); + let type_raw = fields + .next() + .filter(|field| !field.is_empty()) + .ok_or(VarSetError::MissingField { line: line_number })?; + let name_raw = fields + .next() + .filter(|field| !field.is_empty()) + .ok_or(VarSetError::MissingField { line: line_number })?; + let default_raw = fields + .next() + .filter(|field| !field.is_empty()) + .ok_or(VarSetError::MissingField { line: line_number })?; + let type_text = std::str::from_utf8(type_raw) + .map_err(|_| VarSetError::NonAsciiField { line: line_number })?; + let name = std::str::from_utf8(name_raw) + .map_err(|_| VarSetError::NonAsciiField { line: line_number })? + .to_owned(); + let default_text = std::str::from_utf8(default_raw) + .map_err(|_| VarSetError::NonAsciiField { line: line_number })?; + let (type_name, default_value) = match type_text { + "float" => { + let value = default_text + .parse::() + .ok() + .filter(|value| value.is_finite()) + .ok_or(VarSetError::InvalidFloat { line: line_number })?; + (VarSetType::Float, VarSetDefault::FloatBits(value.to_bits())) + } + "DWORD" => ( + VarSetType::Dword, + VarSetDefault::Dword(parse_varset_dword(default_text, line_number)?), + ), + _ => return Err(VarSetError::UnsupportedType { line: line_number }), + }; + Ok(VarSetDeclaration { + type_name, + name, + default_value, + }) +} + +fn parse_varset_dword(value: &str, line: usize) -> Result { + let (radix, digits) = value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + .map_or((10, value), |digits| (16, digits)); + u32::from_str_radix(digits, radix).map_err(|_| VarSetError::InvalidDword { line }) +} + impl ScriptInstruction { /// Returns the recovered dispatch selector from the first disk word. #[must_use] @@ -351,8 +582,9 @@ fn read_instruction( #[cfg(test)] mod tests { use super::{ - decode, decode_with_limits, Handler2RecordInput, Handler2RecordScheduler, - ScriptDispatchSelector, GOG_HANDLER_COUNT, INSTRUCTION_WORDS, + decode, decode_with_limits, parse_varset, Handler2RecordInput, Handler2RecordScheduler, + ScriptDispatchSelector, VarSetDefault, VarSetError, VarSetType, GOG_HANDLER_COUNT, + INSTRUCTION_WORDS, }; use fparkan_binary::{DecodeError, Limits}; @@ -443,6 +675,49 @@ mod tests { assert_eq!(scheduler.records()[0].scalar_6.to_bits(), 3.0_f32.to_bits()); } + #[test] + fn varset_parser_preserves_typed_defaults_and_legacy_comment_bytes() { + let source = b"// \xff legacy comment\r\n\ +VAR( float, fDifficulty, 0.5) // ignored\r\n\ +VAR( DWORD, CLASS_BUILDING, 0x80000000);\r\n\ +STRING( 8, ignored, ignored, ignored)\r\n"; + let parsed = parse_varset(source).expect("valid varset declarations"); + assert_eq!(parsed.declarations.len(), 2); + assert_eq!(parsed.declarations[0].type_name, VarSetType::Float); + assert_eq!(parsed.declarations[0].name, "fDifficulty"); + assert_eq!( + parsed.declarations[0].default_value, + VarSetDefault::FloatBits(0.5_f32.to_bits()) + ); + assert_eq!(parsed.declarations[1].type_name, VarSetType::Dword); + assert_eq!(parsed.declarations[1].name, "CLASS_BUILDING"); + assert_eq!( + parsed.declarations[1].default_value.as_dword(), + Some(0x8000_0000) + ); + assert_eq!(parsed.declarations[0].default_value.as_float(), Some(0.5)); + } + + #[test] + fn varset_parser_rejects_malformed_supported_declarations() { + assert_eq!( + parse_varset(b"VAR( float, f0, nope)\n"), + Err(VarSetError::InvalidFloat { line: 1 }) + ); + assert_eq!( + parse_varset(b"VAR( BYTE, b0, 1)\n"), + Err(VarSetError::UnsupportedType { line: 1 }) + ); + assert_eq!( + parse_varset(b"VAR( DWORD, d0, 0x100000000)\n"), + Err(VarSetError::InvalidDword { line: 1 }) + ); + assert_eq!( + parse_varset(b"VAR( DWORD, d0, 1\n"), + Err(VarSetError::UnterminatedDeclaration { line: 1 }) + ); + } + #[test] fn decodes_lossless_event_and_instruction_records() { let mut bytes = Vec::new(); diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index 4dbd2f8..e387846 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -17,7 +17,13 @@ handlers, второй — числом event records. Каждый event хра records. Вложенный record сохраняет семь `u32` header words (в disk order), список `u32` references после шестого header word и trailing seventh word. Никакой из этих words ещё не получает semantic name. `.fml` — текстовый -symbol/formula oracle; `varset.var` задаёт `VAR(...)`/`STRING(...)` defaults; +symbol/formula oracle; `varset.var` задаёт `VAR(...)`/`STRING(...)` defaults. +`fparkan-script::parse_varset` уже читает подтверждённые numeric +`VAR(float|DWORD, name, default)` declarations byte-safe (comments остаются +opaque, поэтому legacy non-UTF-8 text не ломает загрузку); `STRING(...)` и +`FUNCTION(...)` пока сохранены за границей этого numeric contract; +GOG `MISSIONS/SCRIPTS/varset.var` даёт через него ровно 231 declaration: +31 `float` и 200 `DWORD` (от `f0` до `fY`); `.trf` — NRes tables, чей framing подтверждён, а field semantics местами лишь consumer-inferred.