From d91e63fa9a5d311b8c4e398da2b0d91ecc6f9c9a Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 22:15:55 +0400 Subject: [PATCH] feat(runtime): resolve clan init writes --- apps/fparkan-headless/src/main.rs | 3 +- crates/fparkan-runtime/src/lib.rs | 212 ++++++++++++++++++- docs/appendices/script-vm.md | 29 ++- tools/ghidra/ExportAiCreateSuperAi.java | 23 ++ tools/ghidra/ExportAiFtol.java | 23 ++ tools/ghidra/ExportAiSuperAiConstructor.java | 23 ++ 6 files changed, 305 insertions(+), 8 deletions(-) create mode 100644 tools/ghidra/ExportAiCreateSuperAi.java create mode 100644 tools/ghidra/ExportAiFtol.java create mode 100644 tools/ghidra/ExportAiSuperAiConstructor.java diff --git a/apps/fparkan-headless/src/main.rs b/apps/fparkan-headless/src/main.rs index 013c2da..0da9af9 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={} script_varset_declarations={} graph_failures={}", + "mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} scripts={} script_events={} script_varset_declarations={} script_init_states={} graph_failures={}", loaded.object_count, loaded.areal_count, loaded.surface_count, @@ -69,6 +69,7 @@ fn run() -> Result<(), String> { loaded.script_bundle_count, loaded.script_event_count, loaded.script_varset_declaration_count, + loaded.script_init_state_count, loaded.graph_failure_count ); } diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 13a880f..8095925 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -34,7 +34,9 @@ use fparkan_prototype::{ UnitComponentRecord, }; use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository}; -use fparkan_script::{ScriptPackage, VarSet}; +use fparkan_script::{ + Handler19DwordWrite, Handler19InitInput, ScriptDispatchSelector, ScriptPackage, VarSet, +}; use fparkan_terrain::SurfaceQuery; use fparkan_vfs::{Vfs, VfsError}; use fparkan_world::{ @@ -248,7 +250,8 @@ pub struct MissionScriptBundle { pub base_path: String, /// Resolved compiled `.scr` path. pub script_path: String, - /// Losslessly decoded compiled package; not yet executed by runtime. + /// Losslessly decoded compiled package; only proven `Handler(19)` Init + /// writes are resolved and retained by runtime. pub package: ScriptPackage, } @@ -256,7 +259,7 @@ pub struct MissionScriptBundle { /// /// 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. +/// and supplies the typed targets for the proven `Handler(19)` Init writes. #[derive(Clone, Debug, Eq, PartialEq)] pub struct MissionScriptVarSet { /// Resolved `.var` path selected by the original fallback order. @@ -267,6 +270,21 @@ pub struct MissionScriptVarSet { pub declarations: VarSet, } +/// One resolved, corpus-proven `Init` result for a mission clan. +/// +/// This is intentionally limited to `Handler(19)`: an `Init` event's other +/// selectors are retained in [`MissionScriptBundle::package`] but are not +/// guessed as executable behavior. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MissionScriptInitState { + /// TMA clan index that owns the `SuperAI` instance and varset. + pub clan_index: usize, + /// Raw event word preserved from the compiled `Init` event. + pub event_word: u32, + /// Exact three DWORD writes made by each proven `Handler(19)` instruction. + pub writes: Vec, +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] struct MissionLoadOptions { fail_after_registered_objects: Option, @@ -277,6 +295,7 @@ struct MissionLoadOptions { struct LoadedMissionScripts { bundles: Vec, varset: Option, + init_states: Vec, } /// Selects how much of the resolved mission asset graph to prepare. @@ -366,6 +385,8 @@ pub struct LoadedMission { pub script_event_count: usize, /// Numeric script defaults loaded through the original bundle/fallback rule. pub script_varset_declaration_count: usize, + /// `Handler(19)` Init results resolved for mission clans. + pub script_init_state_count: usize, } /// Frame result. @@ -404,6 +425,7 @@ struct LoadedMissionState { object_drafts: Vec, script_bundles: Vec, script_varset: Option, + script_init_states: Vec, } /// Engine error. @@ -441,6 +463,13 @@ pub enum EngineError { /// Parse diagnostic. message: String, }, + /// A recovered `Handler(19)` Init input or target was not representable. + ScriptInit { + /// Owning TMA clan index. + clan_index: usize, + /// Proven-contract diagnostic. + message: String, + }, /// `NRes` decode error. Nres { /// Resource path. @@ -577,6 +606,10 @@ 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::ScriptInit { + clan_index, + message, + } => write!(f, "clan {clan_index}: script Init 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}"), @@ -619,6 +652,7 @@ impl std::error::Error for EngineError { Self::MissingVfs | Self::Script { .. } | Self::VarSet { .. } + | Self::ScriptInit { .. } | Self::Movement(_) | Self::PrototypeGraph { .. } | Self::SchedulerPhaseOrder { .. } @@ -1064,6 +1098,7 @@ fn load_mission_with_options_and_progress( .varset .as_ref() .map_or(0, |varset| varset.declarations.declarations.len()), + script_init_state_count: loaded_scripts.init_states.len(), }; engine.world = new_runtime_world; @@ -1079,6 +1114,7 @@ fn load_mission_with_options_and_progress( object_drafts, script_bundles: loaded_scripts.bundles, script_varset: loaded_scripts.varset, + script_init_states: loaded_scripts.init_states, }); Ok((summary, trace)) } @@ -1177,6 +1213,18 @@ pub fn loaded_mission_script_varset(engine: &Engine) -> Option<&MissionScriptVar .and_then(|state| state.script_varset.as_ref()) } +/// Returns the proven script `Init` writes resolved for the loaded mission. +/// +/// The list has one entry for each clan whose `Init` event contains recovered +/// `Handler(19)` instructions. It is not a general script event executor. +#[must_use] +pub fn loaded_mission_script_init_states(engine: &Engine) -> Option<&[MissionScriptInitState]> { + engine + .loaded + .as_ref() + .map(|state| state.script_init_states.as_slice()) +} + /// Returns terrain runtime data for the loaded mission. #[must_use] pub fn loaded_terrain(engine: &Engine) -> Option<&TerrainWorld> { @@ -1341,7 +1389,99 @@ fn load_mission_scripts( .as_deref() .map(|base_path| load_script_varset(vfs, base_path)) .transpose()?; - Ok(LoadedMissionScripts { bundles, varset }) + let init_states = match &varset { + Some(varset) => resolve_handler19_init_states( + &mission + .clans + .iter() + .map(|clan| clan.anchor) + .collect::>(), + &bundles, + varset, + )?, + None => Vec::new(), + }; + Ok(LoadedMissionScripts { + bundles, + varset, + init_states, + }) +} + +fn resolve_handler19_init_states( + clan_anchors: &[[f32; 2]], + bundles: &[MissionScriptBundle], + varset: &MissionScriptVarSet, +) -> Result, EngineError> { + let mut states = Vec::new(); + for bundle in bundles { + let Some(anchor) = clan_anchors.get(bundle.clan_index) else { + return Err(EngineError::ScriptInit { + clan_index: bundle.clan_index, + message: "script bundle clan index is outside the decoded mission".to_string(), + }); + }; + let input = Handler19InitInput { + first_x87_word: handler19_x87_truncated_u32(anchor[0], bundle.clan_index, "x")?, + second_x87_word: handler19_x87_truncated_u32(anchor[1], bundle.clan_index, "y")?, + third_word: u32::try_from(bundle.clan_index).map_err(|_| EngineError::ScriptInit { + clan_index: bundle.clan_index, + message: "clan index does not fit the recovered DWORD ClanID field".to_string(), + })?, + }; + for event in &bundle.package.events { + if event.name_raw.as_slice() != b"Init\0" { + continue; + } + let writes: Vec = event + .instructions + .iter() + .filter(|instruction| { + instruction.dispatch_selector() == ScriptDispatchSelector::Handler(19) + }) + .map(|instruction| { + varset + .declarations + .resolve_handler19(instruction, input) + .map_err(|source| EngineError::ScriptInit { + clan_index: bundle.clan_index, + message: source.to_string(), + }) + }) + .collect::, _>>()? + .into_iter() + .flatten() + .collect(); + if !writes.is_empty() { + states.push(MissionScriptInitState { + clan_index: bundle.clan_index, + event_word: event.event_word, + writes, + }); + } + } + } + Ok(states) +} + +fn handler19_x87_truncated_u32( + value: f32, + clan_index: usize, + axis: &'static str, +) -> Result { + if !value.is_finite() || !(0.0..=10_000.0).contains(&value) { + return Err(EngineError::ScriptInit { + clan_index, + message: format!( + "ClanBase{axis}={value:?} is outside the recovered CreateSuperAI base range" + ), + }); + } + // ai.dll's __ftol saves the x87 control word, sets rounding-control bits + // to truncate, performs `fistp qword`, and restores the control word. + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let word = value.trunc() as u32; + Ok(word) } fn load_script_varset( @@ -1488,6 +1628,70 @@ mod tests { )); } + #[test] + fn handler_nineteen_init_executes_from_clan_anchor_for_each_bundle() { + let declarations = fparkan_script::parse_varset( + b"VAR( DWORD, ClanBaseX, 950)\nVAR( DWORD, ClanBaseY, 1000)\nVAR( DWORD, ClanID, 0)\n", + ) + .expect("varset"); + let varset = MissionScriptVarSet { + path: FALLBACK_SCRIPT_VARSET_PATH.to_string(), + used_fallback: true, + declarations, + }; + let bundles = [MissionScriptBundle { + clan_index: 1, + base_path: "MISSIONS/SCRIPTS/default".to_string(), + script_path: "MISSIONS/SCRIPTS/default.scr".to_string(), + package: ScriptPackage { + opcode_handler_count: 73, + events: vec![fparkan_script::ScriptEvent { + name_len: 4, + name_raw: b"Init\0".to_vec(), + event_word: 0x1234_5678, + instructions: vec![fparkan_script::ScriptInstruction { + header_words: [19, 0, 0, 0, 0, 3, 0], + references: vec![0, 1, 2], + }], + }], + trailing_bytes: Vec::new(), + raw: Arc::from(&b""[..]), + }, + }]; + + let states = + resolve_handler19_init_states(&[[500.0, 752.0], [728.0, 449.0]], &bundles, &varset) + .expect("exact Init execution"); + assert_eq!( + states, + vec![MissionScriptInitState { + clan_index: 1, + event_word: 0x1234_5678, + writes: vec![ + Handler19DwordWrite { + index: 0, + value: 728 + }, + Handler19DwordWrite { + index: 1, + value: 449 + }, + Handler19DwordWrite { index: 2, value: 1 }, + ], + }] + ); + } + + #[test] + fn handler_nineteen_init_rejects_anchor_outside_recovered_base_range() { + let error = handler19_x87_truncated_u32(-0.5, 0, "x").expect_err("negative anchor"); + assert!(matches!( + error, + EngineError::ScriptInit { clan_index: 0, message } + if message.contains("outside the recovered CreateSuperAI base range") + )); + } + #[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 3902dfe..dbcd539 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -190,6 +190,26 @@ a license to replace the first two conversions with Rust float casts: their rounding behavior remains an x87 compatibility boundary until it has captured test vectors. +The missing source-field provenance is now constrained by the public creation +boundary. `CreateSuperAI` at `ai.dll` VA `0x1000f710` allocates `0x8b0` bytes +and calls constructor `0x10001000` with its first eight arguments. That +constructor calls `0x10006340(this + 0x7c, clan_id, base_x, base_y)`: these are +the fields later read by `Handler(19)`. `base_x` and `base_y` are unsigned and +the constructor rejects values greater than `10000`. The actual `__ftol` helper +at `0x1001df70` saves the x87 control word, sets its rounding-control bits to +truncate, executes `fistp qword`, then restores the control word. + +Consequently the runtime resolves and retains this one proved vertical slice +during mission loading: each selected clan's TMA anchor is accepted only in the recovered +`0..=10000` base range, truncated through the recovered x87 rule, and paired +with its zero-based clan index. For every `Init` instruction whose selector is +`Handler(19)`, `VarSet::resolve_handler19` produces the three per-clan DWORD +immutable `MissionScriptInitState` writes. Other Init selectors and all other events remain decoded but +unexecuted. AutoDemo validates the path end-to-end: its non-integral first +anchor (`500.2857`) yields captured `ClanBaseX=500`, and the live GOG process +contains two initialized SuperAI entries `(500, 752, 0)` and `(728, 449, 1)`; +the Rust loader reports `script_init_states=2`. + `GetSuperAI` returns element `n` of the 64-pointer global table at preferred `ai.dll + 0x55398` for `n <= 63`. The read-only `tools/capture-ai-init.ps1` probe observed the running GOG AutoDemo values @@ -199,11 +219,14 @@ profile. The Rust reader exposes `VarSet::resolve_handler19`. It accepts the already converted first two words and the third raw word, produces three typed writes, -and rejects missing, out-of-range, or non-`DWORD` targets. It deliberately does -not guess how the mission creates the three source fields or execute a script -event yet. The associated Ghidra scripts are +and rejects missing, out-of-range, or non-`DWORD` targets. The runtime only +binds it to the proven creation/anchor path above; it does not guess the +remaining script event semantics. The associated Ghidra scripts are `ExportAiVmHandler19.java`, `ExportAiVmHandler19Setter.java`, `ExportAiVmHandler19SetterCallee.java`, and `ExportAiGetSuperAi.java`. +The creation and conversion boundaries are reproducible with +`ExportAiCreateSuperAi.java`, `ExportAiSuperAiConstructor.java`, and +`ExportAiFtol.java`. ### Handler(8): problem-record state write diff --git a/tools/ghidra/ExportAiCreateSuperAi.java b/tools/ghidra/ExportAiCreateSuperAi.java new file mode 100644 index 0000000..80117cf --- /dev/null +++ b/tools/ghidra/ExportAiCreateSuperAi.java @@ -0,0 +1,23 @@ +// Emits the GOG CreateSuperAI export to recover the mission-to-SuperAI +// initialization boundary. Run headless; the original PE remains read only. +import ghidra.app.decompiler.DecompInterface; +import ghidra.app.script.GhidraScript; +import ghidra.program.model.address.Address; +import ghidra.program.model.listing.Function; + +public class ExportAiCreateSuperAi extends GhidraScript { + private static final long ADDRESS = 0x1000f710L; + + @Override + public void run() throws Exception { + Address address = currentProgram.getAddressFactory().getDefaultAddressSpace() + .getAddress(ADDRESS); + Function function = currentProgram.getFunctionManager().getFunctionAt(address); + println("===== AI CreateSuperAI ====="); + if (function == null) { println("missing"); return; } + DecompInterface decompiler = new DecompInterface(); + decompiler.openProgram(currentProgram); + println(decompiler.decompileFunction(function, 120, monitor).getDecompiledFunction().getC()); + decompiler.dispose(); + } +} diff --git a/tools/ghidra/ExportAiFtol.java b/tools/ghidra/ExportAiFtol.java new file mode 100644 index 0000000..215dc01 --- /dev/null +++ b/tools/ghidra/ExportAiFtol.java @@ -0,0 +1,23 @@ +// Emits the x87-to-integer helper called by Handler(19). Run headless; the +// original PE remains read only. +import ghidra.app.decompiler.DecompInterface; +import ghidra.app.script.GhidraScript; +import ghidra.program.model.address.Address; +import ghidra.program.model.listing.Function; + +public class ExportAiFtol extends GhidraScript { + private static final long ADDRESS = 0x1001df70L; + + @Override + public void run() throws Exception { + Address address = currentProgram.getAddressFactory().getDefaultAddressSpace() + .getAddress(ADDRESS); + Function function = currentProgram.getFunctionManager().getFunctionAt(address); + println("===== AI x87 __ftol helper ====="); + if (function == null) { println("missing"); return; } + DecompInterface decompiler = new DecompInterface(); + decompiler.openProgram(currentProgram); + println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC()); + decompiler.dispose(); + } +} diff --git a/tools/ghidra/ExportAiSuperAiConstructor.java b/tools/ghidra/ExportAiSuperAiConstructor.java new file mode 100644 index 0000000..85799e9 --- /dev/null +++ b/tools/ghidra/ExportAiSuperAiConstructor.java @@ -0,0 +1,23 @@ +// Emits the SuperAI constructor called by CreateSuperAI. Run headless; the +// original PE remains read only. +import ghidra.app.decompiler.DecompInterface; +import ghidra.app.script.GhidraScript; +import ghidra.program.model.address.Address; +import ghidra.program.model.listing.Function; + +public class ExportAiSuperAiConstructor extends GhidraScript { + private static final long ADDRESS = 0x10001000L; + + @Override + public void run() throws Exception { + Address address = currentProgram.getAddressFactory().getDefaultAddressSpace() + .getAddress(ADDRESS); + Function function = currentProgram.getFunctionManager().getFunctionAt(address); + println("===== AI SuperAI constructor ====="); + if (function == null) { println("missing"); return; } + DecompInterface decompiler = new DecompInterface(); + decompiler.openProgram(currentProgram); + println(decompiler.decompileFunction(function, 120, monitor).getDecompiledFunction().getC()); + decompiler.dispose(); + } +}