aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-07-18 20:17:57 +0300
committerValentin Popov <valentin@popov.link>2026-07-18 20:17:57 +0300
commita7882545b7a9aa0409dae95f8f4e51922b438615 (patch)
treec0acd3022fc650db49c856fb2e071d6376c42ac6
parent3619f22b42bd9217ab30a26b8287fb50b7398971 (diff)
downloadfparkan-a7882545b7a9aa0409dae95f8f4e51922b438615.tar.xz
fparkan-a7882545b7a9aa0409dae95f8f4e51922b438615.zip
feat(script): parse varset numeric defaults
-rw-r--r--apps/fparkan-cli/src/main.rs62
-rw-r--r--crates/fparkan-script/src/lib.rs279
-rw-r--r--docs/appendices/script-vm.md8
3 files changed, 345 insertions, 4 deletions
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<String> = 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<String, String> {
+ 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 <path> [--format json] | archive inspect <file> [--format json] | terrain inspect <Land.msh> [--format json] | wear inspect --root <path> --archive <archive> --resource <wear.wea> [--format json] | model inspect --root <path> --archive <archive> --resource <model.msh> [--format json] | script inspect <file> [--format json] | prototype inspect --root <path> --key <key> [--format json] | mission graph|inspect --root <path> --mission <path> [--format json]".to_string()
+ "usage: fparkan corpus discover|validate --root <path> [--format json] | archive inspect <file> [--format json] | terrain inspect <Land.msh> [--format json] | wear inspect --root <path> --archive <archive> --resource <wear.wea> [--format json] | model inspect --root <path> --archive <archive> --resource <model.msh> [--format json] | script inspect <file> [--format json] | varset inspect <file> [--format json] | prototype inspect --root <path> --key <key> [--format json] | mission graph|inspect --root <path> --mission <path> [--format json]".to_string()
}
#[cfg(test)]
@@ -879,6 +928,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))
.expect("serialize archive inspection");
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<VarSetDeclaration>,
+}
+
+/// 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<f32> {
+ 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<u32> {
+ 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<VarSet, VarSetError> {
+ 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<VarSetDeclaration, VarSetError> {
+ 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::<f32>()
+ .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<u32, VarSetError> {
+ 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};
@@ -444,6 +676,49 @@ mod tests {
}
#[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();
bytes.extend_from_slice(&73_u32.to_le_bytes());
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.