From dc8be18458d6bd69e8b47b10bcbe7ddf4ed257f2 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Tue, 20 Oct 2026 03:20:20 +0400 Subject: fix(runtime): preserve native mission outcome guards --- crates/fparkan-runtime/src/lib.rs | 294 ++++++++++++++++++++++++++++++++------ 1 file changed, 247 insertions(+), 47 deletions(-) (limited to 'crates/fparkan-runtime/src') diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 91909d5..c7899c0 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -49,13 +49,26 @@ impl EngineServices { } } +/// Native mission mode used by the recovered host callback. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MissionMode { + /// Native application mode 1, used by the selected `missions/single.*` path. + SingleMission, + /// Native application mode 3, selected by the main-menu idle timer. + AutoDemo, +} + /// Mission request. #[derive(Clone, Debug, Eq, PartialEq)] pub struct MissionRequest { /// Mission key/path. pub key: String, + /// Explicit host mode; this is not inferred from the mission path. + pub mode: MissionMode, } +const INITIAL_SCRIPT_HOST_STATUS: u32 = 5; + /// Ordered mission property preserved for later runtime stages. #[derive(Clone, Debug, Eq, PartialEq)] pub struct MissionObjectProperty { @@ -197,10 +210,11 @@ pub struct MissionScriptVarSetState { pub values: Vec, } -/// Rust-side outcome selected by the recovered `Iron3D` host-callback mapper. +/// Rust-side outcome selected by the recovered `Iron3D` host callback. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum MissionScriptOutcome { - /// No outcome has been recorded in the Rust model. + /// No host callback outcome has been recorded; this does not mean that the + /// native `IGame` has entered its running state. #[default] Active, /// Rust outcome corresponding to completion payload `1`. @@ -218,7 +232,7 @@ pub enum MissionScriptHostEffect { MissionCompleted, /// The Rust outcome changed to failed. MissionFailed, - /// The Rust outcome already held this target state. + /// The native status guard rejected a repeated callback without changing state. NoChange, } @@ -357,6 +371,8 @@ struct LoadedMissionState { script_varset: Option, script_init_states: Vec, script_varset_states: Vec, + script_host_mode: MissionMode, + script_host_status: u32, script_outcome: MissionScriptOutcome, } @@ -899,6 +915,10 @@ fn load_mission_with_options( script_varset: loaded_scripts.varset, script_init_states: loaded_scripts.init_states, script_varset_states: loaded_scripts.varset_states, + script_host_mode: request.mode, + // Fresh IGame construction seeds this field with 5; this is not the + // later status-4 transition performed after native game initialization. + script_host_status: INITIAL_SCRIPT_HOST_STATUS, script_outcome: MissionScriptOutcome::Active, }); Ok(summary) @@ -997,8 +1017,9 @@ pub fn loaded_mission_script_varset_states(engine: &Engine) -> Option<&[MissionS /// /// Unlike the loader-only resolver, operands are read from the selected /// clan's instantiated varset cells. Dispatching the returned host callback -/// remains separate from this function; the Rust outcome mapper does not model -/// the native `IGame` guards or initial status word. +/// remains separate from this function. The runtime currently models the +/// callback's recovered mode and status guards, but not the full game startup +/// transition or frame/event execution that reaches it. /// /// # Errors /// @@ -1040,13 +1061,16 @@ pub fn loaded_mission_script_outcome(engine: &Engine) -> Option MissionScriptHostEffect { - if command.mode != 0 || command.first != 0 { + if native_mode == MissionMode::AutoDemo || command.mode != 0 || command.first != 0 { return MissionScriptHostEffect::Unhandled; } - let next = match command.second { - 0 => MissionScriptOutcome::Failed, - 1 => MissionScriptOutcome::Completed, - _ => return MissionScriptHostEffect::Unhandled, - }; - if *outcome == next { - return MissionScriptHostEffect::NoChange; - } - *outcome = next; - match next { - MissionScriptOutcome::Completed => MissionScriptHostEffect::MissionCompleted, - MissionScriptOutcome::Failed => MissionScriptHostEffect::MissionFailed, - MissionScriptOutcome::Active => MissionScriptHostEffect::Unhandled, + match command.second { + 0 if *status != 1 => { + *status = 1; + *outcome = MissionScriptOutcome::Failed; + MissionScriptHostEffect::MissionFailed + } + 0 => MissionScriptHostEffect::NoChange, + 1 if *status != 0 => { + *status = 0; + *outcome = MissionScriptOutcome::Completed; + MissionScriptHostEffect::MissionCompleted + } + 1 => MissionScriptHostEffect::NoChange, + _ => MissionScriptHostEffect::Unhandled, } } @@ -1446,6 +1475,7 @@ mod tests { &mut engine, MissionRequest { key: "MISSIONS/Autodemo.00/data.tma".to_string(), + mode: MissionMode::SingleMission, }, ) .expect_err("missing VFS"); @@ -1598,54 +1628,212 @@ mod tests { } #[test] - fn recovered_handler_thirty_host_callback_transitions_mission_outcome() { + fn handler_thirty_host_callback_obeys_native_mode_and_status_guards() { + fn command(second: u32) -> VmHostCallbackCommand { + VmHostCallbackCommand { + mode: 0, + first: 0, + second, + } + } + + let mut status = 5; let mut outcome = MissionScriptOutcome::Active; assert_eq!( apply_recovered_script_host_callback( + MissionMode::SingleMission, + &mut status, &mut outcome, - VmHostCallbackCommand { - mode: 0, - first: 0, - second: 0, - }, + command(0), ), MissionScriptHostEffect::MissionFailed ); - assert_eq!(outcome, MissionScriptOutcome::Failed); + assert_eq!((status, outcome), (1, MissionScriptOutcome::Failed)); + outcome = MissionScriptOutcome::Completed; assert_eq!( apply_recovered_script_host_callback( + MissionMode::SingleMission, + &mut status, &mut outcome, - VmHostCallbackCommand { - mode: 0, - first: 0, - second: 1, - }, + command(0), + ), + MissionScriptHostEffect::NoChange + ); + assert_eq!((status, outcome), (1, MissionScriptOutcome::Completed)); + outcome = MissionScriptOutcome::Failed; + + assert_eq!( + apply_recovered_script_host_callback( + MissionMode::SingleMission, + &mut status, + &mut outcome, + command(1), + ), + MissionScriptHostEffect::MissionCompleted + ); + assert_eq!((status, outcome), (0, MissionScriptOutcome::Completed)); + assert_eq!( + apply_recovered_script_host_callback( + MissionMode::SingleMission, + &mut status, + &mut outcome, + command(1), + ), + MissionScriptHostEffect::NoChange + ); + assert_eq!((status, outcome), (0, MissionScriptOutcome::Completed)); + + assert_eq!( + apply_recovered_script_host_callback( + MissionMode::SingleMission, + &mut status, + &mut outcome, + command(0), + ), + MissionScriptHostEffect::MissionFailed + ); + assert_eq!((status, outcome), (1, MissionScriptOutcome::Failed)); + + status = 4; + outcome = MissionScriptOutcome::Active; + assert_eq!( + apply_recovered_script_host_callback( + MissionMode::SingleMission, + &mut status, + &mut outcome, + command(1), ), MissionScriptHostEffect::MissionCompleted ); - assert_eq!(outcome, MissionScriptOutcome::Completed); + assert_eq!((status, outcome), (0, MissionScriptOutcome::Completed)); + + status = 5; + outcome = MissionScriptOutcome::Active; assert_eq!( apply_recovered_script_host_callback( + MissionMode::AutoDemo, + &mut status, &mut outcome, + command(0), + ), + MissionScriptHostEffect::Unhandled + ); + assert_eq!((status, outcome), (5, MissionScriptOutcome::Active)); + + for command in [ + VmHostCallbackCommand { + mode: 1, + first: 0, + second: 0, + }, + VmHostCallbackCommand { + mode: 0, + first: 3, + second: 0, + }, + VmHostCallbackCommand { + mode: 0, + first: 0, + second: 42, + }, + ] { + assert_eq!( + apply_recovered_script_host_callback( + MissionMode::SingleMission, + &mut status, + &mut outcome, + command, + ), + MissionScriptHostEffect::Unhandled + ); + assert_eq!((status, outcome), (5, MissionScriptOutcome::Active)); + } + } + + #[test] + #[ignore = "requires licensed corpus"] + fn loaded_host_callback_uses_requested_mode_and_constructor_status() { + let root = licensed_root("IS"); + let vfs: Arc = Arc::new(DirectoryVfs::new(root)); + let key = "MISSIONS/Autodemo.00/data.tma".to_string(); + let mut engine = create(EngineServices::new(vfs)).expect("engine"); + + load_mission_static_preview_roots( + &mut engine, + MissionRequest { + key: key.clone(), + mode: MissionMode::SingleMission, + }, + NonZeroUsize::new(1).expect("non-zero root count"), + ) + .expect("load single-mission preview"); + let state = engine.loaded.as_mut().expect("loaded state"); + assert_eq!(state.script_host_status, INITIAL_SCRIPT_HOST_STATUS); + assert_eq!(state.script_outcome, MissionScriptOutcome::Active); + assert_eq!( + apply_loaded_script_host_callback( + &mut engine, VmHostCallbackCommand { mode: 0, first: 0, - second: 1, + second: 0, }, - ), + ) + .expect("apply failure callback"), + MissionScriptHostEffect::MissionFailed + ); + assert_eq!(engine.loaded.as_ref().unwrap().script_host_status, 1); + assert_eq!( + loaded_mission_script_outcome(&engine), + Some(MissionScriptOutcome::Failed) + ); + + engine.loaded.as_mut().unwrap().script_outcome = MissionScriptOutcome::Completed; + assert_eq!( + apply_loaded_script_host_callback( + &mut engine, + VmHostCallbackCommand { + mode: 0, + first: 0, + second: 0, + }, + ) + .expect("apply repeated failure callback"), MissionScriptHostEffect::NoChange ); + assert_eq!(engine.loaded.as_ref().unwrap().script_host_status, 1); assert_eq!( - apply_recovered_script_host_callback( - &mut outcome, + loaded_mission_script_outcome(&engine), + Some(MissionScriptOutcome::Completed) + ); + + load_mission_static_preview_roots( + &mut engine, + MissionRequest { + key, + mode: MissionMode::AutoDemo, + }, + NonZeroUsize::new(1).expect("non-zero root count"), + ) + .expect("reload AutoDemo preview"); + assert_eq!(engine.loaded.as_ref().unwrap().script_host_status, 5); + assert_eq!( + apply_loaded_script_host_callback( + &mut engine, VmHostCallbackCommand { mode: 0, - first: 3, - second: 42, + first: 0, + second: 1, }, - ), + ) + .expect("apply AutoDemo callback"), MissionScriptHostEffect::Unhandled ); + assert_eq!(engine.loaded.as_ref().unwrap().script_host_status, 5); + assert_eq!( + loaded_mission_script_outcome(&engine), + Some(MissionScriptOutcome::Active) + ); } #[test] @@ -1676,6 +1864,7 @@ mod tests { &mut engine, MissionRequest { key: "MISSIONS/Autodemo.00/data.tma".to_string(), + mode: MissionMode::SingleMission, }, ) .expect("load mission"); @@ -1732,12 +1921,14 @@ mod tests { DenyRule::Suffix("Land.map"), MissionRequest { key: "MISSIONS/Autodemo.00/data.tma".to_string(), + mode: MissionMode::SingleMission, }, ), ( DenyRule::Suffix("objects.rlb"), MissionRequest { key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(), + mode: MissionMode::SingleMission, }, ), ] { @@ -1773,6 +1964,7 @@ mod tests { &mut engine, MissionRequest { key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(), + mode: MissionMode::SingleMission, }, MissionLoadOptions { fail_after_registered_objects: Some(1), @@ -1842,6 +2034,7 @@ mod tests { &mut engine, MissionRequest { key: case.1.to_string(), + mode: MissionMode::SingleMission, }, ) .expect("load mission"); @@ -1954,6 +2147,7 @@ mod tests { &mut engine, MissionRequest { key: case.mission.to_string(), + mode: MissionMode::SingleMission, }, ) .expect("load selected mission"); @@ -1978,8 +2172,14 @@ mod tests { let mut totals = LoadTotals::default(); for mission in missions { let mut engine = create(EngineServices::new(vfs.clone())).expect("engine"); - let loaded = load_mission(&mut engine, MissionRequest { key: mission }) - .expect("load mission foundation"); + let loaded = load_mission( + &mut engine, + MissionRequest { + key: mission, + mode: MissionMode::SingleMission, + }, + ) + .expect("load mission foundation"); assert_eq!(loaded.object_count, loaded.registered_objects); assert_eq!(loaded.object_count, loaded.graph_root_count); assert_eq!( -- cgit v1.2.3