diff options
| author | Valentin Popov <valentin@popov.link> | 2026-10-20 03:11:35 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-10-20 03:11:35 +0300 |
| commit | 31fd9d2ea9b7fb01a952eef38a2af31eceebbe86 (patch) | |
| tree | 8ad10aa8b2ab02ed298c9b62b050c5fa73d7d382 /crates/fparkan-runtime/src/lib.rs | |
| parent | dc8be18458d6bd69e8b47b10bcbe7ddf4ed257f2 (diff) | |
| download | fparkan-31fd9d2ea9b7fb01a952eef38a2af31eceebbe86.tar.xz fparkan-31fd9d2ea9b7fb01a952eef38a2af31eceebbe86.zip | |
fix(runtime): execute the first supported Init event
Diffstat (limited to 'crates/fparkan-runtime/src/lib.rs')
| -rw-r--r-- | crates/fparkan-runtime/src/lib.rs | 445 |
1 files changed, 395 insertions, 50 deletions
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index c7899c0..00e9cc5 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -16,8 +16,8 @@ use fparkan_prototype::{ }; use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository}; use fparkan_script::{ - Handler19DwordWrite, Handler19InitInput, ScriptDispatchSelector, ScriptInstruction, - ScriptPackage, VarSet, VarSetDefault, VmHostCallbackCommand, + Handler19DwordWrite, Handler19InitInput, Handler19InitResolution, Handler19ResolveError, + ScriptInstruction, ScriptPackage, VarSet, VarSetDefault, VmHostCallbackCommand, }; use fparkan_terrain::SurfaceQuery; use fparkan_vfs::{Vfs, VfsError}; @@ -166,6 +166,8 @@ pub struct MissionScriptBundle { /// Losslessly decoded compiled package; only proven `Handler(19)` Init /// writes are resolved and retained by runtime. pub package: ScriptPackage, + /// Outcome of the restricted first-`Init` resolution for this bundle. + pub init_execution: MissionScriptInitExecution, } /// Numeric script defaults selected for the mission's first script bundle. @@ -192,12 +194,36 @@ pub struct MissionScriptVarSet { pub struct MissionScriptInitState { /// TMA clan index that owns the `SuperAI` instance and varset. pub clan_index: usize, + /// Zero-based first case-sensitive C-string `Init` event selected by native lookup. + pub event_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<Handler19DwordWrite>, } +/// Execution status of the first event whose case-sensitive C-string name is `Init`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MissionScriptInitExecution { + /// Native name lookup found no event named `Init` and made no handler call. + Missing, + /// The selected event completed within the currently recovered action subset. + Executed { + /// Zero-based event table index selected by native lookup. + event_index: usize, + }, + /// A preview retained the decoded package but stopped before the first + /// instruction outside the recovered `Handler(19)` action subset. + Unsupported { + /// Zero-based event table index selected by native lookup. + event_index: usize, + /// Zero-based instruction index where restricted resolution stopped. + instruction_index: usize, + /// Typed selector, control, or result-save shape that is not modeled. + error: Handler19ResolveError, + }, +} + /// Per-clan mutable-value model of one instantiated mission varset. /// /// Declarations remain shared source metadata; these values are the runtime @@ -418,6 +444,19 @@ pub enum EngineError { /// Proven-contract diagnostic. message: String, }, + /// A full mission load reached an unsupported instruction in its first Init event. + ScriptInitUnsupported { + /// Owning TMA clan index. + clan_index: usize, + /// Resolved `.scr` package path. + path: String, + /// Zero-based first exact Init event index. + event_index: usize, + /// Zero-based instruction index within that event. + instruction_index: usize, + /// Typed unsupported instruction shape. + source: Handler19ResolveError, + }, /// A recovered script handler could not resolve against runtime state. ScriptRuntime { /// Owning TMA clan index. @@ -558,6 +597,16 @@ impl std::fmt::Display for EngineError { clan_index, message, } => write!(f, "clan {clan_index}: script Init failed: {message}"), + Self::ScriptInitUnsupported { + clan_index, + path, + event_index, + instruction_index, + source, + } => write!( + f, + "{path}: clan {clan_index} Init event {event_index} instruction {instruction_index} is unsupported: {source}" + ), Self::ScriptRuntime { clan_index, message, @@ -594,6 +643,7 @@ impl std::error::Error for EngineError { Self::Nres { source, .. } => Some(source), Self::Mission { source, .. } => Some(source), Self::TerrainFormat { source, .. } => Some(source), + Self::ScriptInitUnsupported { source, .. } => Some(source), Self::Terrain(source) => Some(source), Self::World(source) => Some(source), Self::AssetPreparation { source, .. } => Some(source), @@ -736,7 +786,7 @@ fn load_mission_with_options( source, } })?; - let loaded_scripts = load_mission_scripts(&vfs, &mission)?; + let loaded_scripts = load_mission_scripts(&vfs, &mission, options.asset_scope)?; let repository = CachedResourceRepository::with_payload_cache_budget( vfs.clone(), MISSION_DECODED_PAYLOAD_CACHE_ENTRIES, @@ -1195,6 +1245,7 @@ fn normalize_engine_path(role: &'static str, value: &str) -> Result<NormalizedPa fn load_mission_scripts( vfs: &Arc<dyn Vfs>, mission: &MissionDocument, + asset_scope: MissionAssetScope, ) -> Result<LoadedMissionScripts, EngineError> { let script_bases = mission_script_bundle_bases(mission); let mut bundles = Vec::with_capacity(script_bases.len()); @@ -1220,6 +1271,7 @@ fn load_mission_scripts( base_path, script_path: normalized.as_str().to_string(), package, + init_execution: MissionScriptInitExecution::Missing, }); } let varset = first_base_path @@ -1233,8 +1285,9 @@ fn load_mission_scripts( .iter() .map(|clan| clan.anchor) .collect::<Vec<_>>(), - &bundles, + &mut bundles, varset, + asset_scope, )?, None => Vec::new(), }; @@ -1292,11 +1345,21 @@ fn materialize_script_varset_states( fn resolve_handler19_init_states( clan_anchors: &[[f32; 2]], - bundles: &[MissionScriptBundle], + bundles: &mut [MissionScriptBundle], varset: &MissionScriptVarSet, + asset_scope: MissionAssetScope, ) -> Result<Vec<MissionScriptInitState>, EngineError> { let mut states = Vec::new(); for bundle in bundles { + if !bundle + .package + .events + .iter() + .any(|event| event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..])) + { + bundle.init_execution = MissionScriptInitExecution::Missing; + continue; + } let Some(anchor) = clan_anchors.get(bundle.clan_index) else { return Err(EngineError::ScriptInit { clan_index: bundle.clan_index, @@ -1304,41 +1367,60 @@ fn resolve_handler19_init_states( }); }; 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")?, + first_x87_word: handler19_anchor_word(anchor[0], bundle.clan_index, "x")?, + second_x87_word: handler19_anchor_word(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; + match varset + .declarations + .resolve_handler19_init(&bundle.package, input) + .map_err(|source| EngineError::ScriptInit { + clan_index: bundle.clan_index, + message: source.to_string(), + })? { + Handler19InitResolution::Missing => { + bundle.init_execution = MissionScriptInitExecution::Missing; } - let writes: Vec<Handler19DwordWrite> = 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::<Result<Vec<_>, _>>()? - .into_iter() - .flatten() - .collect(); - if !writes.is_empty() { - states.push(MissionScriptInitState { + Handler19InitResolution::Executed { + event_index, + event_word, + writes, + } => { + bundle.init_execution = MissionScriptInitExecution::Executed { event_index }; + if !writes.is_empty() { + states.push(MissionScriptInitState { + clan_index: bundle.clan_index, + event_index, + event_word, + writes, + }); + } + } + Handler19InitResolution::Unsupported { + event_index, + instruction_index, + error, + } if asset_scope != MissionAssetScope::Full => { + bundle.init_execution = MissionScriptInitExecution::Unsupported { + event_index, + instruction_index, + error, + }; + } + Handler19InitResolution::Unsupported { + event_index, + instruction_index, + error, + } => { + return Err(EngineError::ScriptInitUnsupported { clan_index: bundle.clan_index, - event_word: event.event_word, - writes, + path: bundle.script_path.clone(), + event_index, + instruction_index, + source: error, }); } } @@ -1346,24 +1428,29 @@ fn resolve_handler19_init_states( Ok(states) } -fn handler19_x87_truncated_u32( +fn handler19_anchor_word( value: f32, clan_index: usize, axis: &'static str, ) -> Result<u32, EngineError> { - if !value.is_finite() || !(0.0..=10_000.0).contains(&value) { + const SIGNED_QWORD_LIMIT: f32 = 9_223_372_036_854_775_808.0; + + let rounded = value.round_ties_even(); + if !value.is_finite() || rounded < -SIGNED_QWORD_LIMIT || rounded >= SIGNED_QWORD_LIMIT { return Err(EngineError::ScriptInit { clan_index, message: format!( - "ClanBase{axis}={value:?} is outside the recovered CreateSuperAI base range" + "ClanBase{axis}={value:?} is outside the modeled x87 FISTP qword range" ), }); } - // ai.dll's __ftol saves the x87 control word, sets rounding-control bits - // to truncate, performs `fistp qword`, and restores the control word. - let word = value.trunc() as u32; - Ok(word) + // Iron3D FISTP qword keeps its low DWORD; AI FUN_10005c70 then round-trips + // that unsigned word through f32 before Handler(19)'s truncating __ftol. + let creation_word = (rounded as i64) as u32; + let ai_field = creation_word as f32; + let handler_qword = ai_field.trunc() as i64; + Ok(handler_qword as u32) } fn load_script_varset( @@ -1412,6 +1499,7 @@ fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, Engi #[cfg(test)] mod tests { use super::*; + use fparkan_script::ScriptDispatchSelector; use fparkan_vfs::{DirectoryVfs, MemoryVfs, VfsEntry, VfsMetadata}; use std::path::{Path, PathBuf}; @@ -1544,7 +1632,7 @@ mod tests { used_fallback: true, declarations, }; - let bundles = [MissionScriptBundle { + let mut bundles = [MissionScriptBundle { clan_index: 1, base_path: "MISSIONS/SCRIPTS/default".to_string(), script_path: "MISSIONS/SCRIPTS/default.scr".to_string(), @@ -1555,22 +1643,28 @@ mod tests { 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], + header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0], references: vec![0, 1, 2], }], }], trailing_bytes: Vec::new(), raw: Arc::from(&b""[..]), }, + init_execution: MissionScriptInitExecution::Missing, }]; - let states = - resolve_handler19_init_states(&[[500.0, 752.0], [728.0, 449.0]], &bundles, &varset) - .expect("exact Init execution"); + let states = resolve_handler19_init_states( + &[[500.0, 752.0], [728.0, 449.0]], + &mut bundles, + &varset, + MissionAssetScope::Full, + ) + .expect("exact Init execution"); assert_eq!( states, vec![MissionScriptInitState { clan_index: 1, + event_index: 0, event_word: 0x1234_5678, writes: vec![ Handler19DwordWrite { @@ -1618,16 +1712,162 @@ mod tests { } #[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"); + fn handler_nineteen_preview_keeps_unsupported_package_without_partial_writes() { + let varset = MissionScriptVarSet { + path: FALLBACK_SCRIPT_VARSET_PATH.to_string(), + used_fallback: true, + declarations: fparkan_script::parse_varset( + b"VAR( DWORD, x, 950)\nVAR( DWORD, y, 1000)\nVAR( DWORD, id, 0)\n", + ) + .expect("varset"), + }; + let package = ScriptPackage { + opcode_handler_count: 73, + events: vec![fparkan_script::ScriptEvent { + name_len: 4, + name_raw: b"Init\0".to_vec(), + event_word: 0x1234, + instructions: vec![ + fparkan_script::ScriptInstruction { + header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0], + references: vec![0, 1, 2], + }, + fparkan_script::ScriptInstruction { + header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0], + references: Vec::new(), + }, + ], + }], + trailing_bytes: Vec::new(), + raw: Arc::from(&b""[..]), + }; + let bundle = || MissionScriptBundle { + clan_index: 0, + base_path: "MISSIONS/SCRIPTS/default".to_string(), + script_path: "MISSIONS/SCRIPTS/default.scr".to_string(), + package: package.clone(), + init_execution: MissionScriptInitExecution::Missing, + }; + let anchors = [[500.0, 752.0]]; + let mut preview_bundles = vec![bundle()]; + let preview_states = resolve_handler19_init_states( + &anchors, + &mut preview_bundles, + &varset, + MissionAssetScope::FirstMeshPreview, + ) + .expect("static preview retains unsupported script"); + assert!(preview_states.is_empty(), "no partial writes are retained"); + assert_eq!( + preview_bundles[0].init_execution, + MissionScriptInitExecution::Unsupported { + event_index: 0, + instruction_index: 1, + error: Handler19ResolveError::UnsupportedSelector { + found: ScriptDispatchSelector::Handler(18), + }, + } + ); + assert_eq!( + materialize_script_varset_states(&preview_bundles, &varset, &preview_states) + .expect("preview keeps default varset values"), + vec![MissionScriptVarSetState { + clan_index: 0, + values: vec![ + VarSetDefault::Dword(950), + VarSetDefault::Dword(1000), + VarSetDefault::Dword(0), + ], + }] + ); + assert_eq!(preview_bundles[0].package, package); + + let mut full_bundles = vec![bundle()]; + assert!(matches!( + resolve_handler19_init_states( + &anchors, + &mut full_bundles, + &varset, + MissionAssetScope::Full, + ), + Err(EngineError::ScriptInitUnsupported { + clan_index: 0, + event_index: 0, + instruction_index: 1, + source: Handler19ResolveError::UnsupportedSelector { + found: ScriptDispatchSelector::Handler(18), + }, + .. + }) + )); + + let mut malformed_package = package.clone(); + malformed_package.events[0].instructions[0] + .references + .clear(); + let mut malformed_bundle = vec![MissionScriptBundle { + package: malformed_package, + ..bundle() + }]; assert!(matches!( - error, - EngineError::ScriptInit { clan_index: 0, message } - if message.contains("outside the recovered CreateSuperAI base range") + resolve_handler19_init_states( + &anchors, + &mut malformed_bundle, + &varset, + MissionAssetScope::FirstMeshPreview, + ), + Err(EngineError::ScriptInit { message, .. }) + if message.contains("Handler(19) is missing reference 0") )); } #[test] + fn handler_nineteen_init_matches_original_x87_rounding_and_dword_path() { + for (value, expected) in [ + (500.2857, 500), + (751.8217, 752), + (500.5, 500), + (501.5, 502), + (-0.5, 0), + (-0.4, 0), + (-0.6, 0), + (10_000.4, 10_000), + (10_000.5, 10_000), + (10_000.6, 10_001), + (10_001.0, 10_001), + (4_294_967_296.0, 0), + (u32::MAX as f32, 0), + (4_294_967_808.0, 512), + ] { + assert_eq!( + handler19_anchor_word(value, 0, "x").expect("supported anchor"), + expected, + "anchor {value:?}" + ); + } + assert_eq!(u32::MAX as f32, 4_294_967_296.0); + } + + #[test] + fn handler_nineteen_init_rejects_unmodeled_x87_qword_conversions() { + let qword_limit = 9_223_372_036_854_775_808.0_f32; + assert_eq!( + handler19_anchor_word(-qword_limit, 0, "x").expect("valid signed qword minimum"), + 0, + "the signed qword minimum is representable before low-DWORD conversion" + ); + for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, qword_limit] { + let error = + handler19_anchor_word(value, 0, "x").expect_err("unmodeled FISTP qword input"); + assert!(matches!( + error, + EngineError::ScriptInit { clan_index: 0, message } + if message.contains("outside the modeled x87 FISTP qword range") + )); + } + } + + #[test] fn handler_thirty_host_callback_obeys_native_mode_and_status_guards() { fn command(second: u32) -> VmHostCallbackCommand { VmHostCallbackCommand { @@ -1752,6 +1992,111 @@ mod tests { #[test] #[ignore = "requires licensed corpus"] + fn licensed_autodemo_load_resolves_native_handler19_init_for_both_clans() { + let root = licensed_root("IS"); + let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root)); + let mut engine = create(EngineServices::new(vfs)).expect("engine"); + let loaded = load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".to_string(), + mode: MissionMode::AutoDemo, + }, + ) + .expect("load licensed AutoDemo"); + + assert_eq!( + loaded_mission_document(&engine) + .expect("mission document") + .clans + .iter() + .map(|clan| clan.anchor) + .collect::<Vec<_>>(), + vec![[500.2857, 751.8217], [728.0, 449.0]] + ); + assert_eq!(loaded.script_bundle_count, 2); + assert_eq!(loaded.script_init_state_count, 2); + assert_eq!(loaded.script_varset_state_count, 2); + assert!(loaded_mission_script_bundles(&engine) + .expect("script bundles") + .iter() + .all(|bundle| bundle.init_execution + == MissionScriptInitExecution::Executed { event_index: 0 })); + + let varsets = loaded_mission_script_varset_states(&engine).expect("per-clan varsets"); + let clan_zero = varsets + .iter() + .find(|state| state.clan_index == 0) + .expect("first clan varset"); + let clan_one = varsets + .iter() + .find(|state| state.clan_index == 1) + .expect("second clan varset"); + assert_eq!( + &clan_zero.values[224..227], + &[ + VarSetDefault::Dword(500), + VarSetDefault::Dword(752), + VarSetDefault::Dword(0), + ] + ); + assert_eq!( + &clan_one.values[224..227], + &[ + VarSetDefault::Dword(728), + VarSetDefault::Dword(449), + VarSetDefault::Dword(1), + ] + ); + } + + #[test] + #[ignore = "requires licensed corpus"] + fn licensed_campaign_preview_keeps_unsupported_init_without_partial_writes() { + let root = licensed_root("IS"); + let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root)); + let mut engine = create(EngineServices::new(vfs)).expect("engine"); + let loaded = load_mission_static_preview_roots( + &mut engine, + MissionRequest { + key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + NonZeroUsize::new(1).expect("non-zero root count"), + ) + .expect("load licensed campaign preview"); + + let bundles = loaded_mission_script_bundles(&engine).expect("script bundles"); + let unsupported = bundles + .iter() + .find(|bundle| { + matches!( + &bundle.init_execution, + MissionScriptInitExecution::Unsupported { .. } + ) + }) + .expect("campaign package with an unsupported Init instruction"); + let clan_index = unsupported.clan_index; + let states = loaded_mission_script_init_states(&engine).expect("Init writes"); + assert!(states.iter().all(|state| state.clan_index != clan_index)); + let varset = loaded_mission_script_varset_states(&engine) + .expect("per-clan varsets") + .iter() + .find(|state| state.clan_index == clan_index) + .expect("unsupported clan varset"); + assert_eq!( + &varset.values[224..227], + &[ + VarSetDefault::Dword(950), + VarSetDefault::Dword(1000), + VarSetDefault::Dword(0), + ] + ); + assert!(loaded.script_bundle_count >= 1); + } + + #[test] + #[ignore = "requires licensed corpus"] fn loaded_host_callback_uses_requested_mode_and_constructor_status() { let root = licensed_root("IS"); let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root)); |
