diff options
| -rw-r--r-- | crates/fparkan-runtime/src/lib.rs | 808 | ||||
| -rw-r--r-- | crates/fparkan-script/src/lib.rs | 486 | ||||
| -rw-r--r-- | docs/appendices/knowledge-boundaries.md | 139 | ||||
| -rw-r--r-- | docs/appendices/script-vm.md | 97 | ||||
| -rw-r--r-- | docs/tomes/06-behavior.md | 90 |
5 files changed, 1453 insertions, 167 deletions
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 9013019..c962649 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -44,6 +44,7 @@ pub use fparkan_assets::MissionAssets; pub use mesh_collision::{query_building_segment_world, BuildingMeshHit, BuildingMeshNode}; const MISSION_DECODED_PAYLOAD_CACHE_ENTRIES: usize = 256; +const MAX_MISSION_AI_RECORD_SLOTS: usize = 1_000_000; const FALLBACK_SCRIPT_VARSET_PATH: &str = "MISSIONS/SCRIPTS/varset.var"; const INITIAL_BEHAVIOR_RNG_STATE: u32 = 1; const UNIT_DAT_CONTROL_CLASS_HIGH_BIT: u32 = 0x8000_0000; @@ -712,6 +713,15 @@ pub struct MissionScriptBundle { pub behavior_warm_start: Option<BehaviorWarmStartState>, } +/// Initial native record-slot words owned by one mission clan's AI instance. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MissionAiStartupState { + /// TMA clan index that owns this AI instance. + pub clan_index: usize, + /// Sentinel words appended during AI startup, in native insertion order. + pub record_slots: Vec<u32>, +} + /// One decoded research table associated with its TMA clan owner and source path. #[derive(Debug)] pub struct MissionResearchTrfTable { @@ -879,6 +889,7 @@ struct MissionLoadOptions { #[derive(Clone, Debug, Default, Eq, PartialEq)] struct LoadedMissionScripts { bundles: Vec<MissionScriptBundle>, + ai_startup_states: Vec<MissionAiStartupState>, varset: Option<MissionScriptVarSet>, init_states: Vec<MissionScriptInitState>, varset_states: Vec<MissionScriptVarSetState>, @@ -1008,6 +1019,7 @@ struct LoadedMissionState { control_device_tables: Vec<MissionControlDeviceTableState>, behavior_control_groupings: Vec<MissionBehaviorControlGroupingState>, research_trf_tables: Vec<MissionResearchTrfTable>, + ai_startup_states: Vec<MissionAiStartupState>, script_bundles: Vec<MissionScriptBundle>, script_varset: Option<MissionScriptVarSet>, script_init_states: Vec<MissionScriptInitState>, @@ -1079,6 +1091,17 @@ pub enum EngineError { /// Handler-specific diagnostic. message: String, }, + /// A clan's initial AI record slots exceeded the Rust allocation budget or could not be reserved. + MissionAiRecordSlots { + /// Owning TMA clan index. + clan_index: usize, + /// Raw second-resource tag interpreted as unsigned bits. + raw_count: u32, + /// Slots already reserved for earlier clans in this mission. + allocated_slots: usize, + /// Budget or allocation diagnostic. + message: String, + }, /// `NRes` decode error. Nres { /// Resource path. @@ -1258,6 +1281,15 @@ impl std::fmt::Display for EngineError { clan_index, message, } => write!(f, "clan {clan_index}: script runtime failed: {message}"), + Self::MissionAiRecordSlots { + clan_index, + raw_count, + allocated_slots, + message, + } => write!( + f, + "clan {clan_index} AI initial slots (unsigned raw count {raw_count}, after {allocated_slots} mission slots): {message}" + ), Self::Nres { path, source } => write!(f, "{path}: {source}"), Self::ResearchTrf { clan_index, @@ -1318,6 +1350,7 @@ impl std::error::Error for EngineError { | Self::VarSet { .. } | Self::ScriptInit { .. } | Self::ScriptRuntime { .. } + | Self::MissionAiRecordSlots { .. } | Self::Movement(_) | Self::ResearchTrfPath { .. } | Self::PrototypeGraph { .. } @@ -1477,7 +1510,7 @@ fn load_mission_with_options( let research_trf_tables = if options.asset_scope == MissionAssetScope::Full && startup_input.is_some_and(|input| input.research_creation_gate) { - load_mission_research_trf_tables(&vfs, &mission, &loaded_scripts.bundles)? + load_mission_research_trf_tables(&vfs, &mission, &loaded_scripts.ai_startup_states)? } else { Vec::new() }; @@ -1844,6 +1877,7 @@ fn load_mission_with_options( control_device_tables, behavior_control_groupings, research_trf_tables, + ai_startup_states: loaded_scripts.ai_startup_states, script_bundles: loaded_scripts.bundles, script_varset: loaded_scripts.varset, script_init_states: loaded_scripts.init_states, @@ -1925,6 +1959,15 @@ pub fn loaded_mission_script_bundles(engine: &Engine) -> Option<&[MissionScriptB .map(|state| state.script_bundles.as_slice()) } +/// Returns the per-clan AI record slots initialized during full mission startup. +#[must_use] +pub fn loaded_mission_ai_startup_states(engine: &Engine) -> Option<&[MissionAiStartupState]> { + engine + .loaded + .as_ref() + .map(|state| state.ai_startup_states.as_slice()) +} + /// Returns the per-clan TRF tables loaded during full mission startup. #[must_use] pub fn loaded_mission_research_trf_tables(engine: &Engine) -> Option<&[MissionResearchTrfTable]> { @@ -6570,14 +6613,14 @@ fn load_script_formula_table( fn load_mission_research_trf_tables( vfs: &Arc<dyn Vfs>, mission: &MissionDocument, - bundles: &[MissionScriptBundle], + ai_startup_states: &[MissionAiStartupState], ) -> Result<Vec<MissionResearchTrfTable>, EngineError> { - let mut tables = Vec::with_capacity(bundles.len()); - for bundle in bundles { - let Some(clan) = mission.clans.get(bundle.clan_index) else { + let mut tables = Vec::with_capacity(ai_startup_states.len()); + for ai_state in ai_startup_states { + let Some(clan) = mission.clans.get(ai_state.clan_index) else { return Err(EngineError::ScriptRuntime { - clan_index: bundle.clan_index, - message: "script bundle clan index is outside the decoded mission".to_string(), + clan_index: ai_state.clan_index, + message: "AI startup state clan index is outside the decoded mission".to_string(), }); }; let second_resource = match &clan.body { @@ -6599,7 +6642,7 @@ fn load_mission_research_trf_tables( } if !raw_path.is_ascii() { return Err(EngineError::ResearchTrfPath { - clan_index: bundle.clan_index, + clan_index: ai_state.clan_index, path: raw_path.to_vec(), }); } @@ -6608,12 +6651,12 @@ fn load_mission_research_trf_tables( let bytes = read_vfs(vfs, &path)?; let document = ResearchTrfDocument::decode(bytes).map_err(|source| EngineError::ResearchTrf { - clan_index: bundle.clan_index, + clan_index: ai_state.clan_index, path: path.as_str().to_string(), source, })?; tables.push(MissionResearchTrfTable { - clan_index: bundle.clan_index, + clan_index: ai_state.clan_index, path: path.as_str().to_string(), document, }); @@ -6629,6 +6672,54 @@ fn load_mission_scripts( let script_bases = mission_script_bundle_bases(mission); let mut bundles = Vec::with_capacity(script_bases.len()); let mut first_base_path = None; + let mut ai_startup_states = Vec::new(); + if asset_scope == MissionAssetScope::Full { + let mut allocated_slots = 0usize; + for (clan_index, clan) in mission.clans.iter().enumerate() { + let second_resource = match &clan.body { + ClanBody::Standard { + second_resource, .. + } + | ClanBody::Spatial { + second_resource, .. + } => second_resource, + }; + let raw_count = second_resource.tag as u32; + let count = + usize::try_from(raw_count).map_err(|_| EngineError::MissionAiRecordSlots { + clan_index, + raw_count, + allocated_slots, + message: "count does not fit this platform's address space".to_string(), + })?; + let requested_total = allocated_slots.checked_add(count); + if requested_total.is_none_or(|total| total > MAX_MISSION_AI_RECORD_SLOTS) { + return Err(EngineError::MissionAiRecordSlots { + clan_index, + raw_count, + allocated_slots, + message: format!( + "cumulative mission count exceeds the Rust allocation budget of {MAX_MISSION_AI_RECORD_SLOTS} u32 slots" + ), + }); + } + let mut record_slots = Vec::new(); + record_slots.try_reserve_exact(count).map_err(|source| { + EngineError::MissionAiRecordSlots { + clan_index, + raw_count, + allocated_slots, + message: format!("could not reserve {count} u32 slots: {source}"), + } + })?; + record_slots.resize(count, u32::MAX); + allocated_slots = requested_total.expect("checked cumulative AI slot count"); + ai_startup_states.push(MissionAiStartupState { + clan_index, + record_slots, + }); + } + } 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") { @@ -6680,6 +6771,7 @@ fn load_mission_scripts( }; Ok(LoadedMissionScripts { bundles, + ai_startup_states, varset, init_states, varset_states, @@ -6922,7 +7014,6 @@ 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, OverlayVfs, VfsEntry, VfsMetadata}; use std::path::{Path, PathBuf}; @@ -10254,6 +10345,7 @@ mod tests { }; let mut scripts = LoadedMissionScripts { bundles: vec![bundle(0, 18, Vec::new()), bundle(1, 19, vec![0, 1, 2])], + ai_startup_states: Vec::new(), varset: Some(varset), ..LoadedMissionScripts::default() }; @@ -10283,6 +10375,193 @@ mod tests { assert_eq!(rng_state, 0x0029_e2c0); } + fn script_slots_test_mission(clans: &[(bool, bool, i32)]) -> MissionDocument { + fn push_u32(bytes: &mut Vec<u8>, value: u32) { + bytes.extend_from_slice(&value.to_le_bytes()); + } + fn push_lp(bytes: &mut Vec<u8>, value: &[u8]) { + push_u32(bytes, u32::try_from(value.len()).expect("length fits u32")); + bytes.extend_from_slice(value); + } + + let mut tma = Vec::new(); + push_u32(&mut tma, 1); + push_u32(&mut tma, 0); + push_u32(&mut tma, 6); + push_u32( + &mut tma, + u32::try_from(clans.len()).expect("clan count fits u32"), + ); + for (clan_index, (spatial, scripted, tag)) in clans.iter().enumerate() { + push_lp(&mut tma, b"synthetic"); + push_u32(&mut tma, u32::MAX); + tma.extend_from_slice(&(clan_index as f32).to_le_bytes()); + tma.extend_from_slice(&0.0_f32.to_le_bytes()); + let script_path: &[u8] = if *scripted { + b"MISSIONS/SCRIPTS/shared" + } else { + b"" + }; + if *spatial { + push_u32(&mut tma, 0); + push_lp(&mut tma, script_path); + push_u32(&mut tma, 0); + } else { + push_u32(&mut tma, 1); + push_lp(&mut tma, script_path); + push_u32(&mut tma, 0); + } + push_lp(&mut tma, b"unused.trf"); + push_u32(&mut tma, *tag as u32); + push_u32(&mut tma, 0); + } + push_u32(&mut tma, 10); + push_u32(&mut tma, 0); + push_lp(&mut tma, b""); + push_u32(&mut tma, 0); + push_lp(&mut tma, b""); + push_u32(&mut tma, 1); + push_u32(&mut tma, 0); + decode_mission_payload(Arc::from(tma), TmaProfile::Strict).expect("synthetic mission TMA") + } + + fn script_slots_test_tags(mission: &MissionDocument) -> Vec<i32> { + mission + .clans + .iter() + .map(|clan| match &clan.body { + ClanBody::Standard { + second_resource, .. + } + | ClanBody::Spatial { + second_resource, .. + } => second_resource.tag, + }) + .collect() + } + + fn script_slots_test_vfs() -> Arc<dyn Vfs> { + let package = encode_script_package(&ScriptPackage { + opcode_handler_count: 73, + events: Vec::new(), + trailing_bytes: Vec::new(), + raw: Arc::from(&b""[..]), + }); + let mut files = MemoryVfs::default(); + files.insert( + normalize_engine_path("script slots test", "MISSIONS/SCRIPTS/shared.scr") + .expect("script path"), + Arc::from(package.into_boxed_slice()), + ); + files.insert( + normalize_engine_path("script slots test", "MISSIONS/SCRIPTS/shared.var") + .expect("varset path"), + Arc::from(&b"VAR(DWORD, value, 0)\n"[..]), + ); + Arc::new(files) + } + + #[test] + fn full_mission_script_loader_builds_independent_unsigned_ai_slots_per_clan() { + let mission = script_slots_test_mission(&[ + (false, true, 2), + (true, true, 3), + (false, false, 4), + (false, true, 0), + ]); + let raw_tags = script_slots_test_tags(&mission); + let vfs = script_slots_test_vfs(); + + let full = load_mission_scripts(&vfs, &mission, MissionAssetScope::Full) + .expect("Full script bundle load"); + assert_eq!( + full.bundles + .iter() + .map(|bundle| bundle.clan_index) + .collect::<Vec<_>>(), + [0, 1, 3], + "empty-script clans do not produce fake script bundles" + ); + assert_eq!( + full.ai_startup_states + .iter() + .map(|state| state.clan_index) + .collect::<Vec<_>>(), + [0, 1, 2, 3], + "Full startup retains a state for every TMA clan" + ); + assert_eq!( + full.ai_startup_states + .iter() + .map(|state| state.record_slots.clone()) + .collect::<Vec<_>>(), + [ + vec![u32::MAX; 2], + vec![u32::MAX; 3], + vec![u32::MAX; 4], + Vec::new(), + ] + ); + assert_ne!( + full.ai_startup_states[0].record_slots.as_ptr(), + full.ai_startup_states[1].record_slots.as_ptr(), + "each clan AI owns a separate vector" + ); + assert_ne!( + full.ai_startup_states[1].record_slots.as_ptr(), + full.ai_startup_states[2].record_slots.as_ptr(), + "empty-script AI also owns a separate vector" + ); + assert!(full + .bundles + .iter() + .all(|bundle| bundle.init_execution == MissionScriptInitExecution::Missing)); + + let preview = load_mission_scripts(&vfs, &mission, MissionAssetScope::FirstMeshPreview) + .expect("preview script bundle load"); + assert!(preview.ai_startup_states.is_empty()); + assert_eq!( + preview + .bundles + .iter() + .map(|bundle| bundle.clan_index) + .collect::<Vec<_>>(), + [0, 1, 3] + ); + assert_eq!( + script_slots_test_tags(&mission), + raw_tags, + "loading leaves the signed TMA tag bits unchanged" + ); + } + + #[test] + fn full_mission_script_loader_rejects_unsigned_and_cumulative_ai_slot_overflow() { + let vfs = script_slots_test_vfs(); + + let negative_tag = script_slots_test_mission(&[(false, false, -1)]); + assert!(matches!( + load_mission_scripts(&vfs, &negative_tag, MissionAssetScope::Full), + Err(EngineError::MissionAiRecordSlots { + clan_index: 0, + raw_count: u32::MAX, + .. + }) + )); + + let over_budget = + script_slots_test_mission(&[(false, true, 600_000), (true, false, 400_001)]); + assert!(matches!( + load_mission_scripts(&vfs, &over_budget, MissionAssetScope::Full), + Err(EngineError::MissionAiRecordSlots { + clan_index: 1, + raw_count: 400_001, + allocated_slots: 600_000, + .. + }) + )); + } + #[test] fn load_mission_requires_vfs_and_keeps_world_unchanged_on_error() { let mut engine = create(EngineServices::default()).expect("engine"); @@ -13070,12 +13349,14 @@ mod tests { ( "MISSIONS/SCRIPTS/formula.var", Arc::from( - &b"VAR(DWORD, ClanBaseX, 0)\nVAR(DWORD, ClanBaseY, 0)\nVAR(DWORD, ClanID, 0)\nVAR(float, target, 0.5)\n"[..], + &b"VAR(DWORD, ClanBaseX, 0)\nVAR(DWORD, ClanBaseY, 0)\nVAR(DWORD, ClanID, 99)\nVAR(float, target, 0.5)\n"[..], ), ), ( "MISSIONS/SCRIPTS/formula.fml", - Arc::from(&b"// authored literal\n\nFUNCTION( , 0.8, )\nFUNCTION( later, target + 2, )\n"[..]), + Arc::from( + &b"// authored Init expression\n\nFUNCTION( , ClanID + 0.8, )\nFUNCTION( later, target * 2, )\n"[..], + ), ), ] { files.insert( @@ -13112,6 +13393,185 @@ mod tests { } #[test] + #[ignore = "requires licensed corpus"] + fn licensed_fml_variable_rows_resolve_from_shared_varset_defaults() { + fn formula_action(target: u32, row: u32) -> ScriptInstruction { + ScriptInstruction { + header_words: [u32::MAX, target, u32::MAX, u32::MAX, 6, 0, row], + references: Vec::new(), + } + } + + fn init_event_index(package: &ScriptPackage) -> usize { + package + .events + .iter() + .position(|event| { + event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..]) + }) + .expect("Init event") + } + + fn row_is_assigned(instructions: &[ScriptInstruction], row: u32, target: u32) -> bool { + instructions.iter().any(|instruction| { + instruction.header_words[0] == u32::MAX + && instruction.header_words[1] == target + && instruction.header_words[2] == u32::MAX + && instruction.header_words[3] == u32::MAX + && instruction.header_words[6] == row + }) + } + + let root = licensed_root("IS"); + let script_dir = root.join("MISSIONS/SCRIPTS"); + let declarations = fparkan_script::parse_varset( + &std::fs::read(script_dir.join("varset.var")).expect("read shared varset"), + ) + .expect("parse shared varset"); + let formulas3 = FormulaTable::parse( + &std::fs::read(script_dir.join("c1m3e.fml")).expect("read c1m3e.fml"), + ); + let package3 = fparkan_script::decode( + &std::fs::read(script_dir.join("c1m3e.scr")).expect("read c1m3e.scr"), + ) + .expect("decode c1m3e.scr"); + let init3_index = init_event_index(&package3); + let init3_event_word = package3.events[init3_index].event_word; + let init3 = &package3.events[init3_index].instructions; + assert!(row_is_assigned(init3, 0, 40)); + assert!(row_is_assigned(init3, 1, 41)); + assert!(row_is_assigned(init3, 2, 164)); + assert!(!row_is_assigned(init3, 19, 158)); + assert!(!row_is_assigned(init3, 22, 174)); + assert!(row_is_assigned( + &package3 + .events + .iter() + .find(|event| event.name_raw.split(|byte| *byte == 0).next() + == Some(&b"Fort_Captured"[..])) + .expect("Fort_Captured event") + .instructions, + 19, + 158, + )); + assert!(row_is_assigned( + &package3 + .events + .iter() + .find(|event| event.name_raw.split(|byte| *byte == 0).next() + == Some(&b"PBM_BUILDING_PROTECT_Start"[..])) + .expect("building-protection event") + .instructions, + 22, + 174, + )); + assert_eq!(formulas3.rows[19].expression_raw, b" 0.8 + fT"); + assert_eq!( + formulas3.rows[22].expression_raw, + b" dBuildingProtectHits - dT2" + ); + + let f_temp = u32::try_from( + declarations + .declarations + .iter() + .position(|declaration| declaration.name == "fTemp") + .expect("fTemp declaration"), + ) + .expect("fTemp index fits u32"); + let d_temp3 = u32::try_from( + declarations + .declarations + .iter() + .position(|declaration| declaration.name == "dTemp3") + .expect("dTemp3 declaration"), + ) + .expect("dTemp3 index fits u32"); + assert_eq!((f_temp, d_temp3), (158, 174)); + + let mut synthetic3 = package3.clone(); + synthetic3.events[init3_index].instructions = + vec![formula_action(f_temp, 19), formula_action(d_temp3, 22)]; + assert_eq!( + declarations.resolve_init( + &synthetic3, + Handler19InitInput { + first_x87_word: 0, + second_x87_word: 0, + third_word: 0, + }, + Some(&formulas3), + ), + Ok(ScriptInitResolution::Executed { + event_index: init3_index, + event_word: init3_event_word, + writes: vec![ + ScriptInitWrite { + index: f_temp, + value: VarSetDefault::FloatBits(0.8_f32.to_bits()), + }, + ScriptInitWrite { + index: d_temp3, + value: VarSetDefault::Dword(2_000_000), + }, + ], + }) + ); + + let formulas4 = FormulaTable::parse( + &std::fs::read(script_dir.join("c1m4e.fml")).expect("read c1m4e.fml"), + ); + let package4 = fparkan_script::decode( + &std::fs::read(script_dir.join("c1m4e.scr")).expect("read c1m4e.scr"), + ) + .expect("decode c1m4e.scr"); + let init4_index = init_event_index(&package4); + let init4_event_word = package4.events[init4_index].event_word; + let init4 = &package4.events[init4_index].instructions; + assert!(!row_is_assigned(init4, 0, f_temp)); + assert!(row_is_assigned( + &package4 + .events + .iter() + .find(|event| event.name_raw.split(|byte| *byte == 0).next() + == Some(&b"PBM_BUILDING_INF_CAPTURE_Start"[..])) + .expect("building-infantry-capture event") + .instructions, + 0, + f_temp, + )); + assert_eq!(formulas4.rows[0].expression_raw, b" fTemp + 0.00001"); + let mut synthetic4 = package4.clone(); + synthetic4.events[init4_index].instructions = + vec![formula_action(f_temp, 0), formula_action(f_temp, 0)]; + assert_eq!( + declarations.resolve_init( + &synthetic4, + Handler19InitInput { + first_x87_word: 0, + second_x87_word: 0, + third_word: 0, + }, + Some(&formulas4), + ), + Ok(ScriptInitResolution::Executed { + event_index: init4_index, + event_word: init4_event_word, + writes: vec![ + ScriptInitWrite { + index: f_temp, + value: VarSetDefault::FloatBits(0.00001_f32.to_bits()), + }, + ScriptInitWrite { + index: f_temp, + value: VarSetDefault::FloatBits((0.00001_f32 + 0.00001_f32).to_bits()), + }, + ], + }) + ); + } + + #[test] fn script_varset_prefers_bundle_adjacent_file_then_uses_shared_fallback() { let mut personal_files = MemoryVfs::default(); personal_files.insert( @@ -13278,6 +13738,8 @@ mod tests { #[test] fn handler_nineteen_preview_keeps_unsupported_package_without_partial_writes() { + let formula_table = + FormulaTable::parse(b"FUNCTION(add, x + 2, )\nFUNCTION(unsupported, x * 2, )\n"); let varset = MissionScriptVarSet { path: FALLBACK_SCRIPT_VARSET_PATH.to_string(), used_fallback: true, @@ -13298,7 +13760,11 @@ mod tests { references: vec![0, 1, 2], }, fparkan_script::ScriptInstruction { - header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0], + header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 6, 0, 0], + references: Vec::new(), + }, + fparkan_script::ScriptInstruction { + header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 6, 0, 1], references: Vec::new(), }, ], @@ -13311,7 +13777,7 @@ mod tests { base_path: "MISSIONS/SCRIPTS/default".to_string(), script_path: "MISSIONS/SCRIPTS/default.scr".to_string(), package: package.clone(), - formula_table: None, + formula_table: Some(formula_table.clone()), init_execution: MissionScriptInitExecution::Missing, behavior_warm_start: None, }; @@ -13329,9 +13795,10 @@ mod tests { preview_bundles[0].init_execution, MissionScriptInitExecution::Unsupported { event_index: 0, - instruction_index: 1, - error: ScriptInitResolveError::UnsupportedSelector { - found: ScriptDispatchSelector::Handler(18), + instruction_index: 2, + error: ScriptInitResolveError::UnsupportedFormulaExpression { + index: 1, + expression: b" x * 2".to_vec(), }, } ); @@ -13360,12 +13827,13 @@ mod tests { Err(EngineError::ScriptInitUnsupported { clan_index: 0, event_index: 0, - instruction_index: 1, - source: ScriptInitResolveError::UnsupportedSelector { - found: ScriptDispatchSelector::Handler(18), + instruction_index: 2, + source: ScriptInitResolveError::UnsupportedFormulaExpression { + index: 1, + expression, }, .. - }) + }) if expression == b" x * 2" )); let mut malformed_package = package.clone(); @@ -15043,6 +15511,50 @@ mod tests { #[test] #[ignore = "requires licensed corpus"] + fn licensed_single_script_bundle_load_initializes_ai_slots_before_later_init_work() { + let root = licensed_root("IS"); + let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root)); + let path = normalize_engine_path("mission", "MISSIONS/Single.01/data.tma") + .expect("Single.01 path"); + let mission = decode_mission_payload( + read_vfs(&vfs, &path).expect("Single.01 TMA"), + TmaProfile::Strict, + ) + .expect("decode Single.01 TMA"); + assert_eq!(script_slots_test_tags(&mission), [9, 9]); + + let full = load_mission_scripts(&vfs, &mission, MissionAssetScope::Full) + .expect("Full script bundle load"); + assert_eq!( + full.ai_startup_states + .iter() + .map(|state| state.record_slots.len()) + .collect::<Vec<_>>(), + [9, 9] + ); + assert_eq!( + full.ai_startup_states + .iter() + .map(|state| state.clan_index) + .collect::<Vec<_>>(), + [0, 1] + ); + assert!(full + .ai_startup_states + .iter() + .all(|state| { state.record_slots == [u32::MAX; 9] })); + assert!(full + .bundles + .iter() + .all(|bundle| bundle.init_execution == MissionScriptInitExecution::Missing)); + + let preview = load_mission_scripts(&vfs, &mission, MissionAssetScope::FirstMeshPreview) + .expect("preview script bundle load"); + assert!(preview.ai_startup_states.is_empty()); + } + + #[test] + #[ignore = "requires licensed corpus"] fn licensed_single_preview_loads_each_formula_table_and_stops_at_handler43() { let root = licensed_root("IS"); let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root)); @@ -15465,16 +15977,31 @@ mod tests { .iter() .position(|event| event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..])) .expect("Init event"); - assert_eq!( - package.events[init_index].instructions[0].header_words[0], - 19 + let init = &mut package.events[init_index]; + let handler = init + .instructions + .first() + .expect("original Handler(19) instruction"); + assert_eq!(handler.header_words[0], 19); + assert_eq!(handler.references.as_slice(), &[224, 225, 226]); + let original_instruction_count = init.instructions.len(); + init.instructions.push(ScriptInstruction { + header_words: [u32::MAX, 164, u32::MAX, u32::MAX, 6, 0, 0], + references: Vec::new(), + }); + init.instructions.push(ScriptInstruction { + header_words: [u32::MAX, 158, u32::MAX, u32::MAX, 6, 0, 1], + references: Vec::new(), + }); + let replacement_script = Arc::<[u8]>::from(encode_script_package(&package)); + let replacement_formula = Arc::<[u8]>::from( + &b"FUNCTION(add, ClanBaseX + 0.8, )\nFUNCTION(unsupported, fT * 2, )\n"[..], ); - package.events[init_index].instructions[0].header_words[0] = 18; - let replacement = Arc::<[u8]>::from(encode_script_package(&package)); let vfs = Arc::new(SwappingScriptVfs { inner: DirectoryVfs::new(&root), - replacement, + replacement_script, + replacement_formula, enabled: std::sync::atomic::AtomicBool::new(false), }); let services_vfs: Arc<dyn Vfs> = vfs.clone(); @@ -15519,11 +16046,13 @@ mod tests { assert!(matches!( error, EngineError::ScriptInitUnsupported { - source: ScriptInitResolveError::UnsupportedSelector { - found: ScriptDispatchSelector::Handler(18), + instruction_index, + source: ScriptInitResolveError::UnsupportedFormulaExpression { + index: 1, + expression, }, .. - } + } if instruction_index == original_instruction_count + 1 && expression == b" fT * 2" )); assert_eq!(loaded_mission(&engine), Some(&previous_loaded)); assert_eq!( @@ -15917,6 +16446,15 @@ mod tests { research_paths: &[&[u8]], trf: Option<&[u8]>, ) -> Arc<dyn Vfs> { + research_startup_test_vfs_with_raw_tag(script_bases, research_paths, 9, trf) + } + + fn research_startup_test_vfs_with_raw_tag( + script_bases: &[Option<&[u8]>], + research_paths: &[&[u8]], + raw_tag: i32, + trf: Option<&[u8]>, + ) -> Arc<dyn Vfs> { fn push_u32(bytes: &mut Vec<u8>, value: u32) { bytes.extend_from_slice(&value.to_le_bytes()); } @@ -15944,7 +16482,7 @@ mod tests { push_lp(&mut mission, script_base.unwrap_or_default()); push_u32(&mut mission, 0); push_lp(&mut mission, research_path); - push_u32(&mut mission, 9); + push_u32(&mut mission, raw_tag as u32); push_u32(&mut mission, 0); } push_u32(&mut mission, 10); @@ -16001,12 +16539,103 @@ mod tests { } #[test] - fn full_load_keeps_independent_trf_tables_for_each_script_bundle_clan() { + fn ai_record_slot_budget_error_keeps_loaded_mission_clock_and_rng() { + let valid_vfs = research_startup_test_vfs_with_raw_tag( + &[Some(b"MISSIONS/SCRIPTS/shared"), None], + &[b"", b""], + 9, + None, + ); + let mut engine = create(EngineServices::new(valid_vfs)).expect("startup test engine"); + let request = || MissionRequest { + key: "MISSIONS/TEST/data.tma".to_string(), + mode: MissionMode::SingleMission, + }; + load_mission( + &mut engine, + request(), + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, + ) + .expect("initial valid full load"); + let previous_mission = loaded_mission(&engine).expect("loaded mission").clone(); + let previous_bundles = loaded_mission_script_bundles(&engine) + .expect("loaded script bundles") + .to_vec(); + let previous_ai_startup_states = loaded_mission_ai_startup_states(&engine) + .expect("loaded AI startup states") + .to_vec(); + assert_eq!(previous_ai_startup_states.len(), 2); + assert_eq!( + previous_ai_startup_states + .iter() + .map(|state| state.clan_index) + .collect::<Vec<_>>(), + [0, 1] + ); + assert!(previous_ai_startup_states + .iter() + .all(|state| state.record_slots == [u32::MAX; 9])); + assert_eq!(previous_bundles.len(), 1); + assert_eq!(previous_bundles[0].clan_index, 0); + assert_eq!( + previous_bundles[0].behavior_warm_start, + Some(BehaviorWarmStartState { + first_time_ms: 100_000, + next_deadline_ms: 100_202, + cleared_words: [0; 5], + }) + ); + let previous_rng = engine.behavior_rng_state; + assert_ne!(previous_rng, INITIAL_BEHAVIOR_RNG_STATE); + let previous_world = fparkan_world::canonical_state_hash(&engine.world); + + engine.services.vfs = Some(research_startup_test_vfs_with_raw_tag( + &[None, None], + &[b"", b""], + -1, + None, + )); + let error = load_mission( + &mut engine, + request(), + MissionStartupInput { + queue_time_ms: 100_500, + research_creation_gate: false, + }, + ) + .expect_err("negative raw tag is unsigned u32::MAX and exceeds the budget"); + + assert!(matches!( + error, + EngineError::MissionAiRecordSlots { + clan_index: 0, + raw_count: u32::MAX, + .. + } + )); + assert_eq!(loaded_mission(&engine), Some(&previous_mission)); + assert_eq!( + loaded_mission_script_bundles(&engine), + Some(previous_bundles.as_slice()) + ); + assert_eq!( + loaded_mission_ai_startup_states(&engine), + Some(previous_ai_startup_states.as_slice()) + ); + assert_eq!(engine.behavior_rng_state, previous_rng); + assert_eq!( + fparkan_world::canonical_state_hash(&engine.world), + previous_world + ); + } + + #[test] + fn full_load_keeps_independent_trf_tables_for_each_ai_state_clan() { let vfs = research_startup_test_vfs( - &[ - Some(b"MISSIONS/SCRIPTS/shared"), - Some(b"MISSIONS/SCRIPTS/shared"), - ], + &[Some(b"MISSIONS/SCRIPTS/shared"), None], &[ b"DATA/Research/shared.trf\0ignored.trf", b"DATA/Research/shared.trf", @@ -16029,13 +16658,21 @@ mod tests { .expect("full load with explicit research gate"); let tables = loaded_mission_research_trf_tables(&engine).expect("TRF tables"); - assert_eq!(tables.len(), 2, "each loaded script clan owns a table"); + assert_eq!(tables.len(), 2, "each AI state owner gets a table"); + let bundles = loaded_mission_script_bundles(&engine).expect("script bundles"); + assert_eq!(bundles.len(), 1, "only the scripted clan has a bundle"); + assert_eq!(bundles[0].clan_index, 0); + let ai_states = loaded_mission_ai_startup_states(&engine).expect("AI states"); assert_eq!( - loaded_mission_script_bundles(&engine) - .expect("script bundles") - .len(), - 2 + ai_states + .iter() + .map(|state| state.clan_index) + .collect::<Vec<_>>(), + [0, 1] ); + assert!(ai_states + .iter() + .all(|state| state.record_slots == [u32::MAX; 9])); assert_eq!(tables[0].clan_index, 0); assert_eq!(tables[0].path, "DATA/Research/shared.trf"); assert_eq!(tables[1].clan_index, 1); @@ -16053,7 +16690,7 @@ mod tests { } #[test] - fn full_load_skips_research_trf_when_gate_bundle_or_c_string_is_missing() { + fn full_load_skips_research_trf_when_gate_or_c_string_is_missing() { for (script_bases, research_paths, gate, mode) in [ ( vec![Some(&b"MISSIONS/SCRIPTS/shared"[..])], @@ -16073,12 +16710,6 @@ mod tests { true, MissionMode::SingleMission, ), - ( - vec![None], - vec![&b"DATA/Research/missing.trf"[..]], - true, - MissionMode::SingleMission, - ), ] { let vfs = research_startup_test_vfs(&script_bases, &research_paths, None); let mut engine = @@ -16102,10 +16733,36 @@ mod tests { } #[test] - fn research_trf_read_and_decode_errors_keep_the_loaded_world_and_rng() { - let initial_vfs = research_startup_test_vfs( + fn static_preview_skips_research_trf_reads() { + let vfs = research_startup_test_vfs( &[Some(b"MISSIONS/SCRIPTS/shared")], - &[b"DATA/Research/shared.trf"], + &[b"DATA/Research/missing.trf"], + None, + ); + let mut engine = create(EngineServices::new(vfs)).expect("research startup test engine"); + + load_mission_static_preview( + &mut engine, + MissionRequest { + key: "MISSIONS/TEST/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + ) + .expect("preview must not read research TRF paths"); + + assert!(loaded_mission_research_trf_tables(&engine) + .expect("TRF state") + .is_empty()); + assert!(loaded_mission_ai_startup_states(&engine) + .expect("AI state") + .is_empty()); + } + + #[test] + fn no_script_ai_research_trf_read_and_decode_errors_keep_loaded_world_and_rng() { + let initial_vfs = research_startup_test_vfs( + &[Some(b"MISSIONS/SCRIPTS/shared"), None], + &[b"", b"DATA/Research/shared.trf"], Some(&research_test_trf()), ); let mut engine = create(EngineServices::new(initial_vfs)).expect("research startup engine"); @@ -16137,8 +16794,8 @@ mod tests { for trf in [None, Some(&b"malformed"[..])] { let vfs = research_startup_test_vfs( - &[Some(b"MISSIONS/SCRIPTS/shared")], - &[b"DATA/Research/shared.trf"], + &[Some(b"MISSIONS/SCRIPTS/shared"), None], + &[b"", b"DATA/Research/shared.trf"], trf, ); engine.services.vfs = Some(vfs); @@ -16182,7 +16839,7 @@ mod tests { clan_index, path, .. }, ) => { - assert_eq!(clan_index, 0); + assert_eq!(clan_index, 1); assert_eq!(path, "DATA/Research/shared.trf"); } (_, error) => panic!("unexpected TRF failure: {error:?}"), @@ -16193,8 +16850,8 @@ mod tests { #[test] fn research_trf_c_string_paths_reject_non_ascii_bytes() { let vfs = research_startup_test_vfs( - &[Some(b"MISSIONS/SCRIPTS/shared")], - &[b"DATA/Research/\xe9.trf"], + &[Some(b"MISSIONS/SCRIPTS/shared"), None], + &[b"", b"DATA/Research/\xe9.trf"], None, ); let mut engine = create(EngineServices::new(vfs)).expect("research startup test engine"); @@ -16212,14 +16869,14 @@ mod tests { .expect_err("non-ASCII TRF path must fail explicitly"); assert!(matches!( error, - EngineError::ResearchTrfPath { clan_index: 0, path } + EngineError::ResearchTrfPath { clan_index: 1, path } if path == b"DATA/Research/\xe9.trf" )); } #[test] #[ignore = "requires licensed corpus"] - fn licensed_campaign_full_load_creates_trf_tables_for_script_clan_owners() { + fn licensed_campaign_full_load_creates_trf_tables_for_ai_clan_owners() { let root = licensed_root("IS"); let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root)); let mut engine = create(EngineServices::new(vfs)).expect("engine"); @@ -16279,24 +16936,37 @@ mod tests { struct SwappingScriptVfs { inner: DirectoryVfs, - replacement: Arc<[u8]>, + replacement_script: Arc<[u8]>, + replacement_formula: Arc<[u8]>, enabled: std::sync::atomic::AtomicBool, } impl SwappingScriptVfs { - fn use_replacement(&self, path: &NormalizedPath) -> bool { - self.enabled.load(std::sync::atomic::Ordering::SeqCst) - && path - .as_str() - .eq_ignore_ascii_case("MISSIONS/SCRIPTS/default.scr") + fn replacement(&self, path: &NormalizedPath) -> Option<&Arc<[u8]>> { + if !self.enabled.load(std::sync::atomic::Ordering::SeqCst) { + return None; + } + if path + .as_str() + .eq_ignore_ascii_case("MISSIONS/SCRIPTS/default.scr") + { + Some(&self.replacement_script) + } else if path + .as_str() + .eq_ignore_ascii_case("MISSIONS/SCRIPTS/default.fml") + { + Some(&self.replacement_formula) + } else { + None + } } } impl Vfs for SwappingScriptVfs { fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> { - if self.use_replacement(path) { + if let Some(replacement) = self.replacement(path) { return Ok(VfsMetadata { - len: self.replacement.len() as u64, + len: replacement.len() as u64, fingerprint: [0x18; 32], }); } @@ -16304,8 +16974,8 @@ mod tests { } fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> { - if self.use_replacement(path) { - return Ok(self.replacement.clone()); + if let Some(replacement) = self.replacement(path) { + return Ok(replacement.clone()); } self.inner.read(path) } diff --git a/crates/fparkan-script/src/lib.rs b/crates/fparkan-script/src/lib.rs index 7959ce8..20821fe 100644 --- a/crates/fparkan-script/src/lib.rs +++ b/crates/fparkan-script/src/lib.rs @@ -3,7 +3,7 @@ //! //! This module preserves the layout proven by the GOG `ai.dll` reader. It //! deliberately does not implement a general bytecode VM. It contains a -//! narrow resolver for confirmed `Init` handlers and numeric FML literals. +//! narrow resolver for confirmed `Init` handlers and bounded FML expressions. use fparkan_binary::{checked_allocation_len, Cursor, DecodeError, Limits}; use std::sync::Arc; @@ -214,7 +214,7 @@ pub enum ScriptInitResolveError { /// Number of preserved FML rows. rows: usize, }, - /// The referenced FML row is not one numeric token. + /// The referenced FML row is outside the supported atom-expression subset. UnsupportedFormulaExpression { /// Zero-based FML row index. index: u32, @@ -259,7 +259,7 @@ impl std::fmt::Display for ScriptInitResolveError { ), Self::UnsupportedFormulaExpression { index, expression } => write!( formatter, - "formula {index} expression {:?} is not one numeric literal", + "formula {index} expression {:?} is outside the supported atom and binary +/− subset", String::from_utf8_lossy(expression) ), Self::DwordConversionOutOfRange { index, value_bits } => write!( @@ -736,6 +736,7 @@ impl VarSet { /// sentinel selector is accepted only for the proven formula assignment /// with control and copy-source words both equal to `0xffffffff`; the /// referenced row is evaluated only when that instruction is reached. + /// Formula atoms read defaults and earlier successful writes in this event. /// /// # Errors /// @@ -753,10 +754,24 @@ impl VarSet { return Ok(ScriptInitResolution::Missing); }; + let mut values = self + .declarations + .iter() + .map(|declaration| declaration.default_value) + .collect::<Vec<_>>(); let mut writes = Vec::new(); for (instruction_index, instruction) in event.instructions.iter().enumerate() { - match self.resolve_init_instruction(instruction, input, formulas)? { + match self.resolve_init_instruction(instruction, input, formulas, &values)? { InitInstructionResolution::Executed(instruction_writes) => { + for write in &instruction_writes { + let Some(value) = values.get_mut(write.index as usize) else { + return Err(ScriptInitResolveError::VarSetIndexOutOfBounds { + index: write.index, + declarations: self.declarations.len(), + }); + }; + *value = write.value; + } writes.extend(instruction_writes); } InitInstructionResolution::Unsupported(error) => { @@ -781,6 +796,7 @@ impl VarSet { instruction: &ScriptInstruction, input: Handler19InitInput, formulas: Option<&FormulaTable>, + values: &[VarSetDefault], ) -> Result<InitInstructionResolution, ScriptInitResolveError> { match instruction.dispatch_selector() { ScriptDispatchSelector::Handler(19) => { @@ -842,22 +858,23 @@ impl VarSet { )); }; let formula_index = instruction.header_words[6]; - let value = match formulas.evaluate_numeric_literal(formula_index) { - Ok(value) => value, - Err(FormulaLiteralError::IndexOutOfBounds { index, rows }) => { - return Err(ScriptInitResolveError::FormulaIndexOutOfBounds { - index, - rows, - }); - } - Err(FormulaLiteralError::UnsupportedExpression { index, expression }) => { - return Ok(InitInstructionResolution::Unsupported( - ScriptInitResolveError::UnsupportedFormulaExpression { - index, - expression, - }, - )); - } + let Some(formula) = formulas.rows.get(formula_index as usize) else { + return Err(ScriptInitResolveError::FormulaIndexOutOfBounds { + index: formula_index, + rows: formulas.rows.len(), + }); + }; + let Some(value) = parse_init_formula_expression( + &formula.expression_raw, + &self.declarations, + values, + ) else { + return Ok(InitInstructionResolution::Unsupported( + ScriptInitResolveError::UnsupportedFormulaExpression { + index: formula_index, + expression: formula.expression_raw.clone(), + }, + )); }; let target_index = instruction.header_words[1]; let Some(target) = self.declarations.get(target_index as usize) else { @@ -866,6 +883,20 @@ impl VarSet { declarations: self.declarations.len(), }); }; + let Some(current_target) = values.get(target_index as usize) else { + return Err(ScriptInitResolveError::VarSetIndexOutOfBounds { + index: target_index, + declarations: values.len(), + }); + }; + if !varset_value_matches_type(target.type_name, *current_target) { + return Ok(InitInstructionResolution::Unsupported( + ScriptInitResolveError::UnsupportedFormulaExpression { + index: formula_index, + expression: formula.expression_raw.clone(), + }, + )); + } let value = match target.type_name { VarSetType::Float => VarSetDefault::FloatBits(value.to_bits()), VarSetType::Dword => { @@ -1357,6 +1388,113 @@ fn native_decimal_prefix_len(token: &[u8]) -> Option<usize> { Some(index) } +fn parse_init_formula_expression( + expression: &[u8], + declarations: &[VarSetDeclaration], + values: &[VarSetDefault], +) -> Option<f32> { + let (left, left_end) = parse_init_formula_atom(expression, 0, declarations, values)?; + let operator_index = skip_native_formula_space(expression, left_end); + let Some(&operator @ (b'+' | b'-')) = expression.get(operator_index) else { + return expression[left_end..] + .iter() + .all(|byte| *byte == 0 || *byte <= 32) + .then_some(left); + }; + let (right, right_end) = + parse_init_formula_atom(expression, operator_index + 1, declarations, values)?; + if !expression[right_end..] + .iter() + .all(|byte| *byte == 0 || *byte <= 32) + { + return None; + } + Some(match operator { + b'+' => right + left, + b'-' => left - right, + _ => unreachable!(), + }) +} + +fn parse_init_formula_atom( + expression: &[u8], + start: usize, + declarations: &[VarSetDeclaration], + values: &[VarSetDefault], +) -> Option<(f32, usize)> { + let start = skip_native_formula_space(expression, start); + let remaining = expression.get(start..)?; + let first = *remaining.first()?; + let token_len = if first.is_ascii_digit() { + native_numeric_token_len(remaining)? + } else { + native_variable_token_len(remaining)? + }; + let token = &remaining[..token_len]; + if is_native_operator_atom(token) { + return None; + } + + let value = if let Some(index) = declarations + .iter() + .position(|declaration| declaration.name.as_bytes() == token) + { + let declaration = &declarations[index]; + let value = *values.get(index)?; + varset_value_as_f32(declaration.type_name, value)? + } else { + parse_native_numeric_literal(token)? + }; + Some((value, start + token_len)) +} + +fn native_variable_token_len(token: &[u8]) -> Option<usize> { + let first = *token.first()?; + if first == b'^' || !is_native_variable_byte(first) { + return None; + } + Some( + token + .iter() + .take_while(|byte| is_native_variable_byte(**byte)) + .count(), + ) +} + +fn is_native_variable_byte(byte: u8) -> bool { + byte.is_ascii() && ((byte > b'@' && byte < b'{') || byte.is_ascii_digit() || byte == b'_') +} + +fn is_native_operator_atom(token: &[u8]) -> bool { + matches!( + token, + b"+" | b"-" | b"*" | b"/" | b"^" | b"&" | b"|" | b"!" | b"A" | b"B" | b"N" | b"S" + ) +} + +fn skip_native_formula_space(expression: &[u8], start: usize) -> usize { + expression[start..] + .iter() + .position(|byte| *byte == 0 || *byte > 32) + .map_or(expression.len(), |offset| start + offset) +} + +fn varset_value_as_f32(type_name: VarSetType, value: VarSetDefault) -> Option<f32> { + match (type_name, value) { + (VarSetType::Float, VarSetDefault::FloatBits(bits)) => Some(f32::from_bits(bits)), + (VarSetType::Dword, VarSetDefault::Dword(value)) => Some(value as f32), + _ => None, + } +} + +fn varset_value_matches_type(type_name: VarSetType, value: VarSetDefault) -> bool { + matches!( + (type_name, value), + (VarSetType::Float, VarSetDefault::FloatBits(_)) + | (VarSetType::Dword, VarSetDefault::Dword(_)) + ) +} + fn formula_dword_value(value: f32) -> Option<u32> { const SIGNED_QWORD_LIMIT: f64 = 9_223_372_036_854_775_808.0; let value = f64::from(value); @@ -1822,6 +1960,49 @@ mod tests { }; use fparkan_binary::{DecodeError, Limits}; + fn init_package(instructions: Vec<ScriptInstruction>) -> ScriptPackage { + ScriptPackage { + opcode_handler_count: 73, + events: vec![ScriptEvent { + name_len: 4, + name_raw: b"Init\0".to_vec(), + event_word: 0, + instructions, + }], + trailing_bytes: Vec::new(), + raw: std::sync::Arc::from(&b""[..]), + } + } + + fn init_formula_assignment(target: u32, formula: u32) -> ScriptInstruction { + ScriptInstruction { + header_words: [u32::MAX, target, u32::MAX, u32::MAX, 6, 0, formula], + references: Vec::new(), + } + } + + fn init_handler19() -> ScriptInstruction { + ScriptInstruction { + header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX], + references: vec![0, 1, 2], + } + } + + fn init_input() -> Handler19InitInput { + Handler19InitInput { + first_x87_word: 10, + second_x87_word: 20, + third_word: 30, + } + } + + fn one_formula(expression: &[u8]) -> FormulaTable { + let mut source = b"FUNCTION(test, ".to_vec(); + source.extend_from_slice(expression); + source.extend_from_slice(b", )\n"); + FormulaTable::parse(&source) + } + fn handler_two_input( scalar_1: f32, scalar_4: f32, @@ -2625,12 +2806,265 @@ STRING( 8, ignored, ignored, ignored)\r\n"; } #[test] + fn script_init_formula_variables_read_prior_writes_and_self() { + let varset = parse_varset( + b"VAR(DWORD, source, 0)\nVAR(DWORD, y, 0)\nVAR(DWORD, id, 0)\nVAR(float, target, 0)\nVAR(DWORD, integer, 0)\n", + ) + .expect("numeric declarations"); + let formulas = FormulaTable::parse( + b"FUNCTION(literal, 7.0, )\nFUNCTION(add, source + 2.0, )\nFUNCTION(subtract, target - source, )\nFUNCTION(self, target + 1.0, )\nFUNCTION(integer, 10.75 + 0.5, )\nFUNCTION(exponent, target + 1e-2, )\n", + ); + let package = init_package(vec![ + init_handler19(), + init_formula_assignment(3, 0), + init_formula_assignment(3, 1), + init_formula_assignment(3, 2), + init_formula_assignment(3, 3), + init_formula_assignment(4, 4), + init_formula_assignment(3, 5), + ]); + + assert_eq!( + varset.resolve_init(&package, init_input(), Some(&formulas),), + Ok(ScriptInitResolution::Executed { + event_index: 0, + event_word: 0, + writes: vec![ + ScriptInitWrite { + index: 0, + value: VarSetDefault::Dword(10), + }, + ScriptInitWrite { + index: 1, + value: VarSetDefault::Dword(20), + }, + ScriptInitWrite { + index: 2, + value: VarSetDefault::Dword(30), + }, + ScriptInitWrite { + index: 3, + value: VarSetDefault::FloatBits(7.0_f32.to_bits()), + }, + ScriptInitWrite { + index: 3, + value: VarSetDefault::FloatBits(12.0_f32.to_bits()), + }, + ScriptInitWrite { + index: 3, + value: VarSetDefault::FloatBits(2.0_f32.to_bits()), + }, + ScriptInitWrite { + index: 3, + value: VarSetDefault::FloatBits(3.0_f32.to_bits()), + }, + ScriptInitWrite { + index: 4, + value: VarSetDefault::Dword(11), + }, + ScriptInitWrite { + index: 3, + value: VarSetDefault::FloatBits(3.01_f32.to_bits()), + }, + ], + }) + ); + } + + #[test] + fn script_init_formula_atoms_use_native_token_and_name_rules() { + let varset = parse_varset( + b"VAR(float, dup, 1)\nVAR(float, dup, 2)\nVAR(float, Name, 3)\nVAR(float, name, 4)\nVAR(float, 1e-2, 41)\nVAR(float, A, 8)\nVAR(float, A1, 9)\nVAR(DWORD, high, 4294967295)\nVAR(float, large, 16777216)\nVAR(float, target, 0)\nVAR(DWORD, integer, 0)\n", + ) + .expect("numeric declarations"); + let formulas = FormulaTable::parse( + b"FUNCTION(duplicate, dup + 0, )\nFUNCTION(upper, Name + 0, )\nFUNCTION(lower, name + 0, )\nFUNCTION(numeric_name, 1e-2 + 0, )\nFUNCTION(long_operator_prefix, A1 + 0, )\nFUNCTION(unsigned_dword, high + 0, )\nFUNCTION(negative_exponent, 5e-2 + 2e-2, )\nFUNCTION(positive_exponent, 1e+2 + 2e-2, )\nFUNCTION(binary32_rounding, 16777216 + 1, )\nFUNCTION(cancellation, target - 16777215, )\nFUNCTION(dword_target, 10.75 + 0.5, )\nFUNCTION(single_variable, large, )\nFUNCTION(negative_dword_wrap, 2 - 3, )\nFUNCTION(unsigned_dword_wrap, high + 0, )\n", + ); + let package = init_package( + (0..14) + .map(|formula| { + init_formula_assignment( + if matches!(formula, 10 | 12 | 13) { + 10 + } else { + 9 + }, + formula, + ) + }) + .collect(), + ); + + let resolved = varset + .resolve_init( + &package, + Handler19InitInput { + first_x87_word: 0, + second_x87_word: 0, + third_word: 0, + }, + Some(&formulas), + ) + .expect("resolve Init") + .clone(); + assert_eq!( + resolved, + ScriptInitResolution::Executed { + event_index: 0, + event_word: 0, + writes: vec![ + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(1.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(3.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(4.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(41.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(9.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(4_294_967_296.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits((0.05_f32 + 0.02_f32).to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits((100.0_f32 + 0.02_f32).to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(16_777_216.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(1.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 10, + value: VarSetDefault::Dword(11) + }, + ScriptInitWrite { + index: 9, + value: VarSetDefault::FloatBits(16_777_216.0_f32.to_bits()) + }, + ScriptInitWrite { + index: 10, + value: VarSetDefault::Dword(u32::MAX) + }, + ScriptInitWrite { + index: 10, + value: VarSetDefault::Dword(0) + }, + ], + } + ); + assert!(formulas.evaluate_numeric_literal(0).is_err()); + } + + #[test] + fn script_init_rejects_unsupported_formula_variable_forms() { + let varset = parse_varset( + b"VAR(float, target, 1)\nVAR(float, A, 2)\nVAR(float, A1, 3)\nVAR(float, ^name, 4)\n", + ) + .expect("numeric declarations"); + for expression in [ + &b"target * 2"[..], + b"-1", + b"target + 1 + 2", + b"missing + 1", + b"A + 1", + b"^name + 1", + b"name\xc3\xa9 + 1", + ] { + let formulas = one_formula(expression); + let package = init_package(vec![init_formula_assignment(0, 0)]); + assert!( + matches!( + varset.resolve_init( + &package, + Handler19InitInput { + first_x87_word: 0, + second_x87_word: 0, + third_word: 0, + }, + Some(&formulas), + ), + Ok(ScriptInitResolution::Unsupported { + error: ScriptInitResolveError::UnsupportedFormulaExpression { + index: 0, + .. + }, + .. + }) + ), + "accepted unsupported formula {:?}", + String::from_utf8_lossy(expression) + ); + } + + let mut inconsistent = varset; + inconsistent.declarations[0].default_value = VarSetDefault::Dword(1); + let formulas = one_formula(b"target + 1"); + let package = init_package(vec![init_formula_assignment(0, 0)]); + assert!(matches!( + inconsistent.resolve_init( + &package, + Handler19InitInput { + first_x87_word: 0, + second_x87_word: 0, + third_word: 0, + }, + Some(&formulas), + ), + Ok(ScriptInitResolution::Unsupported { + error: ScriptInitResolveError::UnsupportedFormulaExpression { index: 0, .. }, + .. + }) + )); + + let mut inconsistent_target = + parse_varset(b"VAR(float, target, 1)\n").expect("numeric declaration"); + inconsistent_target.declarations[0].default_value = VarSetDefault::Dword(1); + let formulas = one_formula(b"2.0 + 1"); + assert!(matches!( + inconsistent_target.resolve_init( + &init_package(vec![init_formula_assignment(0, 0)]), + Handler19InitInput { + first_x87_word: 0, + second_x87_word: 0, + third_word: 0, + }, + Some(&formulas), + ), + Ok(ScriptInitResolution::Unsupported { + error: ScriptInitResolveError::UnsupportedFormulaExpression { index: 0, .. }, + .. + }) + )); + } + + #[test] fn script_init_keeps_unsupported_formula_expressions_lazy_and_transactional() { - let varset = parse_varset(b"VAR(DWORD, integer, 0)\n").expect("numeric declaration"); + let varset = parse_varset(b"VAR(DWORD, target, 0)\nVAR(DWORD, integer, 0)\n") + .expect("numeric declaration"); let formulas = - FormulaTable::parse(b"FUNCTION(unsupported, target + 2, )\nFUNCTION(literal, 7.5, )\n"); + FormulaTable::parse(b"FUNCTION(unsupported, target * 2, )\nFUNCTION(literal, 7.5, )\n"); let action = |formula_index| ScriptInstruction { - header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 6, 0, formula_index], + header_words: [u32::MAX, 1, u32::MAX, u32::MAX, 6, 0, formula_index], references: Vec::new(), }; let package = |formula_index| ScriptPackage { @@ -2656,7 +3090,7 @@ STRING( 8, ignored, ignored, ignored)\r\n"; event_index: 0, event_word: 0, writes: vec![ScriptInitWrite { - index: 0, + index: 1, value: VarSetDefault::Dword(7), }], }) @@ -2668,7 +3102,7 @@ STRING( 8, ignored, ignored, ignored)\r\n"; instruction_index: 0, error: ScriptInitResolveError::UnsupportedFormulaExpression { index: 0, - expression: b" target + 2".to_vec(), + expression: b" target * 2".to_vec(), }, }) ); @@ -2682,7 +3116,7 @@ STRING( 8, ignored, ignored, ignored)\r\n"; instruction_index: 1, error: ScriptInitResolveError::UnsupportedFormulaExpression { index: 0, - expression: b" target + 2".to_vec(), + expression: b" target * 2".to_vec(), }, }) ); diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index b313375..7fb6798 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -133,7 +133,7 @@ control word процесса. Лицензированный AutoDemo по-прежнему загружается с raw TMA anchor-ами `[(500.2857, 751.8217), (728.0, 449.0)]` и записями `[(500, 752, 0), (728, 449, 1)]`. Rust теперь также читает optional `.fml` sibling для каждого -script base и поддерживает numeric literal assignments в первом `Init`. +script base и поддерживает узкий набор атомарных формул в первом `Init`. Отсутствующий `.fml` не мешает пакету с одними `Handler(19)`; другие ошибки чтения распространяются как VFS errors. @@ -144,9 +144,11 @@ script base и поддерживает numeric literal assignments в первÐ два доказанных действия: `Handler(19)` и formula assignment selector `-1` при source `-1`, нулевой глубине условий и корректных индексах. У `Handler(19)` control находится в `header_words[3]`, а `header_words[1]` — result-save -reference; оба равны `0xffffffff` в поддержанной форме. У formula assignment -`header_words[1]` — индекс целевой varset ячейки, `header_words[2]` — source, -а индекс formula row занимает `header_words[6]`. Маппинг подтверждён +reference; оба равны `0xffffffff` в поддержанной форме. В дисковом заголовке +formula assignment `header_words[1]` — индекс целевой varset ячейки, +`header_words[2]` — source, а индекс formula row занимает `header_words[6]`; +индексы цели и формулы в native record находятся по смещениям `+0x08` и `+0x1c` +соответственно. Маппинг подтверждён `ai.dll!FUN_10011b20` (RVA `0x11b20`) и исполнителем `FUN_10012020` (RVA `0x12020`), SHA-256 `c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`. `Handler(19)` (`FUN_1000aa38`, RVA `0xaa38`) использует references `0..2`; @@ -154,23 +156,58 @@ reference; оба равны `0xffffffff` в поддержанной форме Formula table загружается из `<bundle-base>.fml`; native `FUN_100123b0` (RVA `0x123b0`) проверяет ordinal, compiler `FUN_100152a0` (RVA `0x152a0`) -строит numeric literal node, а evaluator `FUN_10015b30` (RVA `0x15b30`) -возвращает его raw `f32`. Target type `3` сохраняет эти bits; type `5` проходит -через `FUN_10012c00` (RVA `0x12c00`) и `__ftol` (RVA `0x1df70`) к raw DWORD -с усечением к нулю. Изолированный x86 oracle на оригинальном `ai.dll` и -настоящих ресурсах подтвердил таблицы `tut1_pl2`/`scr_pl_1`/`scream` с +строит числовые, переменные и операторные узлы, а evaluator `FUN_10015b30` +(RVA `0x15b30`) в проверенных путях `+` и `-` считывает значения переменных и +сохраняет результаты как `f32` при x87 control word `0x007f`. В GOG-сборке +`ai.dll` с SHA-256 +`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` поиск +переменной через varset vtable slot `+0x10` ведёт на RVA `0x16ad0`: сравнение +байтов точное, регистр учитывается, при повторах берётся первое объявление. +Getter `FUN_10013190` (RVA `0x13190`) читает type `3` как `f32`, type `5` как +беззнаковый `DWORD` и возвращает число через x87. Затем evaluator сохраняет +его как `f32` до операции. Оператор `+` +считает right+left, `-` — left-right; результат также сохраняется как `f32`. +Target type `3` сохраняет эти bits; type `5` проходит через `FUN_10012c00` +(RVA `0x12c00`) и `__ftol` (RVA `0x1df70`) к raw DWORD с усечением к нулю. +Изолированный x86 oracle на оригинальном `ai.dll` и настоящих ресурсах +подтвердил таблицы `tut1_pl2`/`scr_pl_1`/`scream` с 26/5/149 строками и 15 присваиваний: кампанийные `df0..df3` и `Single.01` `dT8/dT9` получают `0`; `scream` задаёт `fDifficulty = 0.8f` и остальные проверенные DWORD `0, 0, 1, 10, 0, 100, 400, 2`. -Граница Rust остаётся узкой: ссылка должна вести на один digit-starting numeric -token и типизированную `float`/`DWORD` ячейку. Неизвестные expression nodes, -переменные, unary operators, составные выражения и source-copy ветвь -`source != 0xffffffff` остаются явными ошибками; native source-copy переносит -всю varset record, не только числовое значение. В preview package сохраняется, -но `Unsupported` не применяет частичные writes; полная загрузка отклоняет весь -новый мир и сохраняет предыдущие world/RNG. Это политика Rust, не правило -оригинального dispatcher-а. +Граница Rust остаётся узкой: один числовой токен либо ASCII-имя переменной, +или два таких атома с одним бинарным `+`/`-`. Имя проверяется раньше числового +преобразования, сравнивается с учётом регистра и выбирает первое совпадение; +публичный `FormulaTable::evaluate_numeric_literal` принимает только числовые +литералы. Временная копия значений начинается со значений по умолчанию и +обновляется после каждого успешного действия `Init`, поэтому следующая формула +видит предыдущие записи. Нет +поддержки неизвестных операторов, унарных форм, третьего атома, отсутствующих +или не-ASCII имён и source-copy ветви `source != 0xffffffff`; native source-copy +переносит всю запись varset. Preview сохраняет package, но не применяет частичные +writes; полный load отклоняет новый мир и сохраняет прежние world/RNG. Это +политика ограниченного Rust resolver-а, а не правило оригинального dispatcher-а. + +Ресурсные строки с переменными найдены в поздних событиях: `c1m3e.fml` row 19 +(`0.8 + fT`) вызывается из `Fort_Captured`, row 22 +(`dBuildingProtectHits - dT2`) — из `PBM_BUILDING_PROTECT_Start`, а `c1m4e.fml` +row 0 (`fTemp + 0.00001`) — из `PBM_BUILDING_INF_CAPTURE_Start`. Декодирование +этих `.scr` не показывает эти формулы в первом `Init`. Игнорируемая проверка +читает настоящие `.fml` и `varset.var`, но вставляет строки в синтетический +`Init`; она проверяет resolver, а не оригинальное исполнение. Одноразовый x86 +oracle на исходной GOG `ai.dll` с SHA-256 +`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` проверил +13 отдельных присваиваний: 9 контролируемых выражений и 4 вызова с настоящими +строками `.fml` (`c1m3e` rows 19/22, `c1m4e` row 0 дважды подряд). В каждом векторе +native harness записывал вычисленный `f32`, типизированное raw-слово и getter; +scratch-проход через публичный Rust parser/resolver совпал со всеми 13 +векторами по `f32`-результату и raw-записи. Для native вызовов control word +временно принудительно устанавливался в `0x007f`: 87 наблюдений сохранили +`0x027f` на входе и после восстановления, +а canary-проверки прошли. Менялись только varset-копии в памяти. Это +подтверждает компиляцию формулы, локальное вычисление и typed writes для этих +входов, но не запуск исходного поиска и обхода события `Init`, исполнение поздних +событий или поведение живой миссии. Лицензированный test загружает одну первую кампанийную миссию `CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma` после literal writes; это не проход @@ -184,6 +221,34 @@ core и `ResearchCandidateSet::refresh_selection`: последний получ создаёт владельца живого контекста, не передаёт реальные AI candidates и не выбирает результат Handler(43). Это не общий dispatcher и не загрузка игры. +Начальный массив слота AI `+0x38` теперь восстановлен отдельно от этого +исследовательского пути. В `iron3d.dll` SHA-256 +`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa` +`FUN_100392e0` (RVA `0x392e0`) вызывает слот для каждого значения счётчика +`second_resource.tag`. В `ai.dll` SHA-256 +`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` метод +RVA `0x2010` передаёт `AI+0x83c` в `FUN_10002980` (RVA `0x2980`), где +`FUN_100048f0` (RVA `0x48f0`) добавляет переданный DWORD. Вызывающая сторона +повторно передаёт `0xffffffff`; эта функция не записывает значение обратно. +Поэтому подтверждён только начальный массив sentinel-слов. Неизвестно, какие +поздние обработчики читают эти ячейки и заменяет ли кто-либо их ID. В начальном +массиве нет назначенных ID объектов; дальнейший смысл этих слов пока не +установлен. Сам массив не является списком +`BehaviorDistributorState.registered_objects` и не определяет владельца +контекста исследований. + +Rust хранит этот снимок как `MissionAiStartupState` в состоянии загруженной +миссии. Полная загрузка проходит по всем TMA-кланам, включая кланы без пакета +сценария и без пути таблицы исследований; при предпросмотре список состояний AI +остаётся пустым. Знаковое поле `second_resource.tag` читается по сырым битам как +`u32`. Бюджет в 1 000 000 DWORD на миссию и выделение памяти с обработкой ошибки +защищают только память Rust; это ограничение не приписывается оригинальному +движку. Искусственные Rust-тесты проверяют обе формы TMA-клана, индекс клана, +независимые массивы, пустой путь сценария, предпросмотр, чтение тега как +беззнакового числа, лимит и откат при ошибке. Проверка на оригинальных ресурсах +для `Single.01` подтверждает состояния обоих кланов (`9/9`) до выполнения +`Init`; это не полный запуск миссии и не исполнение исходных DLL. + Для GOG-сборки также найден статический host call path в `Init`. В `iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) одна ветвь сначала проходит записи кланов и вызывает для них `FUN_100392e0`, затем вызывает `FUN_10039440` (RVA @@ -877,22 +942,34 @@ arg1 следующего вызова. `AddNewObjectToGame` принимает `FUN_10002a30` (RVA `0x2a30`, `RET 8`) записывает его в `inner+8`. TMA tag `row+0x20` ограничивает число вызовов AI slot `+0x38`, но не задаёт владельца. +Создание AI не фильтруется по наличию сценарного пути. В `iron3d.dll` SHA-256 +`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa` +`FUN_1005ff40` (RVA `0x5ff40`) проходит записи кланов и вызывает для каждой +`FUN_100392e0` (RVA `0x392e0`); в `ai.dll` SHA-256 +`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` +`CreateSuperAI` (RVA `0xf710`) выделяет AI и вызывает конструктор по RVA +`0x1000` без проверки пути сценария. Создание исследовательского контекста +следует за успешным созданием AI и требует включённого флага создания +исследований и непустого `second_resource.path` до первого NUL. Переданный в +`descriptor[0]` индекс клана, а не `second_resource.tag`, задаёт владельца; +тег остаётся счётчиком начальных вызовов AI slot `+0x38`. + В Rust эта граница представлена только загрузкой таблиц. Полный `load_mission` требует явный `MissionStartupInput { queue_time_ms, -research_creation_gate }`; gate не выводится из `MissionMode` или имени пути. -При `true` загрузчик проходит по успешно разобранным пакетам сценариев, берёт -реальный `second_resource.path` соответствующего TMA-клана до первого NUL, -отклоняет не-ASCII путь, затем сохраняет отдельный `ResearchTrfDocument` с -`clan_index` и нормализованным путём. Пустая строка, выключенный gate или -отсутствие пакета сценариев пропускают чтение. Повтор одного пути разными -владельцами приводит к отдельным буферам строк TRF0. Обычные тесты на -искусственных данных проверяют владельцев, повторное декодирование, пропуски, -ошибки и откат; игнорируемая проверка оригинальных ресурсов связывает реальные -`Campaign.00/Mission.01` пары путей с индексами владельцев. Это не загружает -полный исследовательский контекст оригинала. -Успешно разобранный пакет сценариев здесь обозначает только границу текущей -настройки Rust, а не совпадение с полным нативным жизненным циклом -`CreateSuperAI`. +research_creation_gate }`; этот флаг не выводится из `MissionMode` или имени +пути. Если флаг равен `true`, загрузчик проходит по существующим +`MissionAiStartupState` всех TMA-кланов, в том числе без сценарного пакета, +читает их `second_resource.path` до первого NUL и отклоняет не-ASCII путь. +Пустая строка пропускается; выключенный флаг и статический preview не читают +TRF. На каждого +владельца сохраняется отдельный `ResearchTrfDocument` с нормализованным путём, +даже когда разные кланы указывают на один файл. Обычные тесты на искусственных +данных проверяют владение у кланов со сценариями и без них, повторное +декодирование, пропуски, ошибки и откат; игнорируемая проверка оригинальных +ресурсов связывает реальные `Campaign.00/Mission.01` пары путей с индексами владельцев. +Rust пока сохраняет только декодированные данные и связь с владельцем: он не +создаёт полный исследовательский контекст, не строит AI-каталог и не исполняет +`Handler(43)`. Нативный metadata loader нельзя считать простым копированием таблицы. В GOG `Behavior.dll` (SHA-256 diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index 6e48445..51dd429 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -22,8 +22,8 @@ symbol/formula oracle; `varset.var` задаёт `VAR(...)`/`STRING(...)` defaul `fparkan-script::parse_varset` уже читает подтверждённые numeric `VAR(float|DWORD, name, default)` declarations byte-safe (comments остаются opaque, поэтому legacy non-UTF-8 text не ломает загрузку); `STRING(...)` и -general `FUNCTION(...)` evaluation остаётся вне numeric contract, но принятые -строки сохраняются для доказанных literal assignments первого `Init`; +general `FUNCTION(...)` evaluation остаются вне восстановленного контракта, +но принятые строки сохраняются для ограниченного поднабора формул первого `Init`; GOG `MISSIONS/SCRIPTS/varset.var` даёт через него ровно 231 declaration: 31 `float` и 200 `DWORD` (от `f0` до `fY`); loader `ai.dll!0x10001000` сначала открывает `<bundle-base>.var` и только при @@ -424,10 +424,13 @@ nearest-even `FISTP qword`, младший DWORD, преобразование D Ограниченный Rust-исполнитель `VarSet::resolve_init` ищет первое событие `Init` и проходит его инструкции по порядку. Он возвращает записи для -поддержанного `Handler(19)` и числовых literal assignments; прочая форма -останавливает preview на `Unsupported`. Runtime применяет упорядоченные записи -к отдельной копии varset выбранного клана лишь после успешного прохода всего -события. Отсутствующий `Init` даёт +поддержанного `Handler(19)` и формул с одним числовым или переменным атомом либо +с двумя такими атомами, соединёнными одним бинарным `+` или `-`; прочая форма +останавливает preview на `Unsupported`. Runtime начинает вычисление с копии +defaults и записывает в неё каждый успешно разрешённый шаг, поэтому поздняя +формула видит прежние записи этого события. Упорядоченные записи попадают в +состояние клана только после успешного прохода всего `Init`. Отсутствующий +`Init` даёт статус `Missing` и не создаёт записей инициализации; пустой первый `Init` даёт `Executed` без записей. Если форма инструкции пока не поддержана, package остаётся доступен в preview режиме со статусом `Unsupported`, а default-значения @@ -437,7 +440,7 @@ nearest-even `FISTP qword`, младший DWORD, преобразование D Это правила ограниченного Rust-исполнителя, а не обнаруженное поведение оригинального dispatcher-а. -#### Числовые литералы `.fml` в `Init` +#### Литералы и простые формулы `.fml` в `Init` В той же сборке `ai.dll` (`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`) загрузчик `FUN_10001000` открывает `.fml` рядом с базой каждого `.scr`. @@ -455,16 +458,35 @@ nearest-even `FISTP qword`, младший DWORD, преобразование D остаётся ошибкой VFS. Native selector `-1` при source `-1` и нулевой глубине условий выбирает -formula assignment; в native record `header_words[1]` — индекс целевой varset -ячейки, а `header_words[6]` — индекс formula row. `FUN_100123b0` (RVA `0x123b0`) проверяет индекс строки, -`FUN_10015b30` (RVA `0x15b30`) возвращает результат единственного literal node -kind `3`, а `FUN_10013650` передаёт его типизированной ячейке через -`FUN_10012c00` (RVA `0x12c00`). Компилятор `FUN_100152a0` получает числовой -prefix как `double` через `FUN_10020af0` (RVA `0x20af0`), затем записывает -его как `float` в node `+0x10`. Rust ограничивает эту ветвь одним -digit-starting числовым токеном с необязательным хвостом пробельных байтов; -ведущие unary `+`/`-`, variable references, операторы и другие node kinds -остаются явными `UnsupportedExpression`. +formula assignment. В дисковом заголовке `.scr` `header_words[1]` — индекс +целевой varset-ячейки, а `header_words[6]` — индекс formula row; соответствующие +поля native record находятся по смещениям `+0x08` и `+0x1c` соответственно. +`FUN_100123b0` (RVA `0x123b0`) проверяет индекс строки. Компилятор +`FUN_100152a0` (RVA `0x152a0`) создаёт +числовые узлы, узлы переменных и узлы операторов; числовой prefix проходит через +`FUN_10020af0` (RVA `0x20af0`) как `double`, после чего сохраняется в узле как +`float`. Для переменной компилятор вызывает vtable slot `+0x10` таблицы varset; +в проверенной GOG-сборке `ai.dll` этот вызов ведёт к поиску по RVA `0x16ad0`. +Поиск сравнивает байты имени с учётом регистра и возвращает первое совпадение. +`FUN_10015b30` (RVA `0x15b30`) читает числовой узел как `f32`, а переменную — +через `FUN_10013190` (RVA `0x13190`): type `3` даёт `float`, type `5` — +беззнаковый `DWORD`. Getter возвращает число через x87; evaluator сохраняет +его как `f32` перед операцией. Оператор `+` складывает правый +и левый операнды, а `-` вычисляет левый минус правый; после узла результат +сохраняется как `f32`. `FUN_10013650` передаёт итог типизированной ячейке через +`FUN_10012c00` (RVA `0x12c00`). + +Rust в первом `Init` принимает один числовой токен или имя ASCII-переменной, +а также ровно два таких атома с одним бинарным `+` либо `-`. Например, +`source + 2.0` и `target - source` читают значения из defaults или из предыдущих +успешных записей события. Имя ищется раньше числового преобразования, поэтому +объявление с именем `1e-2` перекрывает такую числовую запись; сравнение имён +чувствительно к регистру, а повтор объявлений выбирает первое. `DWORD` читается +как беззнаковое число и округляется до `f32` до операции. Публичный +`FormulaTable::evaluate_numeric_literal` по-прежнему принимает только литералы. +Ведущие unary `+`/`-`, `*`, другие операторы, третий атом, отсутствующие или +не-ASCII имена и зарезервированные одиночные операторы остаются `Unsupported`; +так же не поддержана ветвь копирования `source != 0xffffffff`. Проверенный native tokenizer отличается от обычного строгого decimal parser: `1e-2`, `1.`, `1e`, `1e+`, `1ee2` и `1e2.3` были приняты как один literal node @@ -477,8 +499,8 @@ digit-starting числовым токеном с необязательным Ñ исходные `f32` bits; `DWORD` (tag `5`) проходит через `__ftol` (RVA `0x1df70`) с усечением к нулю и записывает младший DWORD. Поэтому literal `0.8` остаётся `0x3f4ccccd` в `float`, но записывается как `0` в `DWORD`; `100.0` даёт raw -DWORD `100`. Неизвестные выражения не заменяются нулём, а запись всего первого -`Init` применяется только после успешного прохода. +DWORD `100`. Неизвестные выражения не заменяются нулём, а записи первого `Init` +публикуются только после успешного прохода. Native x86 oracle на GOG `ai.dll` SHA-256 `c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` загрузил @@ -500,6 +522,27 @@ Native x86 oracle на GOG `ai.dll` SHA-256 | 7 | `dMaxPlant` (`DWORD`) | `400 → 400` | | 8 | `d7` (`DWORD`) | `2 → 2` | +Отдельный одноразовый x86 oracle на той же GOG `ai.dll` проверил ещё 13 +выражений: 9 строк контролируемой `controlled.fml`, строки 19 и 22 настоящей +`c1m3e.fml` и два последовательных вызова строки 0 настоящей `c1m4e.fml`. +Native harness записывал `f32`-результат evaluator-а, типизированную raw-запись +и getter после записи. В scratch-проверке те же входы прошли через публичные +`parse_varset`, `FormulaTable::parse` и `VarSet::resolve_init`; результат +evaluator-а и raw-слово Rust совпали с native во всех 13 случаях. Среди +проверенных границ: `2 - 3` записал в `DWORD` `0xffffffff`; чтение исходного +`DWORD 0xffffffff` дало `f32 0x4f800000`, а последующее `+ 0` записало в +`DWORD` ноль. Для настоящих строк `c1m3e` получены `fTemp = 0x40333333` и +`dTemp3 = 0`; два последовательных вызова строки 0 `c1m4e` дали +`fTemp = 0x4033335d`, затем `0x40333387`. + +Oracle принудительно устанавливал x87 control word `0x007f` на время каждого +проверяемого вызова: все 87 наблюдений показали входное и восстановленное +значение `0x027f`, активное и выходное — `0x007f`; canary-проверки прошли. +Менялись только varset-копии в памяти процесса. Эта выборка подтверждает +компиляцию, вычисление и типизированные записи ровно для перечисленных входов. +Она не запускала исходный поиск и обход события `Init` и не исполняла поздние +события или миссию; выборка не доказывает полную грамматику evaluator-а. + Rust сохраняет всю таблицу строк, но вычисляет только формулу, на которую ссылается поддержанная запись. Лицензированный runtime-тест подтверждает полную загрузку кампании Mission.01 после этих присваиваний; у обычной @@ -511,6 +554,17 @@ Rust сохраняет всю таблицу строк, но вычисляеÑ не перечисляют AI candidates и не выполняют этот handler. Это не подтверждение запуска самой игры или полной VM. +В других строках тех же ресурсов встречаются `0.8 + fT` (`c1m3e.fml`, row 19), +`dBuildingProtectHits - dT2` (row 22) и `fTemp + 0.00001` (`c1m4e.fml`, row 0). +Декодированные `c1m3e.scr` и `c1m4e.scr` ссылаются на них из более поздних +событий `Fort_Captured`, `PBM_BUILDING_PROTECT_Start` и +`PBM_BUILDING_INF_CAPTURE_Start`, а не из их `Init`. Игнорируемый Rust-тест +подаёт эти сохранённые строки в изменённый синтетический `Init` и использует +настоящий общий `varset.var`; это проверка resolver-а, а не утверждение о +исполнении таких выражений в исходном `Init`. Отдельный native oracle и +сравнение с Rust выше проверяют эти строки при явно заданных исходных ячейках, +но не подтверждают их исполнение в оригинальном сценарии или в живой игре. + ### Первый вызов Behavior и сценарного `Init` В той же GOG-сборке `Behavior.dll` (SHA-256 @@ -760,8 +814,9 @@ tie-векторов подтверждает реакцию `FISTP`, но не два слова и третье исходное слово, выдаёт три типизированные записи и отклоняет отсутствующие, выходящие за диапазон или не-`DWORD` цели. Общий для первого события метод `VarSet::resolve_init` добавляет этот `Handler(19)` и формулу из -одного numeric literal; остальные формы инструкции и содержимое `.scr` -сохраняются reader-ом, но остаются вне этого исполнения. +одного числового или переменного атома либо двух таких атомов с одним бинарным +`+` или `-`. Остальные формы инструкции и содержимое `.scr` сохраняются +reader-ом, но остаются вне этого исполнения. ### Начальная группировка подготовленных Control-дескрипторов diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index 6684b13..3a4e51c 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -169,8 +169,10 @@ handler лишь инициирует execution context (`+0x50 = 1`); его ga показывает предупреждение для чисел выше `10000`, но не отменяет создание AI. Rust исполняет подтверждённый поднабор первого `Init`: действие `Handler(19)` -и числовые literal assignments, описанные ниже. Поддержанные записи применяются -к состоянию лишь после успешного прохода всего первого `Init`. +и формулы из одного атома либо двух атомов с бинарным `+` или `-`. Здесь атом — +число или имя переменной. Поддержанные шаги сразу обновляют временную копию +varset, поэтому следующая формула читает уже записанные значения. К состоянию +клана записи попадают лишь после успешного прохода всего первого `Init`. Если встретилась неподдержанная форма, preview сохраняет пакет, сообщает `Unsupported` и оставляет значения по умолчанию; при полной загрузке та же форма становится ошибкой. Это политика Rust для ограниченного исполнителя, а не правило @@ -186,15 +188,54 @@ Rust исполняет подтверждённый поднабор перво нули в `dT8` и `dT9`. Клан 1 той же миссии использует первые девять строк `scream.fml`: восемь проверенных значений попадают в `DWORD` с усечением дробной части, а `0.8` -сохраняется как число `float`. Пока Rust вычисляет только числовой литерал, -начинающийся с цифры; операторы, ссылки на переменные и другие формы формул -остаются явными ошибками. В тесте полной загрузки миссия `Mission.01` -проходит этот `Init`; `Single.01` останавливается на следующем неподдержанном -`Handler(43)` в clan 1, записи 10. Никакое значение не подставляется вместо -этого действия. +сохраняется как число `float`. Например, если `source` равен `10`, выражение +`source + 2.0` записывает `12`; следующее `target - source` читает новое значение +`target` и получает `2`. Каждая операция работает с `f32`: значение ячейки +`DWORD` сначала читается как беззнаковое число и переводится в `f32`, а результат +сохраняет тип целевой ячейки. Поддерживаются только один или два атома и один +бинарный `+` либо `-`; произвольная грамматика формул и остальная VM не +восстановлены. В отдельной точечной проверке вычисления и записи оригинальной +DLL и Rust совпали для 13 выбранных формул. Эта сверка проверяет только +эти значения и не запускает исходную миссию. Тест полной загрузки подтверждает, +что `Mission.01` проходит этот `Init`; у `Single.01` следующий неподдержанный +`Handler(43)` встречается в clan 1, записи 10. Никакое значение не подставляется +вместо этого действия. ### Первый запуск сценария +Перед настройкой первого шага каждый AI получает начальный массив DWORD. В +`iron3d.dll` SHA-256 +`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa` +`FUN_100392e0` (RVA `0x392e0`) повторяет вызов слота AI `+0x38` столько раз, +сколько задаёт `second_resource.tag` этой строки TMA. Этот слот ведёт в +`ai.dll` SHA-256 +`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`: +метод RVA `0x2010` передаёт коллекцию при `AI+0x83c` в `FUN_10002980` +(RVA `0x2980`), а она добавляет один DWORD через `FUN_100048f0` (RVA +`0x48f0`). Каждый вызов копирует `0xffffffff`; входное слово не меняется. +Поэтому тег `3` даёт `[0xffffffff, 0xffffffff, 0xffffffff]`. Это число +начальных ячеек, а не назначение трёх ID. + +Изолированная x86-проверка вызовом оригинальной `ai.dll` подтвердила пары +«число слотов/ёмкость» `0/0`, `1/17`, `17/17` и `18/34`. Все добавленные слова +равны `0xffffffff`, а входное значение, контрольные поля, память вне заголовка +массива и указатель стека сохраняются. Это подтверждает добавление начального +слова, но не работу последующих обработчиков. + +Rust хранит такой снимок в отдельном `MissionAiStartupState` для каждого TMA-клана, +а не в пакете сценария. При полной загрузке (`Full`) создаётся запись для каждого +клана, в том числе если у него нет пути сценария или пути таблицы исследований. Счётчик +берётся из сырых битов `second_resource.tag` как `u32` у обеих форм записи — +`Standard` и `Spatial`; нулевой счётчик даёт существующее состояние с пустым +списком. Статический режим предпросмотра не создаёт состояний AI. + +Загрузка отклоняет суммарный объём свыше 1 000 000 DWORD и резервирует память +проверяемой операцией; это защита Rust от чрезмерного выделения памяти, а не +ограничение оригинальной игры. Rust сохраняет начальные слова, но не +восстанавливает работу последующих обработчиков и не назначает этим словам ID +объектов. Этот массив также не является списком +`BehaviorDistributorState.registered_objects`. + До регулярной работы Behavior настраивает расписание своего первого шага. Он берёт текущее 32-битное время игровой очереди и назначает проверку через три интервала по 64 мс, добавляя небольшую случайную задержку от 0 до 63 мс. Эта @@ -213,7 +254,7 @@ SuperAI для записей кланов, затем обновляет Behavi всё равно устанавливается. В полной загрузке Rust вызывающая сторона передаёт значение времени очереди; после регистрации объектов загрузчик последовательно подготавливает каждый сценарный пакет и выполняет восстановленные действия -`Handler(19)` и числовые literal assignments из первого `Init`. Статический +`Handler(19)` и поддержанные формульные присваивания из первого `Init`. Статический просмотр не запускает Behavior, но может сохранить эти записи `Init` в сценарном varset. @@ -546,8 +587,14 @@ let cost = calc_full_research_cost(&candidate, &table)?; штатный Handler(43) и запуск миссии здесь не проверялись. Владелец начальной записи исследования — индекс текущего прохода по кланам. -Движок сохраняет его в `descriptor[0]`, а затем переносит в контекст, чтобы -связать запрос с AI-данными того же клана. +В GOG-сборке `iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) проходит записи кланов +и вызывает для каждой `FUN_100392e0` (RVA `0x392e0`), которая создаёт AI; +`ai.dll!CreateSuperAI` (RVA `0xf710`) выделяет объект и вызывает конструктор +по RVA `0x1000`, не проверяя наличие сценарного пути. Для исследовательского +пути нужны отдельный флаг включения и непустой `second_resource.path`; наличие +сценарного пакета условием не является. Индекс клана сохраняется в +`descriptor[0]` и переносится в контекст, чтобы связать запрос с AI-данными +того же клана. Это значение отдельно от аргумента 1 `AddNewObjectToGame`, берущегося из `GetNetPlayerNum`, и от TMA-тега `second_resource`, задающего число вызовов @@ -557,12 +604,14 @@ Handler(43) и штатный запуск миссии остаются непÑ Полный Rust-загрузчик принимает явный `MissionStartupInput`: время очереди и отдельный `research_creation_gate`. Режим миссии и имя файла этот -флаг не заменяют. При включённом флаге таблица загружается только для клана, -чей сценарный пакет уже прочитан и успешно разобран; путь берётся из второго -ресурса этого клана как C-строка. Пустая C-строка пропускается, а не-ASCII -байты дают явную ошибку. Клан индексируется по порядку в TMA, тег ресурса на -владение не влияет. Если несколько кланов указывают на один файл, каждый -получает отдельно декодированную таблицу с собственными строками TRF0. +флаг не заменяют. При включённом флаге загрузчик использует уже имеющееся +`MissionAiStartupState` каждого TMA-клана, даже если у него нет сценарного +пакета; путь берётся из второго ресурса как C-строка до первого NUL. Пустой +путь и выключенный флаг пропускают чтение, а не-ASCII байты дают явную ошибку. +Предпросмотр тоже не читает TRF. Клан индексируется по порядку в TMA, а тег +`second_resource` задаёт число слотов AI, не владельца. Если несколько кланов +указывают на один файл, каждый получает отдельно декодированную таблицу со +своими строками TRF0. ```rust MissionStartupInput { @@ -571,9 +620,10 @@ MissionStartupInput { } ``` -Это сохраняет исходные таблицы и их связь с владельцами для следующих этапов. -Оно ещё не создаёт полный исследовательский контекст оригинала и не запускает -`Handler(43)`; эта обработка пока остаётся явно неподдержанной. +Это сохраняет декодированные таблицы и связь с владельцами для следующих +этапов. Оно ещё не создаёт полный исследовательский контекст оригинала, не +строит AI-каталог и не запускает `Handler(43)`; эти части пока не +восстановлены. Отдельная frozen-матрица непосредственно вызвала chooser GOG `ai.dll` SHA-256 `c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`: функция |
