From 837b613513e9a68b35ca2406e810b03e6947e223 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 21:23:28 +0400 Subject: [PATCH] feat(runtime): load script varset defaults --- apps/fparkan-headless/src/main.rs | 3 +- crates/fparkan-runtime/src/lib.rs | 160 +++++++++++++++++- docs/appendices/script-vm.md | 4 + tools/ghidra/ExportAiScriptLoaderStrings.java | 27 +++ 4 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 tools/ghidra/ExportAiScriptLoaderStrings.java diff --git a/apps/fparkan-headless/src/main.rs b/apps/fparkan-headless/src/main.rs index e7a1c14..013c2da 100644 --- a/apps/fparkan-headless/src/main.rs +++ b/apps/fparkan-headless/src/main.rs @@ -56,7 +56,7 @@ fn run() -> Result<(), String> { let loaded = load_mission(&mut engine, MissionRequest { key: mission }) .map_err(|err| format!("{err}"))?; println!( - "mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} scripts={} script_events={} graph_failures={}", + "mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} scripts={} script_events={} script_varset_declarations={} graph_failures={}", loaded.object_count, loaded.areal_count, loaded.surface_count, @@ -68,6 +68,7 @@ fn run() -> Result<(), String> { loaded.graph_lightmap_resolved_count, loaded.script_bundle_count, loaded.script_event_count, + loaded.script_varset_declaration_count, loaded.graph_failure_count ); } diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 5ab7dfd..13a880f 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -34,7 +34,7 @@ use fparkan_prototype::{ UnitComponentRecord, }; use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository}; -use fparkan_script::ScriptPackage; +use fparkan_script::{ScriptPackage, VarSet}; use fparkan_terrain::SurfaceQuery; use fparkan_vfs::{Vfs, VfsError}; use fparkan_world::{ @@ -48,6 +48,7 @@ use std::sync::Arc; pub use fparkan_assets::MissionAssets; const MISSION_DECODED_PAYLOAD_CACHE_ENTRIES: usize = 256; +const FALLBACK_SCRIPT_VARSET_PATH: &str = "MISSIONS/SCRIPTS/varset.var"; /// Engine mode. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -251,12 +252,33 @@ pub struct MissionScriptBundle { pub package: ScriptPackage, } +/// Numeric script defaults selected for the mission's first script bundle. +/// +/// GOG `ai.dll` first tries `.var`, then falls back to the shared +/// `MISSIONS/SCRIPTS/varset.var`. This stores the successfully selected source +/// without claiming that the not-yet-recovered VM executes its declarations. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MissionScriptVarSet { + /// Resolved `.var` path selected by the original fallback order. + pub path: String, + /// Whether the shared `MISSIONS/SCRIPTS/varset.var` fallback was used. + pub used_fallback: bool, + /// Typed numeric declaration defaults in source order. + pub declarations: VarSet, +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] struct MissionLoadOptions { fail_after_registered_objects: Option, asset_scope: MissionAssetScope, } +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct LoadedMissionScripts { + bundles: Vec, + varset: Option, +} + /// Selects how much of the resolved mission asset graph to prepare. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] enum MissionAssetScope { @@ -342,6 +364,8 @@ pub struct LoadedMission { pub script_bundle_count: usize, /// Total named events in loaded script packages. pub script_event_count: usize, + /// Numeric script defaults loaded through the original bundle/fallback rule. + pub script_varset_declaration_count: usize, } /// Frame result. @@ -379,6 +403,7 @@ struct LoadedMissionState { asset_plan: MissionAssetPlan, object_drafts: Vec, script_bundles: Vec, + script_varset: Option, } /// Engine error. @@ -409,6 +434,13 @@ pub enum EngineError { /// Decode diagnostic. message: String, }, + /// Script defaults could not be decoded. + VarSet { + /// Script-default path. + path: String, + /// Parse diagnostic. + message: String, + }, /// `NRes` decode error. Nres { /// Resource path. @@ -544,6 +576,7 @@ impl std::fmt::Display for EngineError { } Self::Vfs { path, source } => write!(f, "{path}: {source}"), Self::Script { path, message } => write!(f, "{path}: script decode failed: {message}"), + Self::VarSet { path, message } => write!(f, "{path}: varset decode failed: {message}"), Self::Nres { path, source } => write!(f, "{path}: {source}"), Self::Mission { path, source } => write!(f, "{path}: {source}"), Self::TerrainFormat { path, source } => write!(f, "{path}: {source}"), @@ -585,6 +618,7 @@ impl std::error::Error for EngineError { Self::AssetPreparation { source, .. } => Some(source), Self::MissingVfs | Self::Script { .. } + | Self::VarSet { .. } | Self::Movement(_) | Self::PrototypeGraph { .. } | Self::SchedulerPhaseOrder { .. } @@ -803,7 +837,7 @@ fn load_mission_with_options_and_progress( source, } })?; - let script_bundles = load_mission_script_bundles(&vfs, &mission)?; + let loaded_scripts = load_mission_scripts(&vfs, &mission)?; trace.transforms = mission .objects .iter() @@ -1020,11 +1054,16 @@ fn load_mission_with_options_and_progress( asset_material_count: mission_asset_plan.material_count, asset_texture_count: mission_asset_plan.texture_count, asset_lightmap_count: mission_asset_plan.lightmap_count, - script_bundle_count: script_bundles.len(), - script_event_count: script_bundles + script_bundle_count: loaded_scripts.bundles.len(), + script_event_count: loaded_scripts + .bundles .iter() .map(|bundle| bundle.package.events.len()) .sum(), + script_varset_declaration_count: loaded_scripts + .varset + .as_ref() + .map_or(0, |varset| varset.declarations.declarations.len()), }; engine.world = new_runtime_world; @@ -1038,7 +1077,8 @@ fn load_mission_with_options_and_progress( mission_assets, asset_plan: mission_asset_plan, object_drafts, - script_bundles, + script_bundles: loaded_scripts.bundles, + script_varset: loaded_scripts.varset, }); Ok((summary, trace)) } @@ -1128,6 +1168,15 @@ pub fn loaded_mission_script_bundles(engine: &Engine) -> Option<&[MissionScriptB .map(|state| state.script_bundles.as_slice()) } +/// Returns the numeric script defaults selected by the original `.var` fallback rule. +#[must_use] +pub fn loaded_mission_script_varset(engine: &Engine) -> Option<&MissionScriptVarSet> { + engine + .loaded + .as_ref() + .and_then(|state| state.script_varset.as_ref()) +} + /// Returns terrain runtime data for the loaded mission. #[must_use] pub fn loaded_terrain(engine: &Engine) -> Option<&TerrainWorld> { @@ -1258,12 +1307,13 @@ fn normalize_engine_path(role: &'static str, value: &str) -> Result, mission: &MissionDocument, -) -> Result, EngineError> { +) -> Result { let script_bases = mission_script_bundle_bases(mission); let mut bundles = Vec::with_capacity(script_bases.len()); + let mut first_base_path = None; for script_base in script_bases { let base_path = String::from_utf8_lossy(&script_base.path_raw).into_owned(); let script_path = if base_path.to_ascii_lowercase().ends_with(".scr") { @@ -1277,6 +1327,9 @@ fn load_mission_script_bundles( path: normalized.as_str().to_string(), message: source.to_string(), })?; + if first_base_path.is_none() { + first_base_path = Some(base_path.clone()); + } bundles.push(MissionScriptBundle { clan_index: script_base.clan_index, base_path, @@ -1284,7 +1337,47 @@ fn load_mission_script_bundles( package, }); } - Ok(bundles) + let varset = first_base_path + .as_deref() + .map(|base_path| load_script_varset(vfs, base_path)) + .transpose()?; + Ok(LoadedMissionScripts { bundles, varset }) +} + +fn load_script_varset( + vfs: &Arc, + base_path: &str, +) -> Result { + let base_path = base_path + .strip_suffix(".scr") + .or_else(|| base_path.strip_suffix(".SCR")) + .unwrap_or(base_path); + let primary_path = normalize_engine_path("mission script varset", &format!("{base_path}.var"))?; + let (path, bytes, used_fallback) = match vfs.read(&primary_path) { + Ok(bytes) => (primary_path, bytes, false), + Err(VfsError::NotFound(_)) => { + let fallback_path = + normalize_engine_path("fallback script varset", FALLBACK_SCRIPT_VARSET_PATH)?; + let bytes = read_vfs(vfs, &fallback_path)?; + (fallback_path, bytes, true) + } + Err(source) => { + return Err(EngineError::Vfs { + path: primary_path.as_str().to_string(), + source, + }); + } + }; + let declarations = + fparkan_script::parse_varset(&bytes).map_err(|source| EngineError::VarSet { + path: path.as_str().to_string(), + message: source.to_string(), + })?; + Ok(MissionScriptVarSet { + path: path.as_str().to_string(), + used_fallback, + declarations, + }) } fn read_vfs(vfs: &Arc, path: &NormalizedPath) -> Result, EngineError> { @@ -1297,7 +1390,7 @@ fn read_vfs(vfs: &Arc, path: &NormalizedPath) -> Result, Engi #[cfg(test)] mod tests { use super::*; - use fparkan_vfs::{DirectoryVfs, VfsEntry, VfsMetadata}; + use fparkan_vfs::{DirectoryVfs, MemoryVfs, VfsEntry, VfsMetadata}; use std::path::{Path, PathBuf}; #[test] @@ -1346,6 +1439,55 @@ mod tests { assert_eq!(phases, vec![MissionLoadPhase::Context]); } + #[test] + fn script_varset_prefers_bundle_adjacent_file_then_uses_shared_fallback() { + let mut personal_files = MemoryVfs::default(); + personal_files.insert( + normalize_engine_path("test", "MISSIONS/SCRIPTS/custom.var").expect("path"), + Arc::from(&b"VAR( DWORD, personal, 7)\n"[..]), + ); + personal_files.insert( + normalize_engine_path("test", FALLBACK_SCRIPT_VARSET_PATH).expect("path"), + Arc::from(&b"VAR( DWORD, fallback, 8)\n"[..]), + ); + let personal_vfs: Arc = Arc::new(personal_files); + let personal = + load_script_varset(&personal_vfs, "MISSIONS/SCRIPTS/custom").expect("personal varset"); + assert_eq!(personal.path, "MISSIONS/SCRIPTS/custom.var"); + assert!(!personal.used_fallback); + assert_eq!(personal.declarations.declarations[0].name, "personal"); + + let mut fallback_files = MemoryVfs::default(); + fallback_files.insert( + normalize_engine_path("test", FALLBACK_SCRIPT_VARSET_PATH).expect("path"), + Arc::from(&b"VAR( float, fallback, 0.5)\n"[..]), + ); + let fallback_vfs: Arc = Arc::new(fallback_files); + let fallback = + load_script_varset(&fallback_vfs, "MISSIONS/SCRIPTS/custom").expect("fallback varset"); + assert_eq!(fallback.path, FALLBACK_SCRIPT_VARSET_PATH); + assert!(fallback.used_fallback); + assert_eq!(fallback.declarations.declarations[0].name, "fallback"); + } + + #[test] + fn malformed_personal_script_varset_does_not_silently_use_fallback() { + let mut files = MemoryVfs::default(); + files.insert( + normalize_engine_path("test", "MISSIONS/SCRIPTS/custom.var").expect("path"), + Arc::from(&b"VAR( DWORD, broken, nope)\n"[..]), + ); + files.insert( + normalize_engine_path("test", FALLBACK_SCRIPT_VARSET_PATH).expect("path"), + Arc::from(&b"VAR( DWORD, fallback, 8)\n"[..]), + ); + let vfs: Arc = Arc::new(files); + assert!(matches!( + load_script_varset(&vfs, "MISSIONS/SCRIPTS/custom"), + Err(EngineError::VarSet { path, .. }) if path == "MISSIONS/SCRIPTS/custom.var" + )); + } + #[test] fn headless_scheduler_trace_skips_presentation_phases() { let mut engine = create( diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index e387846..8955834 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -24,6 +24,10 @@ opaque, поэтому legacy non-UTF-8 text не ломает загрузку) `FUNCTION(...)` пока сохранены за границей этого numeric contract; GOG `MISSIONS/SCRIPTS/varset.var` даёт через него ровно 231 declaration: 31 `float` и 200 `DWORD` (от `f0` до `fY`); +loader `ai.dll!0x10001000` сначала открывает `.var` и только при +`not found` откатывается к этому shared file. Runtime повторяет данный порядок +транзакционно и публикует selected `MissionScriptVarSet` с путём/provenance, но +ещё не исполняет declarations как VM state; `.trf` — NRes tables, чей framing подтверждён, а field semantics местами лишь consumer-inferred. diff --git a/tools/ghidra/ExportAiScriptLoaderStrings.java b/tools/ghidra/ExportAiScriptLoaderStrings.java new file mode 100644 index 0000000..e1443c7 --- /dev/null +++ b/tools/ghidra/ExportAiScriptLoaderStrings.java @@ -0,0 +1,27 @@ +// Emits path literals used by the GOG AI script loader at 0x10001000. +// Run headless; the original PE remains read only. +import ghidra.app.script.GhidraScript; +import ghidra.program.model.address.Address; + +public class ExportAiScriptLoaderStrings extends GhidraScript { + private static final long[] ADDRESSES = { + 0x10038a88L, 0x10038a9cL, 0x10038aa4L, 0x10038aacL, + 0x10038ab4L, 0x10038abcL, 0x10038ad0L, 0x10038ad8L, + 0x10038ae0L + }; + + @Override + public void run() throws Exception { + for (long value : ADDRESSES) { + Address address = currentProgram.getAddressFactory().getDefaultAddressSpace() + .getAddress(value); + byte[] bytes = new byte[256]; + int count = currentProgram.getMemory().getBytes(address, bytes); + StringBuilder text = new StringBuilder(); + for (int index = 0; index < count && bytes[index] != 0; index++) { + text.append((char) (bytes[index] & 0xff)); + } + println(address + " = \"" + text + "\""); + } + } +}