diff --git a/apps/fparkan-headless/src/main.rs b/apps/fparkan-headless/src/main.rs index 0da9af9..9c5ab52 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={} script_init_states={} graph_failures={}", + "mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} scripts={} script_events={} script_varset_declarations={} script_init_states={} script_varset_states={} graph_failures={}", loaded.object_count, loaded.areal_count, loaded.surface_count, @@ -70,6 +70,7 @@ fn run() -> Result<(), String> { loaded.script_event_count, loaded.script_varset_declaration_count, loaded.script_init_state_count, + loaded.script_varset_state_count, loaded.graph_failure_count ); } diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 8095925..771a1d4 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -36,6 +36,7 @@ use fparkan_prototype::{ use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository}; use fparkan_script::{ Handler19DwordWrite, Handler19InitInput, ScriptDispatchSelector, ScriptPackage, VarSet, + VarSetDefault, }; use fparkan_terrain::SurfaceQuery; use fparkan_vfs::{Vfs, VfsError}; @@ -285,6 +286,18 @@ pub struct MissionScriptInitState { pub writes: Vec, } +/// Per-clan mutable-value model of one instantiated mission varset. +/// +/// Declarations remain shared source metadata; these values are the runtime +/// cells that later recovered handlers will read and write. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MissionScriptVarSetState { + /// TMA clan index owning this instantiated varset. + pub clan_index: usize, + /// Values in the original varset declaration order. + pub values: Vec, +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] struct MissionLoadOptions { fail_after_registered_objects: Option, @@ -296,6 +309,7 @@ struct LoadedMissionScripts { bundles: Vec, varset: Option, init_states: Vec, + varset_states: Vec, } /// Selects how much of the resolved mission asset graph to prepare. @@ -387,6 +401,8 @@ pub struct LoadedMission { pub script_varset_declaration_count: usize, /// `Handler(19)` Init results resolved for mission clans. pub script_init_state_count: usize, + /// Instantiated per-clan varsets whose Init writes have been applied. + pub script_varset_state_count: usize, } /// Frame result. @@ -426,6 +442,7 @@ struct LoadedMissionState { script_bundles: Vec, script_varset: Option, script_init_states: Vec, + script_varset_states: Vec, } /// Engine error. @@ -1099,6 +1116,7 @@ fn load_mission_with_options_and_progress( .as_ref() .map_or(0, |varset| varset.declarations.declarations.len()), script_init_state_count: loaded_scripts.init_states.len(), + script_varset_state_count: loaded_scripts.varset_states.len(), }; engine.world = new_runtime_world; @@ -1115,6 +1133,7 @@ fn load_mission_with_options_and_progress( script_bundles: loaded_scripts.bundles, script_varset: loaded_scripts.varset, script_init_states: loaded_scripts.init_states, + script_varset_states: loaded_scripts.varset_states, }); Ok((summary, trace)) } @@ -1225,6 +1244,15 @@ pub fn loaded_mission_script_init_states(engine: &Engine) -> Option<&[MissionScr .map(|state| state.script_init_states.as_slice()) } +/// Returns instantiated per-clan varsets after proven Init writes are applied. +#[must_use] +pub fn loaded_mission_script_varset_states(engine: &Engine) -> Option<&[MissionScriptVarSetState]> { + engine + .loaded + .as_ref() + .map(|state| state.script_varset_states.as_slice()) +} + /// Returns terrain runtime data for the loaded mission. #[must_use] pub fn loaded_terrain(engine: &Engine) -> Option<&TerrainWorld> { @@ -1401,13 +1429,58 @@ fn load_mission_scripts( )?, None => Vec::new(), }; + let varset_states = match &varset { + Some(varset) => materialize_script_varset_states(&bundles, varset, &init_states)?, + None => Vec::new(), + }; Ok(LoadedMissionScripts { bundles, varset, init_states, + varset_states, }) } +fn materialize_script_varset_states( + bundles: &[MissionScriptBundle], + varset: &MissionScriptVarSet, + init_states: &[MissionScriptInitState], +) -> Result, EngineError> { + let defaults: Vec<_> = varset + .declarations + .declarations + .iter() + .map(|declaration| declaration.default_value) + .collect(); + bundles + .iter() + .map(|bundle| { + let mut values = defaults.clone(); + for init in init_states + .iter() + .filter(|init| init.clan_index == bundle.clan_index) + { + for write in &init.writes { + let Some(value) = values.get_mut(write.index as usize) else { + return Err(EngineError::ScriptInit { + clan_index: bundle.clan_index, + message: format!( + "Handler(19) write index {} is outside the instantiated varset", + write.index + ), + }); + }; + *value = VarSetDefault::Dword(write.value); + } + } + Ok(MissionScriptVarSetState { + clan_index: bundle.clan_index, + values, + }) + }) + .collect() +} + fn resolve_handler19_init_states( clan_anchors: &[[f32; 2]], bundles: &[MissionScriptBundle], @@ -1680,6 +1753,19 @@ mod tests { ], }] ); + let runtime_varsets = materialize_script_varset_states(&bundles, &varset, &states) + .expect("materialized per-clan varset"); + assert_eq!( + runtime_varsets, + vec![MissionScriptVarSetState { + clan_index: 1, + values: vec![ + VarSetDefault::Dword(728), + VarSetDefault::Dword(449), + VarSetDefault::Dword(1), + ], + }] + ); } #[test] diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index dbcd539..59c1512 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -204,11 +204,14 @@ during mission loading: each selected clan's TMA anchor is accepted only in the `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 +writes. The runtime then materializes an independent declaration-ordered value +array for each selected clan and applies those writes, so later recovered +handlers can consume `ClanBaseX`, `ClanBaseY`, and `ClanID` as runtime cells +rather than loader defaults. 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`. +the Rust loader reports `script_init_states=2` and `script_varset_states=2`. `GetSuperAI` returns element `n` of the 64-pointer global table at preferred `ai.dll + 0x55398` for `n <= 63`. The read-only