diff options
Diffstat (limited to 'crates/fparkan-runtime')
| -rw-r--r-- | crates/fparkan-runtime/src/lib.rs | 1237 |
1 files changed, 1157 insertions, 80 deletions
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 17d942f..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; @@ -495,6 +496,38 @@ pub struct MissionControlDeviceTableState { pub current_output_rate_5e0_bits: u32, } +/// One initial outer row prepared from the current Control descriptor table. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MissionBehaviorControlGroup { + /// Table ordinal of the descriptor whose current tag starts this group. + pub header_device_ordinal: usize, + /// Table ordinals of current-tag-2 descriptors following this header. + pub candidate_device_ordinals: Vec<usize>, + /// Table ordinals of current-tag-30 descriptors following this header. + pub tag30_device_ordinals: Vec<usize>, +} + +/// Initial grouping of a prepared Control table, before live Behavior refreshes. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct MissionBehaviorControlGrouping { + /// Groups in the order their current-tag-1 headers appeared. + pub groups: Vec<MissionBehaviorControlGroup>, + /// Current-tag-2 entries before the first header. + pub ungrouped_candidate_device_ordinals: Vec<usize>, +} + +/// Initial Behavior grouping projection for one full-load mission object. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MissionBehaviorControlGroupingState { + /// Stable identity from the source TMA object order. + pub original_id: Option<OriginalObjectId>, + /// Corresponding live Rust world object. + pub handle: ObjectHandle, + /// Initial grouping, or the reason its prepared Control table is unavailable. + pub grouping: + Result<MissionBehaviorControlGrouping, MissionControlDeviceTableUnavailableReason>, +} + /// Provenance for the class passed to Control's constructor. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MissionControlClassSource { @@ -680,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 { @@ -847,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>, @@ -974,7 +1017,9 @@ struct LoadedMissionState { behavior_ore_states: Vec<BehaviorOreState>, behavior_profiles: Vec<BehaviorProfileState>, 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>, @@ -1046,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. @@ -1225,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, @@ -1285,6 +1350,7 @@ impl std::error::Error for EngineError { | Self::VarSet { .. } | Self::ScriptInit { .. } | Self::ScriptRuntime { .. } + | Self::MissionAiRecordSlots { .. } | Self::Movement(_) | Self::ResearchTrfPath { .. } | Self::PrototypeGraph { .. } @@ -1444,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() }; @@ -1720,6 +1786,14 @@ fn load_mission_with_options( } else { Vec::new() }; + let behavior_control_groupings = if options.asset_scope == MissionAssetScope::Full { + control_device_tables + .iter() + .map(project_mission_behavior_control_grouping) + .collect() + } else { + Vec::new() + }; let mut next_behavior_rng_state = engine.behavior_rng_state; if options.asset_scope == MissionAssetScope::Full { @@ -1801,7 +1875,9 @@ fn load_mission_with_options( behavior_ore_states, behavior_profiles, 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, @@ -1883,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]> { @@ -2132,6 +2217,61 @@ pub fn loaded_mission_control_device_tables( .map(|state| state.control_device_tables.as_slice()) } +/// Returns the initial prepared Control grouping for full-load mission objects. +#[must_use] +pub fn loaded_mission_behavior_control_groupings( + engine: &Engine, +) -> Option<&[MissionBehaviorControlGroupingState]> { + engine + .loaded + .as_ref() + .map(|state| state.behavior_control_groupings.as_slice()) +} + +fn project_mission_behavior_control_grouping( + table: &MissionControlDeviceTableState, +) -> MissionBehaviorControlGroupingState { + let grouping = match &table.status { + MissionControlDeviceTableStatus::Available(devices) => { + Ok(build_mission_behavior_control_grouping(devices)) + } + MissionControlDeviceTableStatus::Unavailable(reason) => Err(reason.clone()), + }; + MissionBehaviorControlGroupingState { + original_id: table.original_id, + handle: table.handle, + grouping, + } +} + +fn build_mission_behavior_control_grouping( + devices: &[EffectiveControlDevice], +) -> MissionBehaviorControlGrouping { + let mut grouping = MissionBehaviorControlGrouping::default(); + for (ordinal, device) in devices.iter().enumerate() { + match device.descriptor.tag { + 1 => { + grouping.groups.push(MissionBehaviorControlGroup { + header_device_ordinal: ordinal, + candidate_device_ordinals: Vec::new(), + tag30_device_ordinals: Vec::new(), + }); + } + 2 => match grouping.groups.last_mut() { + Some(group) => group.candidate_device_ordinals.push(ordinal), + None => grouping.ungrouped_candidate_device_ordinals.push(ordinal), + }, + 30 => { + if let Some(group) = grouping.groups.last_mut() { + group.tag30_device_ordinals.push(ordinal); + } + } + _ => {} + } + } + grouping +} + /// Queries native Control global-table selector 1 (weighted ratio), 2 (sum), or 15. /// `Ok(None)` preserves the original false/no-result behavior for an empty or /// zero-denominator table or selector 15 without both cached factory rows. Other @@ -2159,20 +2299,22 @@ pub fn query_loaded_mission_control_resource( query_control_device_table_resource(table, selector) } -/// Queries one loaded Control device through its original factory getter. +/// Queries one loaded Control device through its indexed selector interface. /// -/// Selector `0xe00` is modeled for current descriptor tags 5 and 26 with -/// factory tags 5 and 26. Both common factory readers use the device key to -/// read the current provider value and multiply it by coefficient 0. Only -/// current tag 26 also multiplies by `M`; current tag 5 ignores `M` and -/// descriptor word 8 for this selector. Explicit Control update calls can -/// change current provider or multiplier values; the mission clock does not -/// advance them automatically. +/// Selector `0x400` returns the current type-1 value from the provider row +/// selected by the device key, including signed zero, regardless of current +/// descriptor or factory tag. Selector `0xe00` is modeled for current +/// descriptor tags 5 and 26 with factory tags 5 and 26. Both common factory +/// readers use the device key to read the current provider value and multiply +/// it by coefficient 0. Only current tag 26 also multiplies by `M`; current +/// tag 5 ignores `M` and descriptor word 8 for this selector. Explicit Control +/// update calls can change current provider or multiplier values; the mission +/// clock does not advance them automatically. /// /// # Errors /// Returns an error for an invalid handle, unavailable mission/table/provider, -/// an out-of-range device/provider key, or a factory/tag/float path that is not -/// supported by the current reconstruction. +/// an out-of-range device/provider key, an unsupported float range, or an +/// unsupported current/factory tag on selector `0xe00`. pub fn query_loaded_mission_control_device_selector( engine: &Engine, handle: ObjectHandle, @@ -4274,7 +4416,7 @@ fn query_control_device_table_selector( device_index: usize, selector: u32, ) -> Result<Option<f32>, ControlResourceAccessError> { - if selector != 0xe00 { + if !matches!(selector, 0x400 | 0xe00) { return Err(ControlResourceAccessError::UnsupportedSelector(selector)); } let devices = match &table.status { @@ -4290,6 +4432,10 @@ fn query_control_device_table_selector( device_index, device_count: devices.len(), })?; + if selector == 0x400 { + return control_device_provider_value_from_status(&table.node_provider_status, device) + .map(Some); + } query_control_device_selector(device, &table.node_provider_status) } @@ -6467,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 { @@ -6496,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(), }); } @@ -6505,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, }); @@ -6526,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") { @@ -6577,6 +6771,7 @@ fn load_mission_scripts( }; Ok(LoadedMissionScripts { bundles, + ai_startup_states, varset, init_states, varset_states, @@ -6819,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}; @@ -7276,6 +7470,95 @@ mod tests { } } + #[test] + fn behavior_control_grouping_uses_current_tags_and_table_ordinals() { + let tags = [2, 30, 1, 30, 2, 99, 1, 2, 30, 2, 1]; + let devices = tags + .into_iter() + .enumerate() + .map(|(ordinal, current_tag)| { + let mut device = synthetic_effective_control_device( + 5, + current_tag, + 1.0_f32.to_bits(), + None, + None, + ); + device.key = 100 + ordinal as u32; + device.descriptor.fixed_words[1] = 200 + ordinal as u32; + device + }) + .collect(); + let table = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(devices)); + let state = project_mission_behavior_control_grouping(&table); + assert_eq!(state.original_id, Some(OriginalObjectId(7))); + assert_eq!( + state.handle, + ObjectHandle { + generation: 5, + slot: 7 + } + ); + assert_eq!( + state.grouping, + Ok(MissionBehaviorControlGrouping { + ungrouped_candidate_device_ordinals: vec![0], + groups: vec![ + MissionBehaviorControlGroup { + header_device_ordinal: 2, + candidate_device_ordinals: vec![4], + tag30_device_ordinals: vec![3], + }, + MissionBehaviorControlGroup { + header_device_ordinal: 6, + candidate_device_ordinals: vec![7, 9], + tag30_device_ordinals: vec![8], + }, + MissionBehaviorControlGroup { + header_device_ordinal: 10, + candidate_device_ordinals: Vec::new(), + tag30_device_ordinals: Vec::new(), + }, + ], + }), + "group membership uses current descriptor tags and table ordinals, not factory tags or keys" + ); + + let empty = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(Vec::new())); + assert_eq!( + project_mission_behavior_control_grouping(&empty).grouping, + Ok(MissionBehaviorControlGrouping::default()) + ); + + let headerless_devices = [2, 30, 2] + .into_iter() + .map(|current_tag| { + synthetic_effective_control_device(5, current_tag, 1.0_f32.to_bits(), None, None) + }) + .collect(); + let headerless = synthetic_control_device_table( + MissionControlDeviceTableStatus::Available(headerless_devices), + ); + assert_eq!( + project_mission_behavior_control_grouping(&headerless).grouping, + Ok(MissionBehaviorControlGrouping { + groups: Vec::new(), + ungrouped_candidate_device_ordinals: vec![0, 2], + }) + ); + + let reason = MissionControlDeviceTableUnavailableReason::NoControlResource; + let unavailable = synthetic_control_device_table( + MissionControlDeviceTableStatus::Unavailable(reason.clone()), + ); + assert_eq!( + project_mission_behavior_control_grouping(&unavailable).grouping, + Err(reason) + ); + } + fn control_budget_test_table(generator_coefficient: f32) -> MissionControlDeviceTableState { let mut generator = synthetic_effective_control_device( 19, @@ -10062,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() }; @@ -10091,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"); @@ -11705,6 +12176,71 @@ mod tests { } #[test] + fn indexed_provider_value_query_400_reads_current_value_by_device_key() { + let mut device = synthetic_effective_control_device(2, 99, 9.0_f32.to_bits(), None, None); + device.key = 1; + device.native_word_50_bits = Some(0); + device.coefficient_bits = None; + device.descriptor.fixed_words[8] = 13.0_f32.to_bits(); + let table = synthetic_control_device_table_with_providers( + MissionControlDeviceTableStatus::Available(vec![device]), + MissionControlNodeProviderTableStatus::Available(vec![ + synthetic_node_provider_row(0, 0, 0.125_f32.to_bits()), + synthetic_node_provider_row(0, 1, 0.625_f32.to_bits()), + synthetic_node_provider_row(0, 2, 0.875_f32.to_bits()), + ]), + ); + + assert_eq!( + query_control_device_table_selector(&table, 0, 0x400), + Ok(Some(0.625_f32)), + "selector 0x400 returns the current type-1 value at the device's provider key" + ); + + let mut negative_zero = table.clone(); + let MissionControlNodeProviderTableStatus::Available(rows) = + &mut negative_zero.node_provider_status + else { + unreachable!(); + }; + rows[1].current_type1_value_bits = (-0.0_f32).to_bits(); + assert_eq!( + query_control_device_table_selector(&negative_zero, 0, 0x400) + .expect("negative-zero provider value") + .map(f32::to_bits), + Some((-0.0_f32).to_bits()) + ); + + let reason = MissionControlNodeProviderTableUnavailableReason::MissingNodeData { + unit_component_index: 0, + source: None, + }; + let unavailable = synthetic_control_device_table_with_providers( + table.status.clone(), + MissionControlNodeProviderTableStatus::Unavailable(reason.clone()), + ); + assert_eq!( + query_control_device_table_selector(&unavailable, 0, 0x400), + Err(ControlResourceAccessError::NodeProviderTableUnavailable( + reason + )) + ); + + let mut out_of_range = table.clone(); + let MissionControlDeviceTableStatus::Available(devices) = &mut out_of_range.status else { + unreachable!(); + }; + devices[0].key = 3; + assert_eq!( + query_control_device_table_selector(&out_of_range, 0, 0x400), + Err(ControlResourceAccessError::NodeProviderKeyOutOfRange { + key: 3, + row_count: 3, + }) + ); + } + + #[test] fn synthetic_into_builder_appends_all_rows_and_overrides_only_first_record() { let append = synthetic_control_table( &[None, Some(PROTOTYPE_TYPE_INTO)], @@ -12813,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( @@ -12855,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( @@ -13021,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, @@ -13041,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(), }, ], @@ -13054,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, }; @@ -13072,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(), }, } ); @@ -13103,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(); @@ -13550,6 +14275,62 @@ mod tests { #[test] #[ignore = "requires licensed corpus"] + fn full_load_builds_initial_behavior_control_groupings_for_autodemo() { + let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new( + licensed_root("IS"), + )))) + .expect("engine"); + load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".into(), + mode: MissionMode::AutoDemo, + }, + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, + ) + .expect("load licensed AutoDemo with explicit startup inputs"); + + let groupings = + loaded_mission_behavior_control_groupings(&engine).expect("initial Behavior groupings"); + let group = |header_device_ordinal, candidate_device_ordinals: &[usize]| { + MissionBehaviorControlGroup { + header_device_ordinal, + candidate_device_ordinals: candidate_device_ordinals.to_vec(), + tag30_device_ordinals: Vec::new(), + } + }; + let expected = [ + (0, vec![group(6, &[10, 11, 12, 13])]), + (1, vec![group(6, &[10, 11])]), + (2, vec![group(8, &[12, 13, 14])]), + (3, vec![group(6, &[10, 11, 12])]), + (4, Vec::new()), + (5, Vec::new()), + (6, Vec::new()), + (7, Vec::new()), + ]; + assert_eq!(groupings.len(), expected.len()); + for (original_id, expected_groups) in expected { + let state = groupings + .iter() + .find(|state| state.original_id == Some(OriginalObjectId(original_id))) + .expect("grouping for AutoDemo Control object"); + assert_eq!( + state.grouping, + Ok(MissionBehaviorControlGrouping { + groups: expected_groups, + ungrouped_candidate_device_ordinals: Vec::new(), + }), + "original object {original_id}" + ); + } + } + + #[test] + #[ignore = "requires licensed corpus"] fn full_load_queries_control_selector_15_against_frozen_native_inputs() { let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new( licensed_root("IS"), @@ -13594,6 +14375,96 @@ mod tests { #[test] #[ignore = "requires licensed corpus"] + fn full_load_queries_indexed_control_selector_400_for_all_autodemo_devices() { + let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new( + licensed_root("IS"), + )))) + .expect("engine"); + load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".into(), + mode: MissionMode::AutoDemo, + }, + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, + ) + .expect("load licensed AutoDemo with explicit startup inputs"); + + let tables = loaded_mission_control_device_tables(&engine).expect("Control tables"); + let expected: [(u32, usize, &[u32]); 8] = [ + (0, 33, &[0, 0, 0, 0, 0, 0, 16, 19, 16, 18, 27, 30, 31, 32]), + (1, 23, &[0, 0, 0, 0, 0, 0, 10, 13, 10, 11, 19, 22]), + (2, 26, &[0, 0, 0, 0, 0, 0, 0, 0, 8, 9, 8, 10, 19, 22, 25]), + (3, 29, &[0, 0, 0, 0, 0, 0, 16, 21, 16, 20, 26, 27, 28]), + (4, 32, &[5, 5, 1, 23, 3, 22, 7, 19, 16, 23, 5, 29]), + (5, 32, &[5, 5, 1, 23, 3, 22, 7, 19, 16, 23, 5, 29]), + (6, 22, &[17, 19, 19, 15, 13, 14, 10, 11, 4, 5, 7]), + (7, 22, &[17, 19, 19, 15, 13, 14, 10, 11, 4, 5, 7]), + ]; + assert_eq!(tables.len(), expected.len()); + + let mut queried_devices = 0; + for (original_id, expected_provider_rows, expected_keys) in expected { + let table = tables + .iter() + .find(|table| table.original_id == Some(OriginalObjectId(original_id))) + .expect("AutoDemo Control table for original object"); + let devices = match &table.status { + MissionControlDeviceTableStatus::Available(devices) => devices, + MissionControlDeviceTableStatus::Unavailable(reason) => { + panic!("Control table for object {original_id} is unavailable: {reason:?}"); + } + }; + let provider_rows = match &table.node_provider_status { + MissionControlNodeProviderTableStatus::Available(rows) => rows, + MissionControlNodeProviderTableStatus::Unavailable(reason) => { + panic!("provider rows for object {original_id} are unavailable: {reason:?}"); + } + }; + assert_eq!( + devices.len(), + expected_keys.len(), + "device count for {original_id}" + ); + assert_eq!( + provider_rows.len(), + expected_provider_rows, + "provider row count for {original_id}" + ); + + for (device_index, expected_key) in expected_keys.iter().copied().enumerate() { + let device = &devices[device_index]; + assert_eq!( + device.key, expected_key, + "provider key for object {original_id}, device {device_index}" + ); + assert!( + (expected_key as usize) < provider_rows.len(), + "provider key for object {original_id}, device {device_index}" + ); + let actual = query_loaded_mission_control_device_selector( + &engine, + table.handle, + device_index, + 0x400, + ) + .expect("selector 0x400 query"); + assert_eq!( + actual.map(f32::to_bits), + Some(0x3f80_0000), + "current provider value for object {original_id}, device {device_index}, key {expected_key}" + ); + queried_devices += 1; + } + } + assert_eq!(queried_devices, 100); + } + + #[test] + #[ignore = "requires licensed corpus"] fn headless_step_runs_resource_schedule_and_rolls_back_both_error_paths() { let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new( licensed_root("IS"), @@ -14640,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)); @@ -14654,6 +15569,10 @@ mod tests { ) .expect("load licensed Single.01 preview"); + assert!(loaded_mission_behavior_control_groupings(&engine) + .expect("preview grouping projection") + .is_empty()); + let bundles = loaded_mission_script_bundles(&engine).expect("script bundles"); assert_eq!(bundles.len(), 2); let first = bundles @@ -15058,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(); @@ -15112,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!( @@ -15510,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()); } @@ -15537,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); @@ -15594,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", @@ -15622,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); @@ -15646,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"[..])], @@ -15666,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 = @@ -15695,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"); @@ -15730,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); @@ -15775,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:?}"), @@ -15786,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"); @@ -15805,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"); @@ -15872,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], }); } @@ -15897,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) } |
