diff options
Diffstat (limited to 'crates/fparkan-runtime')
| -rw-r--r-- | crates/fparkan-runtime/Cargo.toml | 4 | ||||
| -rw-r--r-- | crates/fparkan-runtime/src/lib.rs | 4282 | ||||
| -rw-r--r-- | crates/fparkan-runtime/src/mesh_collision.rs | 1255 |
3 files changed, 5394 insertions, 147 deletions
diff --git a/crates/fparkan-runtime/Cargo.toml b/crates/fparkan-runtime/Cargo.toml index f66551b..80e5b78 100644 --- a/crates/fparkan-runtime/Cargo.toml +++ b/crates/fparkan-runtime/Cargo.toml @@ -7,6 +7,7 @@ repository.workspace = true [dependencies] fparkan-assets = { path = "../fparkan-assets" } +fparkan-fx = { path = "../fparkan-fx" } fparkan-msh = { path = "../fparkan-msh" } fparkan-path = { path = "../fparkan-path" } fparkan-platform = { path = "../fparkan-platform" } @@ -17,3 +18,6 @@ fparkan-resource = { path = "../fparkan-resource" } fparkan-script = { path = "../fparkan-script" } fparkan-vfs = { path = "../fparkan-vfs" } fparkan-world = { path = "../fparkan-world" } + +[dev-dependencies] +fparkan-animation = { path = "../fparkan-animation" } diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 5e352fc..5cc2d86 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -1,29 +1,33 @@ #![forbid(unsafe_code)] //! Runtime orchestration for mission loading and world stepping. -#[cfg(test)] -use fparkan_assets::PreparedControlResourcePair; +use fparkan_assets::ClanBody; use fparkan_assets::{ decode_mission_land_path, decode_mission_payload, decode_nres_payload, derive_mission_land_paths, extend_graph_report_with_visual_dependencies, mission_script_bundle_bases, prepare_terrain_world, AssetError as AssetPreparationError, AssetId, AssetManager, BehaviorProfileRates, BehaviorProfileVarError, BuildCategory, MissionAssetPlan, MissionDocument, MissionError, MissionTerrainPaths, NresError, - PreparedComponentControl, PreparedControlEffectSection, PreparedControlNodeDataRow, - PreparedControlNodeDataTable, PreparedControlTypedRecord, PreparedVisual, TerrainFormatError, - TerrainPreparationError, TerrainWorld, TmaProfile, + PreparedComponentControl, PreparedControlEffectRow, PreparedControlEffectSection, + PreparedControlNodeDataRow, PreparedControlNodeDataTable, PreparedControlResourcePair, + PreparedControlTypedRecord, PreparedVisual, ResearchError, ResearchTrfDocument, + TerrainFormatError, TerrainPreparationError, TerrainWorld, TmaProfile, }; +use fparkan_fx::{decode_fxid, FxOpcode, FXID_KIND}; use fparkan_msh::{node38_metadata, selected_slot, Group, Lod, NodeId}; use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy}; use fparkan_prototype::{ - build_prototype_graph_report, unit_component_tree, PrototypeGraph, PrototypeGraphFailure, - PrototypeGraphReport, UnitComponentRecord, UnitComponentTreeNode, PROTOTYPE_TYPE_EXTO, - UNIT_DAT_BAS_PLACEMENT_FLAG, + build_prototype_graph_report, resolve_prototype_all, unit_component_tree, PrototypeGraph, + PrototypeGraphFailure, PrototypeGraphReport, PrototypeSource, UnitComponentRecord, + UnitComponentTreeNode, PROTOTYPE_TYPE_EXTO, UNIT_DAT_BAS_PLACEMENT_FLAG, +}; +use fparkan_resource::{ + resource_name, CachedResourceRepository, ResourceError, ResourceKey, ResourceRepository, }; -use fparkan_resource::{resource_name, CachedResourceRepository, ResourceKey, ResourceRepository}; use fparkan_script::{ - Handler19DwordWrite, Handler19InitInput, Handler19InitResolution, Handler19ResolveError, - ScriptInstruction, ScriptPackage, VarSet, VarSetDefault, VmHostCallbackCommand, + FormulaTable, Handler19InitInput, ScriptInitResolution, ScriptInitResolveError, + ScriptInitWrite, ScriptInstruction, ScriptPackage, VarSet, VarSetDefault, + VmHostCallbackCommand, }; use fparkan_terrain::SurfaceQuery; use fparkan_vfs::{Vfs, VfsError}; @@ -36,9 +40,13 @@ use std::collections::HashMap; use std::num::NonZeroUsize; use std::sync::Arc; +mod mesh_collision; + 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; @@ -82,6 +90,15 @@ pub struct MissionRequest { pub mode: MissionMode, } +/// Explicit inputs supplied by the startup caller to the full mission loader. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MissionStartupInput { + /// Caller-supplied queue clock used for the initial Behavior deadline. + pub queue_time_ms: u32, + /// Whether this startup creates the per-clan research tables. + pub research_creation_gate: bool, +} + const INITIAL_SCRIPT_HOST_STATUS: u32 = 5; /// Ordered mission property preserved for later runtime stages. @@ -190,6 +207,251 @@ pub struct ControlDeviceRecordProvenance { pub source: ResourceKey, } +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct Factory2StringPair { + archive: String, + name: String, +} + +impl Factory2StringPair { + fn empty() -> Self { + Self { + archive: String::new(), + name: String::new(), + } + } + + fn matches_ascii_case(&self, other: &Self) -> bool { + self.archive.eq_ignore_ascii_case(&other.archive) + && self.name.eq_ignore_ascii_case(&other.name) + } + + fn cache_key(&self) -> Self { + Self { + archive: self.archive.to_ascii_lowercase(), + name: self.name.to_ascii_lowercase(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Factory2PairOutcome { + Value(u32), + NativeMiss, + Unrepresentable, +} + +fn resolve_factory2_pair_from_assets<R: ResourceRepository>( + asset_manager: &AssetManager<R>, + vfs: &dyn Vfs, + pair: &Factory2StringPair, +) -> Result<Factory2PairOutcome, AssetPreparationError> { + if !pair.archive.eq_ignore_ascii_case("objects.rlb") + || pair.name.is_empty() + || pair + .name + .bytes() + .any(|byte| matches!(byte, b'/' | b'\\' | b'.')) + || normalize_relative(pair.name.as_bytes(), PathPolicy::StrictLegacy).is_err() + { + return Ok(Factory2PairOutcome::Unrepresentable); + } + + let root_name = resource_name(pair.name.as_bytes()); + let prototypes = + resolve_prototype_all(asset_manager.repository(), vfs, &root_name).map_err(|source| { + AssetPreparationError::InvalidPrototype(format!( + "factory-2 pair {}:{}: {source}", + pair.archive, pair.name + )) + })?; + let [prototype] = prototypes.as_slice() else { + return Ok(Factory2PairOutcome::Unrepresentable); + }; + if prototype.source != PrototypeSource::ObjectsRegistry + || prototype.source_type != Some(CONTROL_CLASS_SOURCE_BULL) + || !matches!( + prototype.geometry, + fparkan_prototype::PrototypeGeometry::Mesh(_) + ) + { + return Ok(Factory2PairOutcome::Unrepresentable); + } + + let assets = asset_manager.prepare_mission_assets(&[0..1], &prototypes)?; + let Some(controls) = assets.object_component_controls.first() else { + return Ok(Factory2PairOutcome::Unrepresentable); + }; + let [Some(control)] = controls.as_slice() else { + return Ok(Factory2PairOutcome::Unrepresentable); + }; + if !control.timeline_rows.is_empty() + || !control.typed_records.iter().all(|record| record.tag == 17) + || !matches!( + &control.effect_section, + PreparedControlEffectSection::Absent | PreparedControlEffectSection::Parsed(_) + ) + { + return Ok(Factory2PairOutcome::Unrepresentable); + } + let Some(node_data) = control.node_data.as_ref() else { + return Ok(Factory2PairOutcome::Unrepresentable); + }; + let Some(opcodes) = asset_manager.initial_control_effect_opcodes( + &control.source, + control.model_node_count, + 0, + )? + else { + return Ok(Factory2PairOutcome::Unrepresentable); + }; + if !opcodes.iter().all(|opcode| matches!(opcode, 1 | 4 | 12)) { + return Ok(Factory2PairOutcome::Unrepresentable); + } + if !factory2_selected_fx_resources_are_representable(asset_manager, control, &opcodes)? { + return Ok(Factory2PairOutcome::Unrepresentable); + } + match factory2_node_data_maximum_bits(&node_data.rows) { + Ok(bits) => Ok(Factory2PairOutcome::Value(bits)), + Err(_) => Ok(Factory2PairOutcome::Unrepresentable), + } +} + +fn factory2_selected_fx_resources_are_representable<R: ResourceRepository>( + asset_manager: &AssetManager<R>, + control: &PreparedComponentControl, + selected_opcodes: &[u32], +) -> Result<bool, AssetPreparationError> { + let selected_opcode4_count = selected_opcodes + .iter() + .filter(|opcode| **opcode == 4) + .count(); + let PreparedControlEffectSection::Parsed(rows) = &control.effect_section else { + return Ok(selected_opcode4_count == 0); + }; + let selected_rows = rows + .iter() + .filter(|row| Some(row.table_index) == control.initial_effect_group) + .collect::<Vec<_>>(); + if selected_rows.len() != selected_opcode4_count { + return Ok(false); + } + if selected_rows.is_empty() { + return Ok(true); + } + let Some(points) = control.point_list.as_ref() else { + return Ok(false); + }; + + for row in selected_rows { + if !row.point_offsets.iter().all(|offset| { + *offset != u32::MAX + && usize::try_from(*offset).is_ok_and(|index| index < points.points.len()) + }) { + return Ok(false); + } + let Some(key) = factory2_fx_resource_key(&row.resource) else { + return Ok(false); + }; + if !factory2_fx_resource_is_representable(asset_manager.repository(), &key, row)? { + return Ok(false); + } + } + Ok(true) +} + +fn factory2_fx_resource_key(resource: &PreparedControlResourcePair) -> Option<ResourceKey> { + let archive = bounded_ascii_c_string(&resource.archive)?; + let name = bounded_ascii_c_string(&resource.name)?; + if archive.is_empty() || name.is_empty() { + return None; + } + Some(ResourceKey { + archive: normalize_relative(archive.as_bytes(), PathPolicy::StrictLegacy).ok()?, + name: resource_name(name.as_bytes()), + type_id: Some(FXID_KIND), + }) +} + +fn factory2_fx_resource_is_representable<R: ResourceRepository>( + repository: &R, + key: &ResourceKey, + row: &PreparedControlEffectRow, +) -> Result<bool, AssetPreparationError> { + let context = format!( + "factory-2 selected Control effect group {} row {} FX {}:{}", + row.table_index, + row.row_index, + key.archive.as_str(), + String::from_utf8_lossy(&key.name.0) + ); + let archive = match repository.open_archive_unchanged(&key.archive) { + Ok(archive) => archive, + Err(ResourceError::MissingArchive { .. } | ResourceError::MissingEntry) => { + return Ok(false); + } + Err(source) => { + return Err(AssetPreparationError::Resource { + context, + source: Box::new(source), + }); + } + }; + let entry = + repository + .find(archive, &key.name) + .map_err(|source| AssetPreparationError::Resource { + context: context.clone(), + source: Box::new(source), + })?; + let Some(entry) = entry else { + return Ok(false); + }; + let info = repository + .entry_info(entry) + .map_err(|source| AssetPreparationError::Resource { + context: context.clone(), + source: Box::new(source), + })?; + if info.key.type_id != Some(FXID_KIND) { + return Ok(false); + } + let bytes = repository + .read(entry) + .map_err(|source| AssetPreparationError::Resource { + context: context.clone(), + source: Box::new(source), + })?; + let document = decode_fxid(Arc::from(bytes.into_owned())).map_err(|source| { + AssetPreparationError::InvalidPrototype(format!("{context}: {source}")) + })?; + Ok(document.commands().iter().all(|command| { + matches!( + command.opcode, + FxOpcode::Op2 + | FxOpcode::Op3 + | FxOpcode::Op4 + | FxOpcode::Op5 + | FxOpcode::Op7 + | FxOpcode::Op8 + | FxOpcode::Op9 + ) + })) +} + +fn factory2_node_data_maximum_bits( + rows: &[PreparedControlNodeDataRow], +) -> Result<u32, &'static str> { + let mut maximum = 0.0_f32; + for row in rows { + let value = checked_control_float(2, "factory-2 NDP Max", row.max_volume) + .map_err(|_| "NDP Max float")?; + maximum = checked_control_sum(2, "factory-2 NDP Max sum", maximum, value) + .map_err(|_| "NDP Max sum")?; + } + Ok(maximum.to_bits()) +} + /// Common fields known for one row in a host object's Control device table. /// /// The row index is its position in the containing `Vec`. This stores the @@ -210,6 +472,8 @@ pub struct EffectiveControlDevice { pub class9_selector0_scale_bits: Option<u32>, /// Factory-26 mutable resource-rate addition at native `+0xa4`. pub tag26_extra_rate_bits: Option<u32>, + /// Factory-2 value read by selector 6 at native `+0x174`. + pub device174_bits: Option<u32>, /// Six class-9 budget values at native `+0x98..+0xac`. pub class9_values_bits: Option<[u32; 6]>, /// Six accumulated class-9 changes at native `+0xb0..+0xc4`. @@ -481,6 +745,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 { @@ -654,9 +950,11 @@ pub struct MissionScriptBundle { pub base_path: String, /// Resolved compiled `.scr` path. pub script_path: String, - /// Losslessly decoded compiled package; only proven `Handler(19)` Init - /// writes are resolved and retained by runtime. + /// Losslessly decoded compiled package; only the recovered first-`Init` + /// subset is resolved and retained by runtime. pub package: ScriptPackage, + /// Optional formula rows loaded from this bundle's sibling `.fml` file. + pub formula_table: Option<FormulaTable>, /// Outcome of the restricted first-`Init` resolution for this bundle. pub init_execution: MissionScriptInitExecution, /// Recovered per-AI Behavior scheduler and total state. @@ -664,6 +962,26 @@ 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 { + /// TMA clan index that owns this table. + pub clan_index: usize, + /// Resolved TMA second-resource path used to load this table. + pub path: String, + /// Independently decoded TRF document. + pub document: ResearchTrfDocument, +} + /// Behavior distributor clock and totals tracked from warm start through resource ticks. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct BehaviorWarmStartState { @@ -722,11 +1040,12 @@ fn schedule_behavior_distributor_tick( Some(behavior_elapsed_seconds(elapsed_ms)) } -/// Numeric script defaults selected for the mission's first script bundle. +/// Numeric script declarations selected for the mission's first script bundle. /// /// GOG `ai.dll` first tries `<bundle-base>.var`, then falls back to the shared /// `MISSIONS/SCRIPTS/varset.var`. This stores the successfully selected source -/// and supplies the typed targets for the proven `Handler(19)` Init writes. +/// and supplies typed targets for recovered `Handler(19)` and numeric formula +/// assignments in the first `Init` event. #[derive(Clone, Debug, Eq, PartialEq)] pub struct MissionScriptVarSet { /// Resolved `.var` path selected by the original fallback order. @@ -737,11 +1056,7 @@ pub struct MissionScriptVarSet { pub declarations: VarSet, } -/// One resolved, corpus-proven `Init` result for a mission clan. -/// -/// This is intentionally limited to `Handler(19)`: an `Init` event's other -/// selectors are retained in [`MissionScriptBundle::package`] but are not -/// guessed as executable behavior. +/// One resolved subset of the first `Init` event for a mission clan. #[derive(Clone, Debug, Eq, PartialEq)] pub struct MissionScriptInitState { /// TMA clan index that owns the `SuperAI` instance and varset. @@ -750,8 +1065,8 @@ pub struct MissionScriptInitState { pub event_index: usize, /// Raw event word preserved from the compiled `Init` event. pub event_word: u32, - /// Exact three DWORD writes made by each proven `Handler(19)` instruction. - pub writes: Vec<Handler19DwordWrite>, + /// Ordered numeric writes made by the supported `Init` instructions. + pub writes: Vec<ScriptInitWrite>, } /// Execution status of the first event whose case-sensitive C-string name is `Init`. @@ -765,14 +1080,14 @@ pub enum MissionScriptInitExecution { event_index: usize, }, /// A preview retained the decoded package but stopped before the first - /// instruction outside the recovered `Handler(19)` action subset. + /// instruction outside the recovered numeric `Init` subset. Unsupported { /// Zero-based event table index selected by native lookup. event_index: usize, /// Zero-based instruction index where restricted resolution stopped. instruction_index: usize, - /// Typed selector, control, or result-save shape that is not modeled. - error: Handler19ResolveError, + /// Typed selector, operand, or expression shape that is not modeled. + error: ScriptInitResolveError, }, } @@ -823,6 +1138,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>, @@ -915,7 +1231,7 @@ pub struct LoadedMission { pub script_event_count: usize, /// Numeric script defaults loaded through the original bundle/fallback rule. pub script_varset_declaration_count: usize, - /// `Handler(19)` Init results resolved for mission clans. + /// Writes resolved for the first mission-clan `Init` events. pub script_init_state_count: usize, /// Instantiated per-clan varsets whose Init writes have been applied. pub script_varset_state_count: usize, @@ -950,6 +1266,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>, @@ -994,7 +1313,7 @@ pub enum EngineError { /// Parse diagnostic. message: String, }, - /// A recovered `Handler(19)` Init input or target was not representable. + /// A recovered `Init` input or write target was not representable. ScriptInit { /// Owning TMA clan index. clan_index: usize, @@ -1012,7 +1331,7 @@ pub enum EngineError { /// Zero-based instruction index within that event. instruction_index: usize, /// Typed unsupported instruction shape. - source: Handler19ResolveError, + source: ScriptInitResolveError, }, /// A recovered script handler could not resolve against runtime state. ScriptRuntime { @@ -1021,6 +1340,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. @@ -1028,6 +1358,22 @@ pub enum EngineError { /// Source error. source: NresError, }, + /// A loaded clan's second TMA resource could not be decoded as a TRF table. + ResearchTrf { + /// Owning TMA clan index. + clan_index: usize, + /// Resolved resource path. + path: String, + /// TRF decode diagnostic. + source: ResearchError, + }, + /// A clan's research path is not representable by the supported ASCII path layer. + ResearchTrfPath { + /// Owning TMA clan index. + clan_index: usize, + /// Raw C-string bytes before path normalization. + path: Vec<u8>, + }, /// Mission decode error. Mission { /// Resource path. @@ -1184,7 +1530,25 @@ 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, + path, + source, + } => write!(f, "clan {clan_index} research TRF {path}: {source}"), + Self::ResearchTrfPath { clan_index, path } => write!( + f, + "clan {clan_index} research TRF path contains non-ASCII bytes: {path:?}" + ), Self::Mission { path, source } => write!(f, "{path}: {source}"), Self::TerrainFormat { path, source } => write!(f, "{path}: {source}"), Self::Terrain(source) => write!(f, "{source}"), @@ -1223,6 +1587,7 @@ impl std::error::Error for EngineError { Self::Path { source, .. } => Some(source), Self::Vfs { source, .. } => Some(source), Self::Nres { source, .. } => Some(source), + Self::ResearchTrf { source, .. } => Some(source), Self::Mission { source, .. } => Some(source), Self::TerrainFormat { source, .. } => Some(source), Self::ScriptInitUnsupported { source, .. } => Some(source), @@ -1234,7 +1599,9 @@ impl std::error::Error for EngineError { | Self::VarSet { .. } | Self::ScriptInit { .. } | Self::ScriptRuntime { .. } + | Self::MissionAiRecordSlots { .. } | Self::Movement(_) + | Self::ResearchTrfPath { .. } | Self::PrototypeGraph { .. } | Self::BehaviorProfile { .. } | Self::BehaviorDistributorTick { .. } @@ -1255,10 +1622,12 @@ pub fn create(services: EngineServices) -> Result<Engine, EngineError> { /// Loads a mission transactionally and runs the recovered initial script subset. /// -/// `queue_time_ms` is the caller-supplied 32-bit queue-clock sample used to -/// initialize the first Behavior deadline for each script bundle. This runtime -/// entry point does not acquire the native queue clock or reproduce the full -/// SuperAI host lifecycle. +/// `startup_input.queue_time_ms` is the caller-supplied 32-bit queue-clock +/// sample used to initialize the first Behavior deadline for each script bundle. +/// `startup_input.research_creation_gate` explicitly controls whether full +/// startup loads per-clan research tables; it is not inferred from mission mode +/// or path. This runtime entry point does not acquire the native queue clock or +/// reproduce the full SuperAI host lifecycle. /// /// # Errors /// @@ -1270,13 +1639,13 @@ pub fn create(services: EngineServices) -> Result<Engine, EngineError> { pub fn load_mission( engine: &mut Engine, request: MissionRequest, - queue_time_ms: u32, + startup_input: MissionStartupInput, ) -> Result<LoadedMission, EngineError> { load_mission_with_options( engine, request, MissionLoadOptions::default(), - Some(queue_time_ms), + Some(startup_input), ) } @@ -1335,7 +1704,7 @@ fn load_mission_with_options( engine: &mut Engine, request: MissionRequest, options: MissionLoadOptions, - queue_time_ms: Option<u32>, + startup_input: Option<MissionStartupInput>, ) -> Result<LoadedMission, EngineError> { let vfs = engine.services.vfs.clone().ok_or(EngineError::MissingVfs)?; let mission_path = normalize_engine_path("mission", &request.key)?; @@ -1387,6 +1756,13 @@ fn load_mission_with_options( } })?; let mut loaded_scripts = load_mission_scripts(&vfs, &mission, options.asset_scope)?; + 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.ai_startup_states)? + } else { + Vec::new() + }; let repository = CachedResourceRepository::with_payload_cache_budget( vfs.clone(), MISSION_DECODED_PAYLOAD_CACHE_ENTRIES, @@ -1595,7 +1971,9 @@ fn load_mission_with_options( Vec::new() }; + let mut first_factory2_asset_error = None; let control_device_tables = if options.asset_scope == MissionAssetScope::Full { + let mut factory2_pair_cache = HashMap::new(); object_drafts .iter() .zip(&handles) @@ -1609,6 +1987,29 @@ fn load_mission_with_options( prototype_span, &resolved_prototypes, ); + let mut resolve_factory2_pair = |pair: &Factory2StringPair| { + if first_factory2_asset_error.is_some() { + return Factory2PairOutcome::Unrepresentable; + } + let cache_key = pair.cache_key(); + if let Some(outcome) = factory2_pair_cache.get(&cache_key) { + return *outcome; + } + match resolve_factory2_pair_from_assets( + &asset_manager, + vfs.as_ref(), + pair, + ) { + Ok(outcome) => { + factory2_pair_cache.insert(cache_key, outcome); + outcome + } + Err(error) => { + first_factory2_asset_error = Some(error); + Factory2PairOutcome::Unrepresentable + } + } + }; let projection = build_control_device_projection_with_class( object_index, draft, @@ -1616,6 +2017,7 @@ fn load_mission_with_options( prototype_span, &resolved_prototypes, native_control_class, + &mut resolve_factory2_pair, ); match projection { Ok(projection) => MissionControlDeviceTableState { @@ -1659,10 +2061,27 @@ fn load_mission_with_options( } else { Vec::new() }; + if let Some(source) = first_factory2_asset_error { + let _ = fparkan_world::shutdown(new_runtime_world); + return Err(EngineError::AssetPreparation { + mission: request.key.clone(), + source, + }); + } + 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 { - let queue_time_ms = queue_time_ms.expect("full mission loading requires a queue time"); + let queue_time_ms = startup_input + .expect("full mission loading requires startup inputs") + .queue_time_ms; if let Err(error) = start_mission_scripts( &mission, &mut loaded_scripts, @@ -1738,6 +2157,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, @@ -1819,6 +2241,24 @@ 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]> { + engine + .loaded + .as_ref() + .map(|state| state.research_trf_tables.as_slice()) +} + /// Returns the numeric script defaults selected by the original `.var` fallback rule. #[must_use] pub fn loaded_mission_script_varset(engine: &Engine) -> Option<&MissionScriptVarSet> { @@ -1828,10 +2268,11 @@ pub fn loaded_mission_script_varset(engine: &Engine) -> Option<&MissionScriptVar .and_then(|state| state.script_varset.as_ref()) } -/// Returns the proven script `Init` writes resolved for the loaded mission. +/// Returns the recovered script `Init` writes resolved for the loaded mission. /// -/// The list has one entry for each clan whose `Init` event contains recovered -/// `Handler(19)` instructions. It is not a general script event executor. +/// The list has one entry for each clan whose first `Init` event contains a +/// supported `Handler(19)` instruction or numeric FML literal assignment. It +/// is not a general script event executor. #[must_use] pub fn loaded_mission_script_init_states(engine: &Engine) -> Option<&[MissionScriptInitState]> { engine @@ -2058,9 +2499,65 @@ pub fn loaded_mission_control_device_tables( .map(|state| state.control_device_tables.as_slice()) } -/// Queries native Control global-table selector 1 (weighted ratio) or 2 (sum). +/// 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. Other unsupported native paths are reported as errors. +/// zero-denominator table or selector 15 without both cached factory rows. Other +/// unsupported native paths are reported as errors. /// Inputs and intermediates must be finite and normal, or exact zero. /// /// # Errors @@ -2084,20 +2581,27 @@ 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. Selector `6` first checks the +/// current descriptor tag: a non-2 tag returns `Ok(None)`. For the represented +/// factory-2 layout it reads `+0x174`, subject to the normal-or-zero float +/// range checks; an unsupported field or layout returns a typed error. This is +/// a query of the loaded CPU projection, not an FX execution or game tick. /// /// # 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`, or an unsupported +/// selector-6 factory field/layout. pub fn query_loaded_mission_control_device_selector( engine: &Engine, handle: ObjectHandle, @@ -4127,7 +4631,7 @@ fn query_control_device_table_resource( table: &MissionControlDeviceTableState, selector: u32, ) -> Result<Option<f32>, ControlResourceAccessError> { - if !matches!(selector, 1 | 2) { + if !matches!(selector, 1 | 2 | 15) { return Err(ControlResourceAccessError::UnsupportedSelector(selector)); } let entries = match &table.status { @@ -4137,6 +4641,32 @@ fn query_control_device_table_resource( } }; + if selector == 15 { + let Some(class9) = entries.iter().rev().find(|entry| entry.factory_tag == 9) else { + return Ok(None); + }; + let Some(class21) = entries.iter().rev().find(|entry| entry.factory_tag == 21) else { + return Ok(None); + }; + + let mut sum = control_device_budget_coefficient(class21, 5)?; + for index in (0..5).rev() { + sum = checked_control_sum( + 21, + "sum", + sum, + control_device_budget_coefficient(class21, index)?, + )?; + } + return checked_control_product( + 9, + "selector-15 value", + control_device_selector_zero(class9)?, + sum, + ) + .map(Some); + } + let mut numerator = 0.0_f32; let mut denominator = 0.0_f32; for entry in entries.iter().filter(|entry| entry.descriptor.tag == 19) { @@ -4173,7 +4703,7 @@ fn query_control_device_table_selector( device_index: usize, selector: u32, ) -> Result<Option<f32>, ControlResourceAccessError> { - if selector != 0xe00 { + if !matches!(selector, 6 | 0x400 | 0xe00) { return Err(ControlResourceAccessError::UnsupportedSelector(selector)); } let devices = match &table.status { @@ -4189,6 +4719,21 @@ 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); + } + if selector == 6 { + if device.descriptor.tag != 2 { + return Ok(None); + } + if device.factory_tag != 2 { + return Err(ControlResourceAccessError::UnsupportedFactoryTag { + factory_tag: device.factory_tag, + }); + } + return control_device_float(device, device.device174_bits, "+0x174").map(Some); + } query_control_device_selector(device, &table.node_provider_status) } @@ -5547,7 +6092,7 @@ fn append_effective_control_device( } else { Some(descriptor.fixed_words[6]) }; - devices.push(EffectiveControlDevice { + let device = EffectiveControlDevice { factory_tag: descriptor.tag, component_id, key, @@ -5555,6 +6100,7 @@ fn append_effective_control_device( multiplier_bits, class9_selector0_scale_bits: (descriptor.tag == 9).then_some(0x3f80_0000), tag26_extra_rate_bits: (descriptor.tag == 26).then_some(0), + device174_bits: (descriptor.tag == 2).then_some(0), class9_values_bits: (descriptor.tag == 9).then_some([0x3f80_0000; 6]), class9_deltas_bits: (descriptor.tag == 9).then_some([0; 6]), native_word_50_bits, @@ -5562,7 +6108,65 @@ fn append_effective_control_device( descriptor: descriptor.clone(), factory_provenance: provenance.clone(), descriptor_provenance: provenance, - }); + }; + devices.push(device); +} + +fn bounded_ascii_c_string(raw: &[u8]) -> Option<String> { + let end = raw.iter().position(|byte| *byte == 0)?; + let bytes = &raw[..end]; + if !bytes.is_ascii() { + return None; + } + std::str::from_utf8(bytes).ok().map(str::to_owned) +} + +fn fixed_words_ascii_c_string(words: &[u32]) -> Option<String> { + let bytes = words + .iter() + .flat_map(|word| word.to_le_bytes()) + .collect::<Vec<_>>(); + bounded_ascii_c_string(&bytes) +} + +fn factory2_pair_from_record( + descriptor: &PreparedControlTypedRecord, +) -> Option<Factory2StringPair> { + Some(Factory2StringPair { + archive: fixed_words_ascii_c_string(&descriptor.fixed_words[27..35])?, + name: fixed_words_ascii_c_string(&descriptor.fixed_words[35..43])?, + }) +} + +fn apply_factory2_pair_update( + device: &mut EffectiveControlDevice, + previous_pair: Option<Factory2StringPair>, + descriptor: &PreparedControlTypedRecord, + resolve_factory2_pair: &mut dyn FnMut(&Factory2StringPair) -> Factory2PairOutcome, +) { + if device.factory_tag != 2 { + return; + } + let Some(pair) = factory2_pair_from_record(descriptor) else { + device.device174_bits = None; + return; + }; + if previous_pair + .as_ref() + .is_some_and(|previous| previous.matches_ascii_case(&pair)) + { + return; + } + let outcome = if pair.archive.is_empty() || pair.name.is_empty() { + Factory2PairOutcome::NativeMiss + } else { + resolve_factory2_pair(&pair) + }; + match outcome { + Factory2PairOutcome::Value(bits) => device.device174_bits = Some(bits), + Factory2PairOutcome::NativeMiss => {} + Factory2PairOutcome::Unrepresentable => device.device174_bits = None, + } } fn apply_effective_control_override( @@ -5655,6 +6259,7 @@ fn build_control_device_projection( ) -> Result<ControlDeviceProjection, MissionControlDeviceTableUnavailableReason> { let native_class = native_control_class_for_root(draft.unit_dat_header_flags, prototype_span, prototypes); + let mut resolve_factory2_pair = |_: &Factory2StringPair| Factory2PairOutcome::Unrepresentable; build_control_device_projection_with_class( object_index, draft, @@ -5662,6 +6267,7 @@ fn build_control_device_projection( prototype_span, prototypes, native_class, + &mut resolve_factory2_pair, ) } @@ -5672,6 +6278,7 @@ fn build_control_device_projection_with_class( prototype_span: Option<&std::ops::Range<usize>>, prototypes: &[fparkan_prototype::EffectivePrototype], native_control_class: Option<MissionControlClassProjection>, + resolve_factory2_pair: &mut dyn FnMut(&Factory2StringPair) -> Factory2PairOutcome, ) -> Result<ControlDeviceProjection, MissionControlDeviceTableUnavailableReason> { use MissionControlDeviceTableUnavailableReason as Unavailable; @@ -5980,6 +6587,12 @@ fn build_control_device_projection_with_class( control, descriptor, ); + apply_factory2_pair_update( + devices.last_mut().expect("append inserted one device"), + Some(Factory2StringPair::empty()), + descriptor, + resolve_factory2_pair, + ); initialize_control_profile_cache27(&mut profile_cache27_bits, descriptor); } continue; @@ -6020,6 +6633,12 @@ fn build_control_device_projection_with_class( control, descriptor, ); + apply_factory2_pair_update( + devices.last_mut().expect("append inserted one device"), + Some(Factory2StringPair::empty()), + descriptor, + resolve_factory2_pair, + ); initialize_control_profile_cache27(&mut profile_cache27_bits, descriptor); } } @@ -6048,6 +6667,13 @@ fn build_control_device_projection_with_class( factory_tag: device.factory_tag, }); } + let previous_pair = factory2_pair_from_record(&device.descriptor); + apply_factory2_pair_update( + device, + previous_pair, + descriptor, + resolve_factory2_pair, + ); apply_effective_control_override(device, descriptor, component_index, 0, control); update_control_profile_cache27_on_into(&mut profile_cache27_bits, descriptor); } @@ -6332,6 +6958,91 @@ fn normalize_engine_path(role: &'static str, value: &str) -> Result<NormalizedPa }) } +fn script_bundle_stem(base_path: &str) -> &str { + let suffix_start = base_path.len().saturating_sub(4); + if base_path + .get(suffix_start..) + .is_some_and(|suffix| suffix.eq_ignore_ascii_case(".scr")) + { + &base_path[..suffix_start] + } else { + base_path + } +} + +fn script_sibling_path(base_path: &str, extension: &str) -> String { + format!("{}.{extension}", script_bundle_stem(base_path)) +} + +fn load_script_formula_table( + vfs: &Arc<dyn Vfs>, + base_path: &str, +) -> Result<Option<FormulaTable>, EngineError> { + let path = normalize_engine_path("mission formula", &script_sibling_path(base_path, "fml"))?; + match vfs.read(&path) { + Ok(bytes) => Ok(Some(FormulaTable::parse(&bytes))), + Err(VfsError::NotFound(_)) => Ok(None), + Err(source) => Err(EngineError::Vfs { + path: path.as_str().to_string(), + source, + }), + } +} + +fn load_mission_research_trf_tables( + vfs: &Arc<dyn Vfs>, + mission: &MissionDocument, + ai_startup_states: &[MissionAiStartupState], +) -> Result<Vec<MissionResearchTrfTable>, EngineError> { + 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: ai_state.clan_index, + message: "AI startup state clan index is outside the decoded mission".to_string(), + }); + }; + let second_resource = match &clan.body { + ClanBody::Standard { + second_resource, .. + } + | ClanBody::Spatial { + second_resource, .. + } => second_resource, + }; + let raw_path = second_resource + .path + .raw + .split(|byte| *byte == 0) + .next() + .unwrap_or_default(); + if raw_path.is_empty() { + continue; + } + if !raw_path.is_ascii() { + return Err(EngineError::ResearchTrfPath { + clan_index: ai_state.clan_index, + path: raw_path.to_vec(), + }); + } + let value = std::str::from_utf8(raw_path).expect("ASCII paths are valid UTF-8"); + let path = normalize_engine_path("mission research TRF", value)?; + let bytes = read_vfs(vfs, &path)?; + let document = + ResearchTrfDocument::decode(bytes).map_err(|source| EngineError::ResearchTrf { + clan_index: ai_state.clan_index, + path: path.as_str().to_string(), + source, + })?; + tables.push(MissionResearchTrfTable { + clan_index: ai_state.clan_index, + path: path.as_str().to_string(), + document, + }); + } + Ok(tables) +} + fn load_mission_scripts( vfs: &Arc<dyn Vfs>, mission: &MissionDocument, @@ -6340,6 +7051,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") { @@ -6353,6 +7112,7 @@ fn load_mission_scripts( path: normalized.as_str().to_string(), message: source.to_string(), })?; + let formula_table = load_script_formula_table(vfs, &base_path)?; if first_base_path.is_none() { first_base_path = Some(base_path.clone()); } @@ -6361,6 +7121,7 @@ fn load_mission_scripts( base_path, script_path: normalized.as_str().to_string(), package, + formula_table, init_execution: MissionScriptInitExecution::Missing, behavior_warm_start: None, }); @@ -6372,7 +7133,7 @@ fn load_mission_scripts( let (init_states, varset_states) = if asset_scope == MissionAssetScope::Full { (Vec::new(), Vec::new()) } else if let Some(varset) = &varset { - let init_states = resolve_handler19_init_states( + let init_states = resolve_init_states( &mission .clans .iter() @@ -6389,6 +7150,7 @@ fn load_mission_scripts( }; Ok(LoadedMissionScripts { bundles, + ai_startup_states, varset, init_states, varset_states, @@ -6417,7 +7179,7 @@ fn start_mission_scripts( )); if let Some(varset) = scripts.varset.as_ref() { - init_states.extend(resolve_handler19_init_states( + init_states.extend(resolve_init_states( &clan_anchors, std::slice::from_mut(bundle), varset, @@ -6459,12 +7221,12 @@ fn materialize_script_varset_states( return Err(EngineError::ScriptInit { clan_index: bundle.clan_index, message: format!( - "Handler(19) write index {} is outside the instantiated varset", + "Init write index {} is outside the instantiated varset", write.index ), }); }; - *value = VarSetDefault::Dword(write.value); + *value = write.value; } } Ok(MissionScriptVarSetState { @@ -6475,7 +7237,7 @@ fn materialize_script_varset_states( .collect() } -fn resolve_handler19_init_states( +fn resolve_init_states( clan_anchors: &[[f32; 2]], bundles: &mut [MissionScriptBundle], varset: &MissionScriptVarSet, @@ -6508,15 +7270,15 @@ fn resolve_handler19_init_states( }; match varset .declarations - .resolve_handler19_init(&bundle.package, input) + .resolve_init(&bundle.package, input, bundle.formula_table.as_ref()) .map_err(|source| EngineError::ScriptInit { clan_index: bundle.clan_index, message: source.to_string(), })? { - Handler19InitResolution::Missing => { + ScriptInitResolution::Missing => { bundle.init_execution = MissionScriptInitExecution::Missing; } - Handler19InitResolution::Executed { + ScriptInitResolution::Executed { event_index, event_word, writes, @@ -6531,7 +7293,7 @@ fn resolve_handler19_init_states( }); } } - Handler19InitResolution::Unsupported { + ScriptInitResolution::Unsupported { event_index, instruction_index, error, @@ -6542,7 +7304,7 @@ fn resolve_handler19_init_states( error, }; } - Handler19InitResolution::Unsupported { + ScriptInitResolution::Unsupported { event_index, instruction_index, error, @@ -6631,9 +7393,983 @@ 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_assets::PreparedControlPointList; use fparkan_vfs::{DirectoryVfs, MemoryVfs, OverlayVfs, VfsEntry, VfsMetadata}; use std::path::{Path, PathBuf}; + #[test] + fn factory2_selector6_reads_fresh_constructor_zero() { + let device = synthetic_effective_control_device(2, 2, 0, None, None); + assert!(device.descriptor.fixed_words[27..43] + .iter() + .all(|word| *word == 0)); + let table = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + device, + ])); + + assert_eq!( + query_control_device_table_selector(&table, 0, 6), + Ok(Some(0.0)) + ); + } + + #[test] + fn factory2_selector6_returns_the_stored_float_word() { + let mut device = synthetic_effective_control_device(2, 2, 0, None, None); + device.device174_bits = Some(6.25_f32.to_bits()); + let table = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + device, + ])); + + assert_eq!( + query_control_device_table_selector(&table, 0, 6), + Ok(Some(6.25)) + ); + } + + #[test] + fn factory2_selector6_preserves_stored_negative_zero() { + let mut device = synthetic_effective_control_device(2, 2, 0, None, None); + device.device174_bits = Some((-0.0_f32).to_bits()); + let table = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + device, + ])); + + assert_eq!( + query_control_device_table_selector(&table, 0, 6) + .expect("negative zero is a valid native float") + .expect("tag 2 has a selector-6 value") + .to_bits(), + (-0.0_f32).to_bits() + ); + } + + #[test] + fn factory2_selector6_reports_missing_wrong_factory_nonfinite_and_subnormal_values() { + let table_for = |device| { + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![device])) + }; + + let mut missing = synthetic_effective_control_device(2, 2, 0, None, None); + missing.device174_bits = None; + assert_eq!( + query_control_device_table_selector(&table_for(missing), 0, 6), + Err(ControlResourceAccessError::UnsupportedField { + factory_tag: 2, + field: "+0x174", + }) + ); + + let mut wrong_factory = synthetic_effective_control_device(5, 2, 0, None, None); + wrong_factory.device174_bits = Some(1.0_f32.to_bits()); + assert_eq!( + query_control_device_table_selector(&table_for(wrong_factory), 0, 6), + Err(ControlResourceAccessError::UnsupportedFactoryTag { factory_tag: 5 }) + ); + + let mut nonfinite = synthetic_effective_control_device(2, 2, 0, None, None); + nonfinite.device174_bits = Some(f32::INFINITY.to_bits()); + assert_eq!( + query_control_device_table_selector(&table_for(nonfinite), 0, 6), + Err(ControlResourceAccessError::NonFiniteValue { + factory_tag: 2, + field: "+0x174", + }) + ); + + let mut subnormal = synthetic_effective_control_device(2, 2, 0, None, None); + subnormal.device174_bits = Some(1); + assert_eq!( + query_control_device_table_selector(&table_for(subnormal), 0, 6), + Err(ControlResourceAccessError::SubnormalValue { + factory_tag: 2, + field: "+0x174", + }) + ); + } + + #[test] + fn factory2_selector6_returns_no_result_for_a_non_two_current_tag() { + let mut device = synthetic_effective_control_device(5, 5, 0, None, None); + device.device174_bits = None; + let table = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + device, + ])); + + assert_eq!(query_control_device_table_selector(&table, 0, 6), Ok(None)); + } + + #[test] + fn factory2_pair_updates_keep_append_and_into_order_and_native_miss_bits() { + let mut root_record = synthetic_control_record(2, 0, 0, None); + set_factory2_pair(&mut root_record, b"objects.rlb", b"root"); + let mut same_record = synthetic_control_record(2, 0, 0, None); + set_factory2_pair(&mut same_record, b"OBJECTS.RLB", b"ROOT"); + let mut changed_record = synthetic_control_record(2, 0, 0, None); + set_factory2_pair(&mut changed_record, b"objects.rlb", b"second"); + let mut empty_record = synthetic_control_record(2, 0, 0, None); + set_factory2_pair(&mut empty_record, b"objects.rlb", b""); + let controls = [root_record, same_record, changed_record, empty_record] + .into_iter() + .enumerate() + .map(|(index, record)| { + Some(synthetic_component_control( + index, + vec![record], + PreparedControlEffectSection::Parsed(Vec::new()), + )) + }) + .collect(); + let (draft, assets, prototypes) = synthetic_control_fixture( + &[ + None, + Some(PROTOTYPE_TYPE_INTO), + Some(PROTOTYPE_TYPE_INTO), + Some(PROTOTYPE_TYPE_INTO), + ], + &[1, 1, 1, 1], + &[3, 0, 0, 0], + &[0, 0, 0, 0], + &[1, 0, 0, 0], + controls, + ); + let mut resolved = Vec::new(); + let mut resolver = |pair: &Factory2StringPair| { + resolved.push((pair.archive.clone(), pair.name.clone())); + match pair.name.as_str() { + "root" => Factory2PairOutcome::Value(6.25_f32.to_bits()), + "second" => Factory2PairOutcome::Value(9.5_f32.to_bits()), + _ => Factory2PairOutcome::Unrepresentable, + } + }; + + let projection = build_control_device_projection_with_class( + 0, + &draft, + &assets, + Some(&(0..prototypes.len())), + &prototypes, + None, + &mut resolver, + ) + .expect("factory-2 append plus three INTO updates"); + + assert_eq!( + resolved, + [ + ("objects.rlb".to_string(), "root".to_string()), + ("objects.rlb".to_string(), "second".to_string()) + ], + "same pair skips recalculation and an empty name is a native miss" + ); + assert_eq!(projection.devices.len(), 1); + assert_eq!(projection.devices[0].factory_tag, 2); + assert_eq!( + projection.devices[0].device174_bits, + Some(9.5_f32.to_bits()) + ); + assert_eq!( + projection.devices[0] + .descriptor_provenance + .unit_component_index, + 3, + "INTO retains the last raw descriptor even when selector state is preserved" + ); + } + + #[test] + fn factory2_pair_parser_validates_nul_and_ascii_before_it() { + let mut record = synthetic_control_record(2, 0, 0, None); + set_factory2_pair(&mut record, b"objects.rlb", b"name"); + assert_eq!( + factory2_pair_from_record(&record).map(|pair| (pair.archive, pair.name)), + Some(("objects.rlb".to_string(), "name".to_string())) + ); + + record.fixed_words[27..35].fill(u32::from_le_bytes(*b"ABCD")); + assert!(factory2_pair_from_record(&record).is_none(), "missing NUL"); + + record.fixed_words[27] = u32::from_le_bytes([0xff, 0, 0, 0]); + assert!( + factory2_pair_from_record(&record).is_none(), + "non-ASCII byte" + ); + + record.fixed_words[27] = u32::from_le_bytes([b'a', 0, b'b', 0]); + let first = factory2_pair_from_record(&record).expect("NUL ends the archive string"); + assert_eq!(first.archive, "a"); + let mut different_padding = record.clone(); + different_padding.fixed_words[27] = u32::from_le_bytes([b'A', 0, b'c', 0]); + let second = factory2_pair_from_record(&different_padding) + .expect("native C-string comparison ignores bytes after NUL"); + assert!(first.matches_ascii_case(&second)); + } + + #[test] + fn factory2_unrepresentable_pair_clears_only_the_available_projection() { + let mut device = synthetic_effective_control_device(2, 2, 0, None, None); + device.device174_bits = Some(6.25_f32.to_bits()); + let mut replacement = synthetic_control_record(2, 0, 0, None); + set_factory2_pair(&mut device.descriptor, b"objects.rlb", b"known"); + set_factory2_pair(&mut replacement, b"objects.rlb", b"unknown"); + let previous_pair = factory2_pair_from_record(&device.descriptor); + let mut resolver = |_: &Factory2StringPair| Factory2PairOutcome::Unrepresentable; + apply_factory2_pair_update(&mut device, previous_pair, &replacement, &mut resolver); + + assert_eq!(device.device174_bits, None); + assert_eq!( + control_device_float(&device, device.device174_bits, "+0x174"), + Err(ControlResourceAccessError::UnsupportedField { + factory_tag: 2, + field: "+0x174", + }) + ); + } + + #[test] + fn factory2_pair_cache_key_folds_ascii_case() { + let upper = Factory2StringPair { + archive: "OBJECTS.RLB".to_string(), + name: "FOREIGN".to_string(), + }; + let lower = Factory2StringPair { + archive: "objects.rlb".to_string(), + name: "foreign".to_string(), + }; + + assert_eq!(upper.cache_key(), lower.cache_key()); + } + + #[test] + fn factory2_ndp_maximum_uses_ordered_f32_sum_and_accepts_empty_tables() { + let mut rows = [0.0_f32; 3].map(|max_volume| PreparedControlNodeDataRow { + raw_flags: 0, + max_volume, + mobility_per_volume: 0.0, + effect: PreparedControlResourcePair { + archive: [0; 32], + name: [0; 32], + }, + }); + rows[0].max_volume = 16_777_216.0; + rows[1].max_volume = 1.0; + rows[2].max_volume = -16_777_216.0; + + assert_eq!( + factory2_node_data_maximum_bits(&rows), + Ok(0.0_f32.to_bits()) + ); + assert_eq!(factory2_node_data_maximum_bits(&[]), Ok(0.0_f32.to_bits())); + assert_eq!( + factory2_node_data_maximum_bits(&[PreparedControlNodeDataRow { + max_volume: f32::from_bits(1), + ..rows[0].clone() + }]), + Err("NDP Max float") + ); + } + + fn factory2_test_nres(entries: &[(&[u8], u32, u32, &[u8])]) -> Vec<u8> { + let mut bytes = vec![0; 16]; + let mut offsets = Vec::with_capacity(entries.len()); + for (_, _, _, payload) in entries { + offsets.push(u32::try_from(bytes.len()).expect("payload offset fits u32")); + bytes.extend_from_slice(payload); + bytes.resize(bytes.len() + (8 - bytes.len() % 8) % 8, 0); + } + let mut sorted_indices = (0..entries.len()).collect::<Vec<_>>(); + sorted_indices.sort_by(|left, right| entries[*left].0.cmp(entries[*right].0)); + let mut order = vec![0_u32; entries.len()]; + for (rank, index) in sorted_indices.into_iter().enumerate() { + order[index] = u32::try_from(rank).expect("entry rank fits u32"); + } + for (index, (name, type_id, attr3, payload)) in entries.iter().enumerate() { + assert!(name.len() < 36, "NRes fixture name needs a bounded NUL"); + for word in [ + *type_id, + 0, + 0, + u32::try_from(payload.len()).expect("payload length fits u32"), + *attr3, + ] { + bytes.extend_from_slice(&word.to_le_bytes()); + } + let mut name_raw = [0; 36]; + name_raw[..name.len()].copy_from_slice(name); + bytes.extend_from_slice(&name_raw); + bytes.extend_from_slice(&offsets[index].to_le_bytes()); + bytes.extend_from_slice(&order[index].to_le_bytes()); + } + bytes[0..4].copy_from_slice(b"NRes"); + bytes[4..8].copy_from_slice(&0x100_u32.to_le_bytes()); + bytes[8..12].copy_from_slice( + &u32::try_from(entries.len()) + .expect("entry count fits u32") + .to_le_bytes(), + ); + let total_size = u32::try_from(bytes.len()).expect("archive size fits u32"); + bytes[12..16].copy_from_slice(&total_size.to_le_bytes()); + bytes + } + + fn factory2_test_object_refs(items: &[(&[u8], &[u8])]) -> Vec<u8> { + let mut bytes = Vec::with_capacity(items.len() * 64); + for (archive, name) in items { + assert!(archive.len() < 32 && name.len() < 32); + let mut record = [0; 64]; + record[..archive.len()].copy_from_slice(archive); + record[32..32 + name.len()].copy_from_slice(name); + bytes.extend_from_slice(&record); + } + bytes + } + + fn factory2_test_control_payload( + pair: Option<(&[u8], &[u8])>, + effect_opcode: Option<u32>, + ) -> Vec<u8> { + let mut payload = vec![0; 5 * 4 + 108]; + if let Some((archive, name)) = pair { + payload[12..16].copy_from_slice(&1_u32.to_le_bytes()); + let record_start = payload.len(); + payload.resize(record_start + 0xb0 + 8, 0); + payload[record_start..record_start + 4].copy_from_slice(&2_u32.to_le_bytes()); + let archive_start = record_start + 27 * 4; + payload[archive_start..archive_start + archive.len()].copy_from_slice(archive); + let name_start = record_start + 35 * 4; + payload[name_start..name_start + name.len()].copy_from_slice(name); + } + if let Some(opcode) = effect_opcode { + payload[16..20].copy_from_slice(&1_u32.to_le_bytes()); + let group_start = payload.len(); + payload.resize(group_start + 0x54 + 4 + 0x64, 0); + payload[group_start + 0x54..group_start + 0x58].copy_from_slice(&1_u32.to_le_bytes()); + payload[group_start + 0x58 + 0x0c..group_start + 0x58 + 0x10] + .copy_from_slice(&opcode.to_le_bytes()); + } + payload + } + + fn factory2_test_mesh_payload() -> Vec<u8> { + let res2 = vec![0; 0x8c]; + factory2_test_nres(&[ + (b"Res1", 1, 38, &[]), + (b"Res13", 13, 0, &[]), + (b"Res2", 2, 0, &res2), + (b"Res3", 3, 0, &[]), + (b"Res6", 6, 0, &[]), + ]) + } + + fn factory2_test_mat0() -> Vec<u8> { + let mut bytes = vec![0; 4 + 34]; + bytes[0..2].copy_from_slice(&1_u16.to_le_bytes()); + bytes[22..27].copy_from_slice(b"TEX_A"); + bytes + } + + fn factory2_test_texm() -> Vec<u8> { + let mut bytes = Vec::new(); + for word in [0x6d78_6554_u32, 1, 1, 1, 0, 0, 0, 565] { + bytes.extend_from_slice(&word.to_le_bytes()); + } + bytes.extend_from_slice(&0xffff_u16.to_le_bytes()); + bytes + } + + fn factory2_test_resolver_archives( + foreign_control: &[u8], + ndp_maxima: &[f32], + ) -> Vec<(&'static str, Vec<u8>)> { + let host_refs = factory2_test_object_refs(&[ + (b"static.rlb", b"model.msh"), + (b"controls.rlb", b"host.ctl"), + ]); + let foreign_refs = factory2_test_object_refs(&[ + (b"static.rlb", b"model.msh"), + (b"controls.rlb", b"foreign.ctl"), + (b"providers.rlb", b"foreign.ndp"), + ]); + let objects = factory2_test_nres(&[ + (b"foreign", CONTROL_CLASS_SOURCE_BULL, 0, &foreign_refs), + (b"host", CONTROL_CLASS_SOURCE_BULL, 0, &host_refs), + ]); + let mesh = factory2_test_mesh_payload(); + let static_archive = factory2_test_nres(&[ + (b"model.msh", 0, 0, &mesh), + (b"model.wea", 0x5241_4557, 0, b"1\n0 MAT_A\n"), + ]); + let host_control = factory2_test_control_payload(Some((b"objects.rlb", b"foreign")), None); + let control_archive = factory2_test_nres(&[ + (b"foreign.ctl", 0, 0, foreign_control), + (b"host.ctl", 0, 0, &host_control), + ]); + let mut node_data = vec![0; 4 + ndp_maxima.len() * 76]; + node_data[..4].copy_from_slice( + &u32::try_from(ndp_maxima.len()) + .expect("NDP count fits u32") + .to_le_bytes(), + ); + for (index, maximum) in ndp_maxima.iter().enumerate() { + let start = 4 + index * 76; + node_data[start + 4..start + 8].copy_from_slice(&maximum.to_bits().to_le_bytes()); + } + let node_data_archive = factory2_test_nres(&[(b"foreign.ndp", 0, 0, &node_data)]); + let mat0 = factory2_test_mat0(); + let material_archive = factory2_test_nres(&[(b"MAT_A", 0x3054_414D, 0, &mat0)]); + let texm = factory2_test_texm(); + let texture_archive = factory2_test_nres(&[(b"TEX_A", 0, 0, &texm)]); + vec![ + ("objects.rlb", objects), + ("static.rlb", static_archive), + ("controls.rlb", control_archive), + ("providers.rlb", node_data_archive), + ("material.lib", material_archive), + ("textures.lib", texture_archive), + ] + } + + fn factory2_test_resolver_vfs(foreign_control: &[u8]) -> Arc<dyn Vfs> { + factory2_test_resolver_vfs_with_maxima(foreign_control, &[]) + } + + fn factory2_test_resolver_vfs_with_maxima( + foreign_control: &[u8], + ndp_maxima: &[f32], + ) -> Arc<dyn Vfs> { + let mut files = MemoryVfs::default(); + for (path, bytes) in factory2_test_resolver_archives(foreign_control, ndp_maxima) { + files.insert( + normalize_engine_path("factory-2 resolver fixture", path).expect("archive path"), + Arc::from(bytes.into_boxed_slice()), + ); + } + Arc::new(files) + } + + fn factory2_test_fxid_payload(opcodes: &[u32]) -> Vec<u8> { + let command_size = |opcode| match opcode { + 1 => 224, + 2 => 148, + 3 => 200, + 4 => 204, + 5 => 112, + 6 => 4, + 7 | 9 | 10 => 208, + 8 => 248, + _ => panic!("fixture opcode {opcode} is not framed"), + }; + let mut payload = vec![0; 60]; + payload[..4].copy_from_slice( + &u32::try_from(opcodes.len()) + .expect("FX command count fits u32") + .to_le_bytes(), + ); + for opcode in opcodes { + let start = payload.len(); + payload.resize(start + command_size(*opcode), 0); + payload[start..start + 4].copy_from_slice(&opcode.to_le_bytes()); + } + payload + } + + fn factory2_test_fxid_manager( + entries: &[(&[u8], u32, &[u8])], + ) -> AssetManager<CachedResourceRepository> { + let archive_entries = entries + .iter() + .map(|(name, type_id, payload)| (*name, *type_id, 0, *payload)) + .collect::<Vec<_>>(); + let archive = factory2_test_nres(&archive_entries); + let mut files = MemoryVfs::default(); + files.insert( + normalize_engine_path("factory-2 FX fixture", "effects.rlb").expect("FX archive path"), + Arc::from(archive.into_boxed_slice()), + ); + let vfs: Arc<dyn Vfs> = Arc::new(files); + AssetManager::new(CachedResourceRepository::new(vfs)) + } + + fn factory2_test_effect_resource_pair( + archive: &[u8], + name: &[u8], + ) -> PreparedControlResourcePair { + fn fixed_string(raw: &[u8]) -> [u8; 32] { + assert!(raw.len() <= 32); + let mut bytes = [0; 32]; + bytes[..raw.len()].copy_from_slice(raw); + bytes + } + + PreparedControlResourcePair { + archive: fixed_string(archive), + name: fixed_string(name), + } + } + + fn factory2_test_effect_row( + table_index: usize, + row_index: usize, + archive: &[u8], + name: &[u8], + point_offsets: [u32; 3], + ) -> PreparedControlEffectRow { + PreparedControlEffectRow { + table_index, + row_index, + raw_condition: [0; 3], + point_offsets, + selector: 0, + resource: factory2_test_effect_resource_pair(archive, name), + } + } + + fn factory2_test_control_effects( + rows: Vec<PreparedControlEffectRow>, + initial_effect_group: Option<usize>, + point_count: Option<usize>, + ) -> PreparedComponentControl { + PreparedComponentControl { + source: synthetic_resource_key(940), + native_profile_words: [0; 27], + model_node_count: 3, + bindings: Vec::new(), + timeline_rows: Vec::new(), + typed_records: Vec::new(), + node_data: None, + effect_section: PreparedControlEffectSection::Parsed(rows), + initial_effect_group, + point_list: point_count.map(|count| PreparedControlPointList { + source: synthetic_resource_key(941), + points: (0..count) + .map(|_| fparkan_assets::PreparedControlPoint { + raw_flags: 0, + raw_signed_field_04: 0, + raw_signed_field_08: 0, + position: [0.0; 3], + direction: [0.0; 3], + name: [0; 32], + }) + .collect(), + }), + } + } + + fn factory2_test_full_load_vfs(foreign_control: &[u8]) -> Arc<dyn Vfs> { + factory2_test_full_load_vfs_with_maxima(foreign_control, &[]) + } + + fn factory2_test_full_load_vfs_with_maxima( + foreign_control: &[u8], + ndp_maxima: &[f32], + ) -> Arc<dyn Vfs> { + 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); + } + fn push_f32(bytes: &mut Vec<u8>, value: f32) { + bytes.extend_from_slice(&value.to_le_bytes()); + } + + let mut mission = Vec::new(); + push_u32(&mut mission, 1); + push_u32(&mut mission, 0); + push_u32(&mut mission, 6); + push_u32(&mut mission, 1); + push_lp(&mut mission, b"Synthetic clan"); + push_u32(&mut mission, u32::MAX); + push_f32(&mut mission, 0.0); + push_f32(&mut mission, 0.0); + push_u32(&mut mission, 1); + push_lp(&mut mission, b"MISSIONS/SCRIPTS/shared"); + push_u32(&mut mission, 0); + push_lp(&mut mission, b""); + push_u32(&mut mission, 0); + push_u32(&mut mission, 0); + push_u32(&mut mission, 10); + push_u32(&mut mission, 1); + push_u32(&mut mission, 1); + push_u32(&mut mission, 0x8000_0002); + push_lp(&mut mission, b"UNITS/FACTORY2/host.dat"); + push_u32(&mut mission, 0); + push_u32(&mut mission, 0); + for value in [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0] { + push_f32(&mut mission, value); + } + push_lp(&mut mission, b"host"); + push_u32(&mut mission, 0); + push_u32(&mut mission, 0); + push_u32(&mut mission, 0); + push_u32(&mut mission, 1); + push_u32(&mut mission, 0); + push_lp(&mut mission, b"DATA\\MAPS\\SYNTHETIC\\land"); + push_u32(&mut mission, 0); + push_lp(&mut mission, b""); + push_u32(&mut mission, 1); + push_u32(&mut mission, 0); + + let mut unit_dat = vec![0; 8 + 112]; + unit_dat[4..8].copy_from_slice(&0_u32.to_le_bytes()); + unit_dat[8..19].copy_from_slice(b"objects.rlb"); + unit_dat[40..44].copy_from_slice(b"host"); + unit_dat[76..80].copy_from_slice(&0_i32.to_le_bytes()); + + let mut files = MemoryVfs::default(); + for (path, bytes) in [ + ("MISSIONS/TEST/data.tma", mission), + ("UNITS/FACTORY2/host.dat", unit_dat), + ("DATA/MAPS/SYNTHETIC/Land.msh", empty_land_mesh()), + ("DATA/MAPS/SYNTHETIC/Land.map", minimal_land_map()), + ( + "MISSIONS/SCRIPTS/shared.scr", + encode_script_package(&ScriptPackage { + opcode_handler_count: 73, + events: Vec::new(), + trailing_bytes: Vec::new(), + raw: Arc::from(&b""[..]), + }), + ), + ( + "MISSIONS/SCRIPTS/shared.var", + b"VAR(DWORD, value, 0)\n".to_vec(), + ), + ] { + files.insert( + normalize_engine_path("factory-2 full-load fixture", path).expect("test path"), + Arc::from(bytes.into_boxed_slice()), + ); + } + for (path, bytes) in factory2_test_resolver_archives(foreign_control, ndp_maxima) { + files.insert( + normalize_engine_path("factory-2 full-load archive", path).expect("archive path"), + Arc::from(bytes.into_boxed_slice()), + ); + } + files.insert( + normalize_engine_path("factory-2 full-load BuildDat", "BuildDat.lst") + .expect("BuildDat path"), + Arc::from(&b""[..]), + ); + Arc::new(files) + } + + #[test] + fn factory2_resolver_accepts_empty_effects_bull_and_ordered_ndp_rows() { + let control = factory2_test_control_payload(None, None); + let vfs = + factory2_test_resolver_vfs_with_maxima(&control, &[16_777_216.0, 1.0, -16_777_216.0]); + let manager = AssetManager::new(CachedResourceRepository::new(vfs.clone())); + let pair = Factory2StringPair { + archive: "OBJECTS.RLB".to_string(), + name: "FOREIGN".to_string(), + }; + assert_eq!( + resolve_factory2_pair_from_assets(&manager, vfs.as_ref(), &pair) + .expect("valid foreign BULL projection"), + Factory2PairOutcome::Value(0.0_f32.to_bits()) + ); + } + + #[test] + fn factory2_fx_guard_accepts_supported_commands_only_from_selected_group() { + let fxid = factory2_test_fxid_payload(&[2, 3, 4, 5, 7, 8, 9]); + let manager = factory2_test_fxid_manager(&[(b"selected", FXID_KIND, &fxid)]); + let mut selected = factory2_test_effect_row(0, 0, b"effects.rlb", b"selected", [0, 1, 2]); + selected.resource.archive[12] = 0xff; + selected.resource.name[9] = 0xfe; + let unselected = factory2_test_effect_row(1, 0, b"missing.rlb", b"missing", [0, 1, 2]); + let control = factory2_test_control_effects(vec![selected, unselected], Some(0), Some(3)); + + assert!( + factory2_selected_fx_resources_are_representable(&manager, &control, &[4]) + .expect("valid selected FX resource") + ); + } + + #[test] + fn factory2_fx_guard_rejects_unknown_types_missing_inputs_and_bad_points() { + for opcode in [1, 6, 10] { + let fxid = factory2_test_fxid_payload(&[opcode]); + let manager = factory2_test_fxid_manager(&[(b"effect", FXID_KIND, &fxid)]); + let row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", [0, 0, 0]); + let control = factory2_test_control_effects(vec![row], Some(0), Some(1)); + assert!( + !factory2_selected_fx_resources_are_representable(&manager, &control, &[4]) + .expect("framed unknown FX command remains unsupported") + ); + } + + let fxid = factory2_test_fxid_payload(&[2]); + let manager = factory2_test_fxid_manager(&[(b"effect", FXID_KIND, &fxid)]); + for (point_offsets, point_count) in [ + ([0, 1, 0], Some(1)), + ([0, u32::MAX, 0], Some(1)), + ([0, 0, 0], None), + ] { + let row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", point_offsets); + let control = factory2_test_control_effects(vec![row], Some(0), point_count); + assert!( + !factory2_selected_fx_resources_are_representable(&manager, &control, &[4]) + .expect("unsupported CPT selector remains unknown") + ); + } + + let missing_entry = factory2_test_fxid_manager(&[]); + let row = factory2_test_effect_row(0, 0, b"effects.rlb", b"absent", [0, 0, 0]); + let control = factory2_test_control_effects(vec![row], Some(0), Some(1)); + assert!( + !factory2_selected_fx_resources_are_representable(&missing_entry, &control, &[4]) + .expect("missing FX entry is not a decode error") + ); + + let row = factory2_test_effect_row(0, 0, b"absent.rlb", b"effect", [0, 0, 0]); + let control = factory2_test_control_effects(vec![row], Some(0), Some(1)); + assert!( + !factory2_selected_fx_resources_are_representable(&manager, &control, &[4]) + .expect("missing FX archive is unsupported input") + ); + + let wrong_type = factory2_test_fxid_manager(&[(b"effect", 0, &fxid)]); + let row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", [0, 0, 0]); + let control = factory2_test_control_effects(vec![row], Some(0), Some(1)); + assert!( + !factory2_selected_fx_resources_are_representable(&wrong_type, &control, &[4]) + .expect("wrong NRes type is unsupported, not malformed FXID") + ); + + for archive in [ + [b'x'; 32], + { + let mut raw = [0; 32]; + raw[0] = 0xff; + raw[1] = 0; + raw + }, + factory2_test_effect_resource_pair(b"../effects.rlb", b"effect").archive, + ] { + let mut row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", [0, 0, 0]); + row.resource.archive = archive; + let control = factory2_test_control_effects(vec![row], Some(0), Some(1)); + assert!( + !factory2_selected_fx_resources_are_representable(&manager, &control, &[4]) + .expect("invalid FX path or string remains unsupported") + ); + } + + let mut row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", [0, 0, 0]); + row.resource.name = [b'x'; 32]; + let control = factory2_test_control_effects(vec![row], Some(0), Some(1)); + assert!( + !factory2_selected_fx_resources_are_representable(&manager, &control, &[4]) + .expect("unterminated resource name is unsupported input") + ); + } + + #[test] + fn factory2_fx_guard_fails_on_malformed_recognized_fxid() { + let mut malformed_fxid = vec![0; 64]; + malformed_fxid[..4].copy_from_slice(&1_u32.to_le_bytes()); + malformed_fxid[60..64].copy_from_slice(&2_u32.to_le_bytes()); + let manager = factory2_test_fxid_manager(&[(b"effect", FXID_KIND, &malformed_fxid)]); + let row = factory2_test_effect_row(0, 0, b"effects.rlb", b"effect", [0, 0, 0]); + let control = factory2_test_control_effects(vec![row], Some(0), Some(1)); + + assert!(matches!( + factory2_selected_fx_resources_are_representable(&manager, &control, &[4]), + Err(AssetPreparationError::InvalidPrototype(_)) + )); + } + + #[test] + fn factory2_unsupported_ndp_numeric_projection_keeps_valid_mission_loaded() { + let control = factory2_test_control_payload(None, None); + let request = || MissionRequest { + key: "MISSIONS/TEST/data.tma".to_string(), + mode: MissionMode::SingleMission, + }; + let unsupported_maxima = [ + ("subnormal", vec![f32::from_bits(1)]), + ("nonfinite", vec![f32::NAN]), + ("ordered sum overflow", vec![f32::MAX, f32::MAX]), + ]; + + for (case, maxima) in unsupported_maxima { + let vfs = factory2_test_full_load_vfs_with_maxima(&control, &maxima); + let mut engine = create(EngineServices::new(vfs)).expect("factory-2 test engine"); + load_mission( + &mut engine, + request(), + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, + ) + .unwrap_or_else(|error| { + panic!("{case} resolver input aborted a structurally valid load: {error:?}") + }); + + let table = loaded_mission_control_device_tables(&engine) + .expect("loaded Control tables") + .first() + .expect("synthetic host Control table"); + let devices = match &table.status { + MissionControlDeviceTableStatus::Available(devices) => devices, + MissionControlDeviceTableStatus::Unavailable(reason) => { + panic!("synthetic Control table unavailable for {case}: {reason:?}"); + } + }; + assert_eq!(devices.len(), 1, "{case}"); + assert_eq!( + devices[0].device174_bits, None, + "unsupported {case} must stay unknown rather than become zero or NativeMiss" + ); + assert_eq!( + query_loaded_mission_control_device_selector(&engine, table.handle, 0, 6), + Err(ControlResourceAccessError::UnsupportedField { + factory_tag: 2, + field: "+0x174", + }), + "unsupported {case} remains visible as an unavailable native value" + ); + } + } + + #[test] + fn factory2_resolver_accepts_only_known_initial_effect_opcodes() { + for opcode in [1, 12] { + let control = factory2_test_control_payload(None, Some(opcode)); + let vfs = factory2_test_resolver_vfs(&control); + let manager = AssetManager::new(CachedResourceRepository::new(vfs.clone())); + let pair = Factory2StringPair { + archive: "objects.rlb".to_string(), + name: "foreign".to_string(), + }; + + assert_eq!( + resolve_factory2_pair_from_assets(&manager, vfs.as_ref(), &pair) + .expect("complete known opcode group"), + Factory2PairOutcome::Value(0.0_f32.to_bits()), + "initial opcode {opcode} is covered" + ); + } + + let control = factory2_test_control_payload(None, Some(4)); + let vfs = factory2_test_resolver_vfs(&control); + let manager = AssetManager::new(CachedResourceRepository::new(vfs.clone())); + let pair = Factory2StringPair { + archive: "objects.rlb".to_string(), + name: "foreign".to_string(), + }; + assert_eq!( + resolve_factory2_pair_from_assets(&manager, vfs.as_ref(), &pair) + .expect("opcode 4 remains representable as an unknown shape"), + Factory2PairOutcome::Unrepresentable + ); + } + + #[test] + fn factory2_resolver_keeps_unknown_and_nonbare_pairs_unrepresentable() { + let vfs = factory2_test_resolver_vfs(&factory2_test_control_payload(None, None)); + let manager = AssetManager::new(CachedResourceRepository::new(vfs.clone())); + for pair in [ + Factory2StringPair { + archive: "static.rlb".to_string(), + name: "foreign".to_string(), + }, + Factory2StringPair { + archive: "objects.rlb".to_string(), + name: "foreign.msh".to_string(), + }, + Factory2StringPair { + archive: "objects.rlb".to_string(), + name: "nested/foreign".to_string(), + }, + Factory2StringPair { + archive: "objects.rlb".to_string(), + name: "missing".to_string(), + }, + ] { + assert_eq!( + resolve_factory2_pair_from_assets(&manager, vfs.as_ref(), &pair) + .expect("unknown mapping does not become a native miss"), + Factory2PairOutcome::Unrepresentable + ); + } + } + + #[test] + fn factory2_foreign_asset_error_keeps_loaded_world_and_rng() { + let valid_vfs = factory2_test_full_load_vfs_with_maxima( + &factory2_test_control_payload(None, None), + &[6.25], + ); + let mut engine = create(EngineServices::new(valid_vfs)).expect("factory-2 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_summary = loaded_mission(&engine).expect("loaded mission").clone(); + assert_ne!(engine.behavior_rng_state, INITIAL_BEHAVIOR_RNG_STATE); + let control_table = + &loaded_mission_control_device_tables(&engine).expect("Control table")[0]; + let MissionControlDeviceTableStatus::Available(devices) = &control_table.status else { + panic!("synthetic host Control table should be available"); + }; + assert_eq!(devices.len(), 1); + assert_eq!( + devices[0].device174_bits, + Some(6.25_f32.to_bits()), + "full-load builder resolves the foreign pair lazily" + ); + let handle = control_table.handle; + let retained_transform = TransformState { + position: [17.0_f32.to_bits(), 29.0_f32.to_bits(), 41.0_f32.to_bits()], + orientation: [0x3f00_0000; 3], + scale: [0x3f80_0000; 3], + }; + set_transform(&mut engine.world, handle, retained_transform).expect("set sentinel pose"); + let previous_rng = engine.behavior_rng_state; + engine.services.vfs = Some(factory2_test_full_load_vfs(&[0, 0, 0])); + + let error = load_mission( + &mut engine, + request(), + MissionStartupInput { + queue_time_ms: 100_200, + research_creation_gate: false, + }, + ) + .expect_err("malformed foreign CTLD aborts staged loading"); + + assert!(matches!( + error, + EngineError::AssetPreparation { + mission, + source: AssetPreparationError::InvalidPrototype(_), + } if mission == "MISSIONS/TEST/data.tma" + )); + assert_eq!(loaded_mission(&engine), Some(&previous_summary)); + assert_eq!(engine.behavior_rng_state, previous_rng); + assert_eq!( + transform_state(&engine.world, handle), + Ok(retained_transform) + ); + } fn basement_draft(header_flags: Option<u32>, raw_after_name: u32) -> MissionObjectDraft { MissionObjectDraft { @@ -6680,6 +8416,21 @@ mod tests { } } + fn set_factory2_pair(record: &mut PreparedControlTypedRecord, archive: &[u8], name: &[u8]) { + fn write_words(words: &mut [u32], value: &[u8]) { + assert!(value.len() < words.len() * 4, "fixed name needs a NUL"); + assert!(value.is_ascii(), "synthetic pair must be ASCII"); + let mut bytes = [0; 32]; + bytes[..value.len()].copy_from_slice(value); + for (word, bytes) in words.iter_mut().zip(bytes.chunks_exact(4)) { + *word = u32::from_le_bytes(bytes.try_into().expect("four-byte word")); + } + } + + write_words(&mut record.fixed_words[27..35], archive); + write_words(&mut record.fixed_words[35..43], name); + } + fn synthetic_component_control( index: usize, typed_records: Vec<PreparedControlTypedRecord>, @@ -6808,8 +8559,10 @@ mod tests { normals: None, uv0: None, uv1: None, + type16_present: false, indices: Vec::new(), batches: Vec::new(), + triangle_descriptors: None, node_names: None, animation: None, } @@ -7042,6 +8795,7 @@ mod tests { multiplier_bits, class9_selector0_scale_bits: class9_scale_bits, tag26_extra_rate_bits: (factory_tag == 26).then_some(0), + device174_bits: (factory_tag == 2).then_some(0), class9_values_bits: (factory_tag == 9).then_some([0x3f80_0000; 6]), class9_deltas_bits: (factory_tag == 9).then_some([0; 6]), native_word_50_bits, @@ -7086,6 +8840,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, @@ -7986,6 +9829,251 @@ mod tests { } #[test] + fn control_resource_selector_15_uses_last_cached_factories_and_current_fields() { + let mut first_class9 = synthetic_effective_control_device( + 9, + 19, + 90.0_f32.to_bits(), + Some(f32::INFINITY.to_bits()), + Some(9.0_f32.to_bits()), + ); + let mut first_class21 = synthetic_effective_control_device( + 21, + 19, + 90.0_f32.to_bits(), + Some(f32::INFINITY.to_bits()), + None, + ); + let mut class9 = synthetic_effective_control_device( + 9, + 5, + 99.0_f32.to_bits(), + Some(f32::NAN.to_bits()), + Some(99.0_f32.to_bits()), + ); + let mut class21 = synthetic_effective_control_device( + 21, + 26, + 90.0_f32.to_bits(), + Some(f32::INFINITY.to_bits()), + None, + ); + first_class9.native_word_50_bits = Some(0); + first_class21.native_word_50_bits = Some(0); + class9.native_word_50_bits = Some(0); + class21.native_word_50_bits = Some(0); + class9.key = u32::MAX; + class21.key = u32::MAX; + class9.coefficient_bits.as_mut().unwrap()[0] = 3.0_f32.to_bits(); + class9.class9_selector0_scale_bits = Some(4.0_f32.to_bits()); + class21.coefficient_bits.as_mut().unwrap()[..6].copy_from_slice(&[ + 1.0_f32.to_bits(), + 2.0_f32.to_bits(), + 3.0_f32.to_bits(), + 4.0_f32.to_bits(), + 5.0_f32.to_bits(), + 6.0_f32.to_bits(), + ]); + + let table = synthetic_control_device_table_with_providers( + MissionControlDeviceTableStatus::Available(vec![ + first_class9, + first_class21, + class9, + class21, + ]), + MissionControlNodeProviderTableStatus::Unavailable( + MissionControlNodeProviderTableUnavailableReason::MissingNodeData { + unit_component_index: 0, + source: None, + }, + ), + ); + + assert_eq!( + query_control_device_table_resource(&table, 15) + .expect("selector 15 uses current cached getter values") + .map(f32::to_bits), + Some(252.0_f32.to_bits()) + ); + } + + #[test] + fn control_resource_selector_15_preserves_descending_binary32_accumulation() { + let mut class9 = synthetic_effective_control_device( + 9, + 19, + 1.0_f32.to_bits(), + None, + Some(1.0_f32.to_bits()), + ); + let mut class21 = synthetic_effective_control_device(21, 19, 0, None, None); + class9.coefficient_bits.as_mut().unwrap()[0] = 1.0_f32.to_bits(); + class21.coefficient_bits.as_mut().unwrap()[..6].copy_from_slice(&[ + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + 0.0_f32.to_bits(), + (-16_777_216.0_f32).to_bits(), + 16_777_216.0_f32.to_bits(), + 1.0_f32.to_bits(), + ]); + let forward = class21.coefficient_bits.unwrap()[..6] + .iter() + .map(|bits| f32::from_bits(*bits)) + .fold(0.0_f32, |sum, value| sum + value); + assert_eq!(forward.to_bits(), 1.0_f32.to_bits()); + + let table = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9.clone(), + class21.clone(), + ])); + assert_eq!( + query_control_device_table_resource(&table, 15) + .expect("descending native order") + .map(f32::to_bits), + Some(0.0_f32.to_bits()) + ); + + class21.coefficient_bits.as_mut().unwrap()[..6].fill((-0.0_f32).to_bits()); + let negative_zero = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9, class21, + ])); + assert_eq!( + query_control_device_table_resource(&negative_zero, 15) + .expect("negative zero is retained") + .map(f32::to_bits), + Some((-0.0_f32).to_bits()) + ); + } + + #[test] + fn control_resource_selector_15_returns_no_result_without_both_cached_factories() { + let empty = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(Vec::new())); + assert_eq!(query_control_device_table_resource(&empty, 15), Ok(None)); + + let class9_only = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + synthetic_effective_control_device(9, 35, 1, None, None), + ])); + assert_eq!( + query_control_device_table_resource(&class9_only, 15), + Ok(None) + ); + + let mut class21 = synthetic_effective_control_device(21, 35, 1, None, None); + class21.coefficient_bits = None; + let class21_only = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class21, + ])); + assert_eq!( + query_control_device_table_resource(&class21_only, 15), + Ok(None) + ); + } + + #[test] + fn control_resource_selector_15_reports_missing_and_unsupported_inputs() { + let mut class9 = synthetic_effective_control_device( + 9, + 19, + 1.0_f32.to_bits(), + None, + Some(1.0_f32.to_bits()), + ); + let mut class21 = synthetic_effective_control_device(21, 19, 1.0_f32.to_bits(), None, None); + class21.coefficient_bits = None; + let missing_coefficients = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9.clone(), + class21.clone(), + ])); + assert_eq!( + query_control_device_table_resource(&missing_coefficients, 15), + Err(ControlResourceAccessError::UnsupportedField { + factory_tag: 21, + field: "coefficient table", + }) + ); + + class21.coefficient_bits = Some([1.0_f32.to_bits(); 16]); + class9.coefficient_bits = None; + let missing_class9_coefficient = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9.clone(), + class21.clone(), + ])); + assert_eq!( + query_control_device_table_resource(&missing_class9_coefficient, 15), + Err(ControlResourceAccessError::UnsupportedField { + factory_tag: 9, + field: "coefficient", + }) + ); + + class9.coefficient_bits = Some([1.0_f32.to_bits(); 16]); + class9.class9_selector0_scale_bits = None; + let missing_scale = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9.clone(), + class21.clone(), + ])); + assert_eq!( + query_control_device_table_resource(&missing_scale, 15), + Err(ControlResourceAccessError::UnsupportedField { + factory_tag: 9, + field: "class-9 selector-0 scale", + }) + ); + + class9.class9_selector0_scale_bits = Some(1.0_f32.to_bits()); + class21.coefficient_bits.as_mut().unwrap()[5] = f32::INFINITY.to_bits(); + let nonfinite = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9.clone(), + class21.clone(), + ])); + assert_eq!( + query_control_device_table_resource(&nonfinite, 15), + Err(ControlResourceAccessError::NonFiniteValue { + factory_tag: 21, + field: "coefficient", + }) + ); + + class21.coefficient_bits.as_mut().unwrap()[5] = 1; + let subnormal = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9.clone(), + class21.clone(), + ])); + assert_eq!( + query_control_device_table_resource(&subnormal, 15), + Err(ControlResourceAccessError::SubnormalValue { + factory_tag: 21, + field: "coefficient", + }) + ); + + class21.coefficient_bits.as_mut().unwrap()[5] = f32::MAX.to_bits(); + class21.coefficient_bits.as_mut().unwrap()[4] = f32::MAX.to_bits(); + let overflowing_sum = + synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ + class9, class21, + ])); + assert_eq!( + query_control_device_table_resource(&overflowing_sum, 15), + Err(ControlResourceAccessError::UnsupportedFloatRange { + factory_tag: Some(21), + field: "sum", + }) + ); + } + + #[test] fn control_resource_setter_updates_matching_rows_atomically_and_round_trips() { let mut table = synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![ @@ -9621,11 +11709,13 @@ mod tests { trailing_bytes: Vec::new(), raw: Arc::from(&b""[..]), }, + formula_table: None, init_execution: MissionScriptInitExecution::Missing, behavior_warm_start: None, }; 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() }; @@ -9637,7 +11727,7 @@ mod tests { assert!(matches!( error, EngineError::ScriptInitUnsupported { - source: Handler19ResolveError::UnsupportedSelector { + source: ScriptInitResolveError::UnsupportedSelector { found: fparkan_script::ScriptDispatchSelector::Handler(18), }, .. @@ -9655,6 +11745,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"); @@ -9665,7 +11942,10 @@ mod tests { key: "MISSIONS/Autodemo.00/data.tma".to_string(), mode: MissionMode::SingleMission, }, - 0, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: false, + }, ) .expect_err("missing VFS"); assert!(matches!(err, EngineError::MissingVfs)); @@ -10032,6 +12312,8 @@ mod tests { [7.0_f32.to_bits(), 11.0_f32.to_bits(), 0, 0, 0]; draft.scale = [2.0, 3.0, 5.0]; + let mut resolve_factory2_pair = + |_: &Factory2StringPair| Factory2PairOutcome::Unrepresentable; let projection = build_control_device_projection_with_class( 0, &draft, @@ -10039,6 +12321,7 @@ mod tests { Some(&(0..prototypes.len())), &prototypes, Some(native_class), + &mut resolve_factory2_pair, ) .expect("base device and FAC0 projections"); let profile = projection @@ -11266,6 +13549,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)], @@ -11511,7 +13859,10 @@ mod tests { key: "MISSIONS/TEST/MINE/data.tma".to_string(), mode: MissionMode::SingleMission, }, - 0, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: false, + }, ) .expect("full normal mission load"); assert_eq!(loaded.object_count, 1); @@ -12207,6 +14558,392 @@ mod tests { assert_eq!(rows[stage_key as usize].initial_amount_bits, stage_initial); } + fn test_push_u32(bytes: &mut Vec<u8>, value: u32) { + bytes.extend_from_slice(&value.to_le_bytes()); + } + + fn test_push_i32(bytes: &mut Vec<u8>, value: i32) { + bytes.extend_from_slice(&value.to_le_bytes()); + } + + fn build_nres(entries: &[(u32, u32, u32, u32, Vec<u8>)]) -> Vec<u8> { + let mut bytes = vec![0; 16]; + let mut offsets = Vec::with_capacity(entries.len()); + for (_, _, _, _, payload) in entries { + offsets.push(u32::try_from(bytes.len()).expect("offset fits u32")); + bytes.extend_from_slice(payload); + let padding = (8 - bytes.len() % 8) % 8; + bytes.resize(bytes.len() + padding, 0); + } + for (index, (type_id, attr1, attr2, attr3, payload)) in entries.iter().enumerate() { + test_push_u32(&mut bytes, *type_id); + test_push_u32(&mut bytes, *attr1); + test_push_u32(&mut bytes, *attr2); + test_push_u32( + &mut bytes, + u32::try_from(payload.len()).expect("payload fits u32"), + ); + test_push_u32(&mut bytes, *attr3); + let mut name = [0; 36]; + name[..4].copy_from_slice(b"Test"); + bytes.extend_from_slice(&name); + test_push_u32(&mut bytes, offsets[index]); + test_push_u32(&mut bytes, u32::try_from(index).expect("index fits u32")); + } + bytes[..4].copy_from_slice(b"NRes"); + bytes[4..8].copy_from_slice(&0x100_u32.to_le_bytes()); + let count = u32::try_from(entries.len()).expect("count fits u32"); + bytes[8..12].copy_from_slice(&count.to_le_bytes()); + let size = u32::try_from(bytes.len()).expect("size fits u32"); + bytes[12..16].copy_from_slice(&size.to_le_bytes()); + bytes + } + + fn empty_land_mesh() -> Vec<u8> { + build_nres(&[ + (1, 0, 0, 38, Vec::new()), + (2, 0, 0, 0, vec![0; 0x8c]), + (3, 0, 0, 12, Vec::new()), + (4, 0, 0, 4, Vec::new()), + (5, 0, 0, 4, Vec::new()), + (18, 0, 0, 4, Vec::new()), + (14, 0, 0, 4, Vec::new()), + (11, 0, 0, 4, Vec::new()), + (21, 0, 0, 28, Vec::new()), + ]) + } + + fn minimal_land_map() -> Vec<u8> { + let mut payload = Vec::new(); + for value in [0.0_f32, 0.0, 0.0, 0.0, 2.0, 0.0, 1.0, 0.0] { + payload.extend_from_slice(&value.to_le_bytes()); + } + for value in [0, 0, 7, 0, 2, 0] { + test_push_u32(&mut payload, value); + } + for value in [0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.0] { + payload.extend_from_slice(&value.to_le_bytes()); + } + for _ in 0..2 { + test_push_i32(&mut payload, -1); + test_push_i32(&mut payload, -1); + } + test_push_u32(&mut payload, 1); + test_push_u32(&mut payload, 1); + payload.extend_from_slice(&1_u16.to_le_bytes()); + payload.extend_from_slice(&0_u16.to_le_bytes()); + build_nres(&[(12, 1, 0, 0, payload)]) + } + + #[test] + fn full_load_applies_a_synthetic_numeric_formula_in_init() { + fn push_u32(bytes: &mut Vec<u8>, value: u32) { + bytes.extend_from_slice(&value.to_le_bytes()); + } + + fn push_i32(bytes: &mut Vec<u8>, value: i32) { + bytes.extend_from_slice(&value.to_le_bytes()); + } + + fn push_f32(bytes: &mut Vec<u8>, value: f32) { + 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); + } + + fn synthetic_mission() -> Vec<u8> { + let mut bytes = Vec::new(); + push_u32(&mut bytes, 1); + push_u32(&mut bytes, 0); + push_u32(&mut bytes, 6); + push_u32(&mut bytes, 1); + push_lp(&mut bytes, b"Synthetic"); + push_u32(&mut bytes, u32::MAX); + push_f32(&mut bytes, 12.25); + push_f32(&mut bytes, 34.75); + push_u32(&mut bytes, 1); + push_lp(&mut bytes, b"MISSIONS/SCRIPTS/formula"); + push_i32(&mut bytes, 0); + push_lp(&mut bytes, b""); + push_i32(&mut bytes, 0); + push_u32(&mut bytes, 0); + push_u32(&mut bytes, 10); + push_u32(&mut bytes, 0); + push_lp(&mut bytes, b"DATA\\MAPS\\SYNTHETIC\\land"); + push_u32(&mut bytes, 0); + push_lp(&mut bytes, b""); + push_u32(&mut bytes, 1); + push_u32(&mut bytes, 0); + bytes + } + + let package = ScriptPackage { + opcode_handler_count: 73, + events: vec![fparkan_script::ScriptEvent { + name_len: 4, + name_raw: b"Init\0".to_vec(), + event_word: 0, + instructions: vec![ + ScriptInstruction { + header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX], + references: vec![0, 1, 2], + }, + ScriptInstruction { + header_words: [u32::MAX, 3, u32::MAX, u32::MAX, 6, 0, 0], + references: Vec::new(), + }, + ], + }], + trailing_bytes: Vec::new(), + raw: Arc::from(&b""[..]), + }; + let mut files = MemoryVfs::default(); + for (path, bytes) in [ + ( + "MISSIONS/TEST/data.tma", + Arc::<[u8]>::from(synthetic_mission().into_boxed_slice()), + ), + ( + "DATA/MAPS/SYNTHETIC/Land.msh", + Arc::<[u8]>::from(empty_land_mesh().into_boxed_slice()), + ), + ( + "DATA/MAPS/SYNTHETIC/Land.map", + Arc::<[u8]>::from(minimal_land_map().into_boxed_slice()), + ), + ("BuildDat.lst", Arc::from(&b""[..])), + ( + "MISSIONS/SCRIPTS/formula.scr", + Arc::<[u8]>::from(encode_script_package(&package).into_boxed_slice()), + ), + ( + "MISSIONS/SCRIPTS/formula.var", + Arc::from( + &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 Init expression\n\nFUNCTION( , ClanID + 0.8, )\nFUNCTION( later, target * 2, )\n"[..], + ), + ), + ] { + files.insert( + normalize_engine_path("synthetic mission", path).expect("valid path"), + bytes, + ); + } + let vfs: Arc<dyn Vfs> = Arc::new(files); + let mut engine = create(EngineServices::new(vfs)).expect("engine"); + let loaded = load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/TEST/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: false, + }, + ) + .expect("full load applies the authored numeric literal"); + + assert_eq!(loaded.script_bundle_count, 1); + let states = loaded_mission_script_varset_states(&engine).expect("runtime varset"); + assert_eq!( + states[0].values, + [ + VarSetDefault::Dword(12), + VarSetDefault::Dword(35), + VarSetDefault::Dword(0), + VarSetDefault::FloatBits(0x3f4c_cccd), + ] + ); + } + + #[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(); @@ -12257,6 +14994,27 @@ mod tests { } #[test] + fn script_formula_table_uses_an_optional_case_insensitive_scr_sibling() { + let mut files = MemoryVfs::default(); + let vfs: Arc<dyn Vfs> = Arc::new(files.clone()); + assert!(matches!( + load_script_formula_table(&vfs, "MISSIONS/SCRIPTS/custom.SCR"), + Ok(None) + )); + + files.insert( + normalize_engine_path("test", "MISSIONS/SCRIPTS/custom.fml").expect("path"), + Arc::from(&b"FUNCTION(value, 1.25, )\n"[..]), + ); + let vfs: Arc<dyn Vfs> = Arc::new(files); + let formulas = load_script_formula_table(&vfs, "MISSIONS/SCRIPTS/custom.SCR") + .expect("load optional formula sibling") + .expect("formula file exists"); + assert_eq!(formulas.rows.len(), 1); + assert_eq!(formulas.evaluate_numeric_literal(0), Ok(1.25)); + } + + #[test] fn handler_nineteen_init_executes_from_clan_anchor_for_each_bundle() { let declarations = fparkan_script::parse_varset( b"VAR( DWORD, ClanBaseX, 950)\nVAR( DWORD, ClanBaseY, 1000)\nVAR( DWORD, ClanID, 0)\n", @@ -12278,18 +15036,19 @@ mod tests { name_raw: b"Init\0".to_vec(), event_word: 0x1234_5678, instructions: vec![fparkan_script::ScriptInstruction { - header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0], + header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX], references: vec![0, 1, 2], }], }], trailing_bytes: Vec::new(), raw: Arc::from(&b""[..]), }, + formula_table: None, init_execution: MissionScriptInitExecution::Missing, behavior_warm_start: None, }]; - let states = resolve_handler19_init_states( + let states = resolve_init_states( &[[500.0, 752.0], [728.0, 449.0]], &mut bundles, &varset, @@ -12303,15 +15062,18 @@ mod tests { event_index: 0, event_word: 0x1234_5678, writes: vec![ - Handler19DwordWrite { + ScriptInitWrite { index: 0, - value: 728 + value: VarSetDefault::Dword(728), }, - Handler19DwordWrite { + ScriptInitWrite { index: 1, - value: 449 + value: VarSetDefault::Dword(449), + }, + ScriptInitWrite { + index: 2, + value: VarSetDefault::Dword(1), }, - Handler19DwordWrite { index: 2, value: 1 }, ], }] ); @@ -12349,6 +15111,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, @@ -12365,11 +15129,15 @@ mod tests { event_word: 0x1234, instructions: vec![ fparkan_script::ScriptInstruction { - header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0], + header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX], 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(), }, ], @@ -12382,12 +15150,13 @@ mod tests { base_path: "MISSIONS/SCRIPTS/default".to_string(), script_path: "MISSIONS/SCRIPTS/default.scr".to_string(), package: package.clone(), + formula_table: Some(formula_table.clone()), init_execution: MissionScriptInitExecution::Missing, behavior_warm_start: None, }; let anchors = [[500.0, 752.0]]; let mut preview_bundles = vec![bundle()]; - let preview_states = resolve_handler19_init_states( + let preview_states = resolve_init_states( &anchors, &mut preview_bundles, &varset, @@ -12399,9 +15168,10 @@ mod tests { preview_bundles[0].init_execution, MissionScriptInitExecution::Unsupported { event_index: 0, - instruction_index: 1, - error: Handler19ResolveError::UnsupportedSelector { - found: ScriptDispatchSelector::Handler(18), + instruction_index: 2, + error: ScriptInitResolveError::UnsupportedFormulaExpression { + index: 1, + expression: b" x * 2".to_vec(), }, } ); @@ -12421,7 +15191,7 @@ mod tests { let mut full_bundles = vec![bundle()]; assert!(matches!( - resolve_handler19_init_states( + resolve_init_states( &anchors, &mut full_bundles, &varset, @@ -12430,12 +15200,13 @@ mod tests { Err(EngineError::ScriptInitUnsupported { clan_index: 0, event_index: 0, - instruction_index: 1, - source: Handler19ResolveError::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(); @@ -12447,7 +15218,7 @@ mod tests { ..bundle() }]; assert!(matches!( - resolve_handler19_init_states( + resolve_init_states( &anchors, &mut malformed_bundle, &varset, @@ -12639,7 +15410,10 @@ mod tests { key: "MISSIONS/Autodemo.00/data.tma".to_string(), mode: MissionMode::AutoDemo, }, - 100_000, + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, ) .expect("load licensed AutoDemo"); @@ -12779,11 +15553,24 @@ mod tests { assert_eq!(loaded.script_bundle_count, 2); assert_eq!(loaded.script_init_state_count, 2); assert_eq!(loaded.script_varset_state_count, 2); - assert!(loaded_mission_script_bundles(&engine) - .expect("script bundles") - .iter() - .all(|bundle| bundle.init_execution - == MissionScriptInitExecution::Executed { event_index: 0 })); + let script_bundles = loaded_mission_script_bundles(&engine).expect("script bundles"); + assert_eq!(script_bundles.len(), 2); + assert!(script_bundles.iter().all(|bundle| bundle.init_execution + == MissionScriptInitExecution::Executed { event_index: 0 })); + for bundle in script_bundles { + let init = bundle + .package + .events + .iter() + .find(|event| event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..])) + .expect("first Init event"); + let instruction = init.instructions.first().expect("Handler(19) action"); + assert_eq!( + instruction.header_words, + [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX] + ); + assert_eq!(instruction.references.as_slice(), &[224, 225, 226]); + } assert_eq!(engine.behavior_rng_state, 0xc823_f683); assert_eq!( loaded_mission_script_bundles(&engine) @@ -12837,7 +15624,10 @@ mod tests { key: "MISSIONS/Autodemo.00/data.tma".into(), mode: MissionMode::AutoDemo, }, - 100_000, + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, ) .unwrap(); let tables = loaded_mission_control_device_tables(&engine).unwrap(); @@ -12858,6 +15648,334 @@ 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"), + )))) + .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 = [ + (0, Some(0x4657_2800)), + (1, Some(0x4636_d000)), + (2, Some(0x467b_4001)), + (3, Some(0x468c_a000)), + (4, Some(0x46ec_4001)), + (5, Some(0x46ec_4001)), + (6, None), + (7, None), + ]; + assert_eq!(tables.len(), expected.len()); + for (original_id, expected_bits) in expected { + let table = tables + .iter() + .find(|table| table.original_id == Some(OriginalObjectId(original_id))) + .expect("AutoDemo Control table for original object"); + let actual = query_loaded_mission_control_resource(&engine, table.handle, 15) + .expect("selector 15 query") + .map(f32::to_bits); + assert_eq!(actual, expected_bits, "original object {original_id}"); + } + } + + #[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 AutoDemo corpus"] + fn licensed_autodemo_queries_selector_6_for_all_effective_factory2_pairs() { + 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, + }, + ) + .unwrap_or_else(|error| { + panic!("full AutoDemo load failed before selector-6 queries: {error:?}") + }); + + let expected_vectors = [ + ("bb_m_01", 0x43f9_8000), + ("bm_m_01", 0x4422_8000), + ("bl_l_01", 0x436a_0000), + ("br_b_01", 0x44c8_0000), + ("bf_b_01", 0x443b_8000), + ("bl_m_01", 0x443b_4000), + ("bm_l_01", 0x4334_0000), + ]; + let tables = loaded_mission_control_device_tables(&engine).expect("Control tables"); + let mut observed = Vec::new(); + for table in tables { + let devices = match &table.status { + MissionControlDeviceTableStatus::Available(devices) => devices, + MissionControlDeviceTableStatus::Unavailable(reason) => { + panic!( + "Control table for object {:?} is unavailable before selector-6 census: {reason:?}", + table.original_id + ); + } + }; + for (device_index, device) in devices.iter().enumerate() { + if device.descriptor.tag != 2 || device.factory_tag != 2 { + continue; + } + let pair = factory2_pair_from_record(&device.descriptor); + let result = query_loaded_mission_control_device_selector( + &engine, + table.handle, + device_index, + 6, + ); + observed.push((table.original_id, device_index, pair, result)); + } + } + + let mut observed_pairs = Vec::new(); + let mut mismatches = Vec::new(); + for (original_id, device_index, pair, result) in &observed { + let pair_label = pair + .as_ref() + .map(|pair| format!("{}:{}", pair.archive, pair.name)) + .unwrap_or_else(|| "<unparsed>".to_string()); + match result { + Ok(Some(value)) => println!( + "selector6 object={original_id:?} device={device_index} pair={pair_label} result=Some({:08x})", + value.to_bits() + ), + Ok(None) => println!( + "selector6 object={original_id:?} device={device_index} pair={pair_label} result=None" + ), + Err(error) => println!( + "selector6 object={original_id:?} device={device_index} pair={pair_label} error={error:?}" + ), + } + + let Some(pair) = pair else { + mismatches.push(format!( + "object {original_id:?} device {device_index}: malformed factory-2 pair" + )); + continue; + }; + if !pair.archive.eq_ignore_ascii_case("objects.rlb") { + mismatches.push(format!( + "object {original_id:?} device {device_index}: unexpected archive {}", + pair.archive + )); + continue; + } + let name = pair.name.to_ascii_lowercase(); + if !observed_pairs.contains(&name) { + observed_pairs.push(name.clone()); + } + let expected = expected_vectors + .iter() + .find(|(expected_name, _)| name.as_str() == *expected_name) + .map(|(_, bits)| *bits); + match (result, expected) { + (Ok(Some(value)), Some(expected_bits)) if value.to_bits() == expected_bits => {} + (Ok(Some(value)), Some(expected_bits)) => mismatches.push(format!( + "object {original_id:?} device {device_index} pair {name}: expected {expected_bits:08x}, got {:08x}", + value.to_bits() + )), + (Ok(None), Some(_)) => mismatches.push(format!( + "object {original_id:?} device {device_index} pair {name}: native selector returned None" + )), + (Err(error), Some(_)) => mismatches.push(format!( + "object {original_id:?} device {device_index} pair {name}: {error:?}" + )), + (_, None) => mismatches.push(format!( + "object {original_id:?} device {device_index}: pair {name} is not in the frozen native vectors" + )), + } + } + + observed_pairs.sort(); + let mut expected_pairs = expected_vectors + .iter() + .map(|(name, _)| (*name).to_string()) + .collect::<Vec<_>>(); + expected_pairs.sort(); + println!( + "selector6 census: effective_devices={}, distinct_pairs={}, pairs={observed_pairs:?}", + observed.len(), + observed_pairs.len() + ); + assert_eq!( + observed_pairs, expected_pairs, + "actual effective AutoDemo factory-2 pair census differs from the frozen vector set" + ); + assert!( + mismatches.is_empty(), + "selector-6 mismatches: {mismatches:#?}" + ); + } + + #[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"), @@ -12869,7 +15987,10 @@ mod tests { key: "MISSIONS/Autodemo.00/data.tma".into(), mode: MissionMode::AutoDemo, }, - 100_000, + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, ) .expect("licensed mission"); let schedules = |engine: &Engine| { @@ -12978,7 +16099,10 @@ mod tests { key: "MISSIONS/Autodemo.00/data.tma".to_string(), mode: MissionMode::AutoDemo, }, - 100_000, + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, ) .expect("load licensed AutoDemo"); @@ -13713,16 +16837,17 @@ mod tests { #[test] #[ignore = "requires licensed corpus"] - fn licensed_campaign_preview_keeps_unsupported_init_without_partial_writes() { + fn licensed_campaign_full_load_applies_literals_then_single_handler43_rolls_back() { let root = licensed_root("IS"); let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root)); - let mut engine = create(EngineServices::new(vfs)).expect("engine"); + let mut engine = create(EngineServices::new(vfs.clone())).expect("engine"); + let campaign_request = || MissionRequest { + key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(), + mode: MissionMode::SingleMission, + }; let loaded = load_mission_static_preview_roots( &mut engine, - MissionRequest { - key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(), - mode: MissionMode::SingleMission, - }, + campaign_request(), NonZeroUsize::new(1).expect("non-zero root count"), ) .expect("load licensed campaign preview"); @@ -13741,32 +16866,310 @@ mod tests { .iter() .all(|bundle| bundle.behavior_warm_start.is_none())); assert_eq!(engine.behavior_rng_state, INITIAL_BEHAVIOR_RNG_STATE); - let unsupported = bundles + let bundle = bundles .iter() - .find(|bundle| { - matches!( - &bundle.init_execution, - MissionScriptInitExecution::Unsupported { .. } - ) - }) - .expect("campaign package with an unsupported Init instruction"); - let clan_index = unsupported.clan_index; + .find(|bundle| bundle.clan_index == 0) + .expect("first campaign script bundle"); + assert_eq!( + bundle.base_path.replace('\\', "/"), + "MISSIONS/SCRIPTS/tut1_pl2" + ); + assert_eq!(bundle.formula_table.as_ref().unwrap().rows.len(), 26); + assert_eq!( + bundle + .formula_table + .as_ref() + .unwrap() + .evaluate_numeric_literal(0), + Ok(0.0) + ); + assert_eq!( + bundle.init_execution, + MissionScriptInitExecution::Executed { event_index: 0 } + ); let states = loaded_mission_script_init_states(&engine).expect("Init writes"); - assert!(states.iter().all(|state| state.clan_index != clan_index)); + let init = states + .iter() + .find(|state| state.clan_index == 0) + .expect("first clan Init writes"); + assert_eq!( + init.writes + .iter() + .map(|write| write.index) + .collect::<Vec<_>>(), + [224, 225, 226, 188, 189, 190, 191] + ); + assert!(init.writes[3..] + .iter() + .all(|write| write.value == VarSetDefault::Dword(0))); let varset = loaded_mission_script_varset_states(&engine) .expect("per-clan varsets") .iter() - .find(|state| state.clan_index == clan_index) - .expect("unsupported clan varset"); + .find(|state| state.clan_index == 0) + .expect("first clan varset"); + assert_eq!( + &varset.values[188..192], + &[ + VarSetDefault::Dword(0), + VarSetDefault::Dword(0), + VarSetDefault::Dword(0), + VarSetDefault::Dword(0), + ] + ); assert_eq!( &varset.values[224..227], &[ - VarSetDefault::Dword(950), - VarSetDefault::Dword(1000), + VarSetDefault::Dword(549), + VarSetDefault::Dword(630), VarSetDefault::Dword(0), ] ); assert!(loaded.script_bundle_count >= 1); + + let full = load_mission( + &mut engine, + campaign_request(), + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, + ) + .expect("full load licensed campaign mission"); + assert_eq!(full.script_bundle_count, 4); + assert_eq!(full.script_init_state_count, 4); + assert_eq!(full.script_varset_state_count, 4); + let full_bundles = loaded_mission_script_bundles(&engine).expect("full script bundles"); + assert_eq!(full_bundles.len(), 4); + assert!(full_bundles.iter().all(|bundle| matches!( + &bundle.init_execution, + MissionScriptInitExecution::Executed { event_index: 0 } + ))); + assert!(full_bundles + .iter() + .all(|bundle| bundle.behavior_warm_start.is_some())); + let full_states = loaded_mission_script_init_states(&engine).expect("full Init writes"); + let first_full_init = full_states + .iter() + .find(|state| state.clan_index == 0) + .expect("campaign clan 0 Init"); + assert_eq!( + first_full_init + .writes + .iter() + .map(|write| write.index) + .collect::<Vec<_>>(), + [224, 225, 226, 188, 189, 190, 191] + ); + assert!(first_full_init.writes[3..] + .iter() + .all(|write| write.value == VarSetDefault::Dword(0))); + let campaign_summary = loaded_mission(&engine).expect("full campaign").clone(); + let campaign_bundles = full_bundles.to_vec(); + let campaign_init_states = full_states.to_vec(); + let campaign_varsets = loaded_mission_script_varset_states(&engine) + .expect("full per-clan varsets") + .to_vec(); + let campaign_rng = engine.behavior_rng_state; + let before = step_headless(&mut engine, InputSnapshot, 0) + .expect("step loaded campaign") + .snapshot + .objects; + + let single_error = load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/Single.01/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + MissionStartupInput { + queue_time_ms: 100_500, + research_creation_gate: false, + }, + ) + .expect_err("Single.01 reaches the later Handler(43) boundary"); + assert!(matches!( + single_error, + EngineError::ScriptInitUnsupported { + clan_index: 1, + path, + event_index: 0, + instruction_index: 10, + source: ScriptInitResolveError::UnsupportedSelector { + found: fparkan_script::ScriptDispatchSelector::Handler(43), + }, + } if path.ends_with("scream.scr") + )); + assert_eq!(loaded_mission(&engine), Some(&campaign_summary)); + assert_eq!( + loaded_mission_script_bundles(&engine), + Some(campaign_bundles.as_slice()) + ); + assert_eq!( + loaded_mission_script_init_states(&engine), + Some(campaign_init_states.as_slice()) + ); + assert_eq!( + loaded_mission_script_varset_states(&engine), + Some(campaign_varsets.as_slice()) + ); + assert_eq!(engine.behavior_rng_state, campaign_rng); + let after = step_headless(&mut engine, InputSnapshot, 0) + .expect("step retained campaign") + .snapshot + .objects; + assert_eq!(before, after); + } + + #[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)); + let mut engine = create(EngineServices::new(vfs)).expect("engine"); + load_mission_static_preview_roots( + &mut engine, + MissionRequest { + key: "MISSIONS/Single.01/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + NonZeroUsize::new(1).expect("non-zero root count"), + ) + .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 + .iter() + .find(|bundle| bundle.clan_index == 0) + .expect("scr_pl_1 bundle"); + assert_eq!( + first.base_path.replace('\\', "/"), + "MISSIONS/SCRIPTS/scr_pl_1" + ); + assert_eq!(first.formula_table.as_ref().unwrap().rows.len(), 5); + assert_eq!( + first.init_execution, + MissionScriptInitExecution::Executed { event_index: 0 } + ); + let second = bundles + .iter() + .find(|bundle| bundle.clan_index == 1) + .expect("scream bundle"); + assert_eq!( + second.base_path.replace('\\', "/"), + "MISSIONS/SCRIPTS/scream" + ); + let formulas = second.formula_table.as_ref().expect("scream FML"); + assert_eq!(formulas.rows.len(), 149); + for (index, expected_bits) in [ + 0x3ecc_cccd, + 0x3f4c_cccd, + 0x3f00_0000, + 0x3f80_0000, + 0x4120_0000, + 0x3e4c_cccd, + 0x42c8_0000, + 0x43c8_0000, + 0x4000_0000, + ] + .into_iter() + .enumerate() + { + assert_eq!( + formulas + .evaluate_numeric_literal(index as u32) + .expect("referenced native literal") + .to_bits(), + expected_bits + ); + } + assert_eq!( + second.init_execution, + MissionScriptInitExecution::Unsupported { + event_index: 0, + instruction_index: 10, + error: ScriptInitResolveError::UnsupportedSelector { + found: fparkan_script::ScriptDispatchSelector::Handler(43), + }, + } + ); + + let states = loaded_mission_script_init_states(&engine).expect("preview Init writes"); + assert!(states.iter().any(|state| state.clan_index == 0)); + assert!(states.iter().all(|state| state.clan_index != 1)); + let first_varset = loaded_mission_script_varset_states(&engine) + .expect("per-clan varsets") + .iter() + .find(|state| state.clan_index == 0) + .expect("scr_pl_1 varset"); + assert_eq!( + &first_varset.values[186..188], + &[VarSetDefault::Dword(0), VarSetDefault::Dword(0)] + ); + let declarations = loaded_mission_script_varset(&engine) + .expect("shared script varset") + .declarations + .declarations + .iter() + .map(|declaration| declaration.default_value) + .collect::<Vec<_>>(); + let second_varset = loaded_mission_script_varset_states(&engine) + .expect("per-clan varsets") + .iter() + .find(|state| state.clan_index == 1) + .expect("scream varset"); + assert_eq!(second_varset.values, declarations); + assert!(bundles + .iter() + .all(|bundle| bundle.behavior_warm_start.is_none())); } #[test] @@ -13800,8 +17203,15 @@ mod tests { .snapshot .objects; - let error = load_mission(&mut engine, request(), 0) - .expect_err("malformed profile archive prevents full mission load"); + let error = load_mission( + &mut engine, + request(), + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: false, + }, + ) + .expect_err("malformed profile archive prevents full mission load"); assert!(matches!( error, EngineError::Nres { ref path, .. } if path == "behpsp.res" @@ -13930,7 +17340,10 @@ mod tests { key: "MISSIONS/Autodemo.00/data.tma".to_string(), mode: MissionMode::SingleMission, }, - 0, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: false, + }, ) .expect("load mission"); @@ -14003,7 +17416,15 @@ mod tests { }); let mut engine = create(EngineServices::new(vfs)).expect("engine"); let before = step_headless(&mut engine, InputSnapshot, 0).expect("before"); - let err = load_mission(&mut engine, mission, 0).expect_err("load error"); + let err = load_mission( + &mut engine, + mission, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: false, + }, + ) + .expect_err("load error"); match denied { DenyRule::Suffix("Land.map") => assert!(matches!(err, EngineError::Vfs { .. })), DenyRule::Suffix("objects.rlb") => { @@ -14035,7 +17456,10 @@ mod tests { fail_after_registered_objects: Some(1), ..MissionLoadOptions::default() }, - Some(0), + Some(MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: false, + }), ) .expect_err("forced registration failure"); @@ -14064,16 +17488,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(); @@ -14082,7 +17521,15 @@ mod tests { key: "MISSIONS/Autodemo.00/data.tma".to_string(), mode: MissionMode::AutoDemo, }; - load_mission(&mut engine, mission(), 100_000).expect("initial full startup"); + load_mission( + &mut engine, + mission(), + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: false, + }, + ) + .expect("initial full startup"); let previous_loaded = loaded_mission(&engine).expect("previous mission").clone(); let previous_bundles = loaded_mission_script_bundles(&engine) @@ -14098,16 +17545,25 @@ mod tests { .objects; vfs.enabled.store(true, std::sync::atomic::Ordering::SeqCst); - let error = load_mission(&mut engine, mission(), 100_500) - .expect_err("unsupported Init must fail after new-world registration"); + let error = load_mission( + &mut engine, + mission(), + MissionStartupInput { + queue_time_ms: 100_500, + research_creation_gate: false, + }, + ) + .expect_err("unsupported Init must fail after new-world registration"); assert!(matches!( error, EngineError::ScriptInitUnsupported { - source: Handler19ResolveError::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!( @@ -14176,7 +17632,10 @@ mod tests { key: case.1.to_string(), mode: MissionMode::SingleMission, }, - 0, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: false, + }, ) .expect("load mission"); let drafts = loaded_mission_object_drafts(&engine).expect("object drafts"); @@ -14290,7 +17749,10 @@ mod tests { key: case.mission.to_string(), mode: MissionMode::SingleMission, }, - 0, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: false, + }, ) .expect("load selected mission"); assert_eq!(loaded.object_count, case.object_count); @@ -14320,7 +17782,10 @@ mod tests { key: mission, mode: MissionMode::SingleMission, }, - 0, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: false, + }, ) .expect("load mission foundation"); assert_eq!(loaded.object_count, loaded.registered_objects); @@ -14460,6 +17925,516 @@ mod tests { bytes } + fn research_test_trf() -> Vec<u8> { + let mut row = [0_u32; 10]; + row[0] = 0x1122_3344; + row[1] = 0x5566_7788; + row[8] = 0x09ff_0000; + let mut row_bytes = Vec::with_capacity(40); + for word in row { + row_bytes.extend_from_slice(&word.to_le_bytes()); + } + let mut alias = Vec::new(); + alias.extend_from_slice(&0_u16.to_le_bytes()); + alias.extend_from_slice(&0_u16.to_le_bytes()); + let zero_group = 0_u32.to_le_bytes().to_vec(); + build_nres(&[ + (u32::from_le_bytes(*b"TRF0"), 1, 3, 0, row_bytes), + (u32::from_le_bytes(*b"TRF1"), 1, 0, 0, vec![0x5a]), + (u32::from_le_bytes(*b"TRF2"), 1, 0, 0, zero_group.clone()), + (u32::from_le_bytes(*b"TRF4"), 1, 0, 0, zero_group), + (u32::from_le_bytes(*b"TRF6"), 0, 0, 0, b"test\0".to_vec()), + (u32::from_le_bytes(*b"TRF7"), 0, 0, 0, Vec::new()), + (u32::from_le_bytes(*b"TRF8"), 0, 0, 0, Vec::new()), + (u32::from_le_bytes(*b"TRF9"), 0, 0, 0, Vec::new()), + (u32::from_le_bytes(*b"TRFA"), 0, 0, 0, Vec::new()), + (u32::from_le_bytes(*b"TRFB"), 1, 0, 0, alias), + ]) + } + + fn research_startup_test_vfs( + script_bases: &[Option<&[u8]>], + 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()); + } + fn push_lp(bytes: &mut Vec<u8>, value: &[u8]) { + push_u32( + bytes, + u32::try_from(value.len()).expect("string length fits u32"), + ); + bytes.extend_from_slice(value); + } + assert_eq!(script_bases.len(), research_paths.len()); + let mut mission = Vec::new(); + push_u32(&mut mission, 1); + push_u32(&mut mission, 0); + push_u32(&mut mission, 6); + push_u32(&mut mission, script_bases.len() as u32); + for (index, (script_base, research_path)) in + script_bases.iter().zip(research_paths).enumerate() + { + push_lp(&mut mission, b"Synthetic clan"); + push_u32(&mut mission, u32::MAX); + mission.extend_from_slice(&(index as f32).to_le_bytes()); + mission.extend_from_slice(&0.0_f32.to_le_bytes()); + push_u32(&mut mission, 1); + push_lp(&mut mission, script_base.unwrap_or_default()); + push_u32(&mut mission, 0); + push_lp(&mut mission, research_path); + push_u32(&mut mission, raw_tag as u32); + push_u32(&mut mission, 0); + } + push_u32(&mut mission, 10); + push_u32(&mut mission, 0); + push_lp(&mut mission, b"DATA\\MAPS\\SYNTHETIC\\land"); + push_u32(&mut mission, 0); + push_lp(&mut mission, b""); + push_u32(&mut mission, 1); + push_u32(&mut mission, 0); + + let land_mesh = empty_land_mesh(); + let land_map = minimal_land_map(); + + let script_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(); + for (path, bytes) in [ + ("MISSIONS/TEST/data.tma", mission), + ("DATA/MAPS/SYNTHETIC/Land.msh", land_mesh), + ("DATA/MAPS/SYNTHETIC/Land.map", land_map), + ] { + files.insert( + normalize_engine_path("research startup test", path).expect("test path"), + Arc::from(bytes.into_boxed_slice()), + ); + } + files.insert( + normalize_engine_path("test BuildDat", "BuildDat.lst").expect("BuildDat path"), + Arc::from(&b""[..]), + ); + if script_bases.iter().any(Option::is_some) { + files.insert( + normalize_engine_path("test script", "MISSIONS/SCRIPTS/shared.scr") + .expect("script path"), + Arc::from(script_package.into_boxed_slice()), + ); + files.insert( + normalize_engine_path("test varset", "MISSIONS/SCRIPTS/shared.var") + .expect("varset path"), + Arc::from(&b"VAR(DWORD, value, 0)\n"[..]), + ); + } + if let Some(trf) = trf { + files.insert( + normalize_engine_path("test TRF", "DATA/Research/shared.trf").expect("TRF path"), + Arc::from(trf), + ); + } + Arc::new(files) + } + + #[test] + 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"), None], + &[ + b"DATA/Research/shared.trf\0ignored.trf", + b"DATA/Research/shared.trf", + ], + Some(&research_test_trf()), + ); + let mut engine = create(EngineServices::new(vfs)).expect("research startup test engine"); + + load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/TEST/data.tma".to_string(), + mode: MissionMode::AutoDemo, + }, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: true, + }, + ) + .expect("full load with explicit research gate"); + + let tables = loaded_mission_research_trf_tables(&engine).expect("TRF tables"); + 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!( + 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); + assert_eq!(tables[1].path, "DATA/Research/shared.trf"); + assert_eq!( + tables[0].document.row_words(0), + tables[1].document.row_words(0) + ); + assert_eq!(tables[0].document.flag_byte(0), Some(0x5a)); + assert_ne!( + tables[0].document.row_words(0).unwrap().as_ptr(), + tables[1].document.row_words(0).unwrap().as_ptr(), + "each clan owns an independently decoded row buffer" + ); + } + + #[test] + 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"[..])], + vec![&b"DATA/Research/missing.trf"[..]], + false, + MissionMode::AutoDemo, + ), + ( + vec![Some(&b"MISSIONS/SCRIPTS/shared"[..])], + vec![&b""[..]], + true, + MissionMode::SingleMission, + ), + ( + vec![Some(&b"MISSIONS/SCRIPTS/shared"[..])], + vec![&b"\0ignored.trf"[..]], + true, + MissionMode::SingleMission, + ), + ] { + let vfs = research_startup_test_vfs(&script_bases, &research_paths, None); + let mut engine = + create(EngineServices::new(vfs)).expect("research startup test engine"); + load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/TEST/data.tma".to_string(), + mode, + }, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: gate, + }, + ) + .expect("skipped TRF input must not be read"); + assert!(loaded_mission_research_trf_tables(&engine) + .expect("TRF state") + .is_empty()); + } + } + + #[test] + fn static_preview_skips_research_trf_reads() { + let vfs = research_startup_test_vfs( + &[Some(b"MISSIONS/SCRIPTS/shared")], + &[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"); + 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: true, + }, + ) + .expect("initial valid TRF table load"); + let previous_summary = loaded_mission(&engine).expect("loaded mission").clone(); + let previous_table = + &loaded_mission_research_trf_tables(&engine).expect("initial TRF table")[0]; + let previous_owner = previous_table.clan_index; + let previous_path = previous_table.path.clone(); + let previous_row = *previous_table.document.row_words(0).expect("initial row"); + let previous_row_ptr = previous_table.document.row_words(0).unwrap().as_ptr(); + let before = step_headless(&mut engine, InputSnapshot, 100_000) + .expect("step initial mission") + .snapshot + .objects; + let previous_rng = engine.behavior_rng_state; + + for trf in [None, Some(&b"malformed"[..])] { + let vfs = research_startup_test_vfs( + &[Some(b"MISSIONS/SCRIPTS/shared"), None], + &[b"", b"DATA/Research/shared.trf"], + trf, + ); + engine.services.vfs = Some(vfs); + + let error = load_mission( + &mut engine, + request(), + MissionStartupInput { + queue_time_ms: 100_100, + research_creation_gate: true, + }, + ) + .expect_err("gate true must report a missing or malformed TRF"); + assert_eq!(loaded_mission(&engine), Some(&previous_summary)); + assert_eq!(engine.behavior_rng_state, previous_rng); + let retained_tables = + loaded_mission_research_trf_tables(&engine).expect("retained TRF tables"); + assert_eq!(retained_tables.len(), 1); + assert_eq!(retained_tables[0].clan_index, previous_owner); + assert_eq!(retained_tables[0].path, previous_path); + assert_eq!( + retained_tables[0].document.row_words(0), + Some(&previous_row) + ); + assert_eq!( + retained_tables[0].document.row_words(0).unwrap().as_ptr(), + previous_row_ptr + ); + let after = step_headless(&mut engine, InputSnapshot, 100_000) + .expect("step retained mission") + .snapshot + .objects; + assert_eq!(before, after); + match (trf, error) { + (None, EngineError::Vfs { path, .. }) => { + assert_eq!(path, "DATA/Research/shared.trf"); + } + ( + Some(_), + EngineError::ResearchTrf { + clan_index, path, .. + }, + ) => { + assert_eq!(clan_index, 1); + assert_eq!(path, "DATA/Research/shared.trf"); + } + (_, error) => panic!("unexpected TRF failure: {error:?}"), + } + } + } + + #[test] + fn research_trf_c_string_paths_reject_non_ascii_bytes() { + let vfs = research_startup_test_vfs( + &[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"); + let error = load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/TEST/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + MissionStartupInput { + queue_time_ms: 0, + research_creation_gate: true, + }, + ) + .expect_err("non-ASCII TRF path must fail explicitly"); + assert!(matches!( + error, + 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_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"); + load_mission( + &mut engine, + MissionRequest { + key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + MissionStartupInput { + queue_time_ms: 100_000, + research_creation_gate: true, + }, + ) + .expect("full campaign load with explicit research gate"); + + let tables = loaded_mission_research_trf_tables(&engine).expect("TRF tables"); + assert_eq!( + tables + .iter() + .map(|table| (table.clan_index, table.path.as_str())) + .collect::<Vec<_>>(), + [ + (0, "MISSIONS/SCRIPTS/data.trf"), + (1, "MISSIONS/SCRIPTS/data.trf"), + (2, "MISSIONS/SCRIPTS/data.trf"), + ] + ); + assert!(tables.iter().all(|table| table.document.row_count() > 0)); + assert_eq!( + tables[0].document.row_words(0), + tables[1].document.row_words(0) + ); + assert_eq!( + tables[0].document.row_words(0), + tables[2].document.row_words(0) + ); + assert_ne!( + tables[0].document.row_words(0).unwrap().as_ptr(), + tables[1].document.row_words(0).unwrap().as_ptr() + ); + assert_ne!( + tables[1].document.row_words(0).unwrap().as_ptr(), + tables[2].document.row_words(0).unwrap().as_ptr() + ); + } + #[derive(Clone, Copy)] enum DenyRule { Suffix(&'static str), @@ -14472,24 +18447,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], }); } @@ -14497,8 +18485,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-runtime/src/mesh_collision.rs b/crates/fparkan-runtime/src/mesh_collision.rs new file mode 100644 index 0000000..6e9ae2d --- /dev/null +++ b/crates/fparkan-runtime/src/mesh_collision.rs @@ -0,0 +1,1255 @@ +//! World-space segment queries over selected building MSH nodes and slots. + +use fparkan_msh::{ + node38_metadata, query_slot_segment, ModelAsset, MshError, NodeId, SegmentQueryMasks, SlotId, +}; +use fparkan_render::RawCameraTransform; + +/// One selected MSH node/slot and its affine transform into world space. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BuildingMeshNode { + /// Model-local node id. + pub node: NodeId, + /// Currently selected slot for this node. + pub slot: SlotId, + /// Row-major affine transform from node-local space to world space. + pub world_from_node: [f32; 16], +} + +/// One accepted building mesh hit in world space. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BuildingMeshHit { + /// Node that supplied this hit. + pub node: NodeId, + /// Selected slot that supplied this hit. + pub slot: SlotId, + /// Hit position in world space. + pub position: [f32; 3], + /// Squared world distance from the original segment start. + pub distance_squared: f32, + /// Global Batch20 index. + pub batch_index: usize, + /// Global type-7 triangle descriptor index. + pub triangle_descriptor_index: usize, + /// Global Res3 position indices of the hit triangle. + pub vertex_indices: [usize; 3], +} + +const MAX_INTERSECTION_VISITS: usize = 200; + +/// Queries a world-space segment against all supplied building nodes and slots. +/// +/// Node flags use the native camera query's `0/0` masks. The initial batch +/// query excludes `0x0200` while keeping bit `0x0008` visible to the building +/// retry rule; triangle flags use `0/0x0024`. A nearest bit-8 batch hit restarts +/// the query from that world-space point toward the original endpoint, just as +/// the native building camera does. Each helper's Batch20 `local_batch_index` +/// selects the next Node38 root; that retry visits its eligible descendants +/// child-before-self and queries only nodes with supplied geometry bindings. +pub fn query_building_segment_world( + model: &ModelAsset, + nodes: &[BuildingMeshNode], + world_segment: [[f32; 3]; 2], +) -> Result<Option<BuildingMeshHit>, MshError> { + if !world_segment + .iter() + .flatten() + .all(|value| value.is_finite()) + { + return Err(MshError::InvalidGeometry( + "building query segment contains a non-finite endpoint".to_string(), + )); + } + + let prepared_nodes = nodes + .iter() + .map(|node| { + prepare_node(*node).map_err(|reason| { + MshError::InvalidGeometry(format!( + "building node {} has an invalid world transform: {reason}", + node.node.0 + )) + }) + }) + .collect::<Result<Vec<_>, _>>()?; + + let original_start = world_segment[0]; + let original_end = world_segment[1]; + let mut segment_start = original_start; + let mut linked_root = None; + let mut intersection_visits = 0; + loop { + let mut nearest = None; + let query_nodes = if let Some(root) = linked_root { + let order = linked_node_postorder(model, root)?; + let mut ordered = Vec::new(); + for node_id in order { + ordered.extend( + prepared_nodes + .iter() + .filter(|node| node.source.node == node_id), + ); + } + ordered + } else { + prepared_nodes.iter().collect() + }; + for node in query_nodes { + let local_segment = [ + transform_point(node.node_from_world, segment_start) + .ok_or_else(|| invalid_transformed_point(node.source.node, "world start"))?, + transform_point(node.node_from_world, original_end) + .ok_or_else(|| invalid_transformed_point(node.source.node, "world endpoint"))?, + ]; + let hit = query_slot_segment( + model, + node.source.node, + node.source.slot, + local_segment, + SegmentQueryMasks { + node_include: 0, + node_exclude: 0, + batch_include: 0, + // CBuilding clears bit 8 from the camera's 0x208 exclude + // mask so it can restart after inspecting the raw batch. + batch_exclude: 0x0200, + triangle_include: 0, + triangle_exclude: 0x0024, + }, + ) + .map_err(|source| { + MshError::InvalidGeometry(format!( + "building node {} slot {} segment query failed: {source}", + node.source.node.0, node.source.slot.0 + )) + })?; + let Some(hit) = hit else { + continue; + }; + let position = transform_point(node.source.world_from_node, hit.position) + .ok_or_else(|| invalid_transformed_point(node.source.node, "local hit"))?; + let offset = subtract3(position, original_start); + let distance_squared = dot3(offset, offset); + if !distance_squared.is_finite() { + return Err(MshError::InvalidGeometry(format!( + "building node {} produced a non-finite world hit distance", + node.source.node.0 + ))); + } + let batch_flags = model + .batches + .get(hit.batch_index) + .ok_or_else(|| { + MshError::InvalidGeometry(format!( + "building node {} query returned missing batch {}", + node.source.node.0, hit.batch_index + )) + })? + .batch_flags; + let candidate = BuildingMeshHit { + node: node.source.node, + slot: node.source.slot, + position, + distance_squared, + batch_index: hit.batch_index, + triangle_descriptor_index: hit.triangle_descriptor_index, + vertex_indices: hit.vertex_indices, + }; + if nearest.is_none_or(|(best, _): (BuildingMeshHit, u16)| { + candidate.distance_squared <= best.distance_squared + }) { + nearest = Some((candidate, batch_flags)); + } + } + + let Some((nearest, batch_flags)) = nearest else { + return Ok(None); + }; + intersection_visits += 1; + if intersection_visits == MAX_INTERSECTION_VISITS { + return Err(MshError::InvalidGeometry(format!( + "building query exhausted the native {MAX_INTERSECTION_VISITS}-intersection visit limit" + ))); + } + if batch_flags & 0x0008 == 0 { + return Ok(Some(nearest)); + } + let batch = model.batches.get(nearest.batch_index).ok_or_else(|| { + MshError::InvalidGeometry(format!( + "building query returned missing batch {}", + nearest.batch_index + )) + })?; + if batch.local_batch_index == u16::MAX { + return Err(MshError::InvalidGeometry(format!( + "building helper batch {} has the sentinel linked-node key", + nearest.batch_index + ))); + } + let linked_node = NodeId(u32::from(batch.local_batch_index)); + if usize::try_from(linked_node.0).unwrap_or(usize::MAX) >= model.node_count { + return Err(MshError::InvalidGeometry(format!( + "building helper batch {} has linked node {} outside Node38 count {}", + nearest.batch_index, linked_node.0, model.node_count + ))); + } + segment_start = nearest.position; + linked_root = Some(linked_node); + } +} + +/// Builds the native child-before-self query order rooted at a portal link. +/// +/// Node metadata is traversed even when the caller has no selected geometry +/// binding for that node, because eligible descendants can still carry hits. +fn linked_node_postorder(model: &ModelAsset, root: NodeId) -> Result<Vec<NodeId>, MshError> { + let root_index = usize::try_from(root.0).map_err(|_| { + MshError::InvalidGeometry(format!("linked query node {} is not addressable", root.0)) + })?; + if root_index >= model.node_count { + return Err(MshError::InvalidGeometry(format!( + "linked query node {} is outside Node38 count {}", + root.0, model.node_count + ))); + } + + let metadata = (0..model.node_count) + .map(|index| { + let node = NodeId(u32::try_from(index).map_err(|_| { + MshError::InvalidGeometry(format!("Node38 index {index} is not addressable")) + })?); + node38_metadata(model, node).ok_or_else(|| { + MshError::InvalidGeometry(format!( + "linked query requires Node38 metadata for node {index}" + )) + }) + }) + .collect::<Result<Vec<_>, _>>()?; + for (index, entry) in metadata.iter().enumerate() { + if entry.parent_or_link_raw != u16::MAX + && usize::from(entry.parent_or_link_raw) >= model.node_count + { + return Err(MshError::InvalidGeometry(format!( + "Node38 parent {} for node {index} is outside node count {}", + entry.parent_or_link_raw, model.node_count + ))); + } + } + + // Each stack frame marks entry or postorder exit. This keeps traversal + // bounded by the model table rather than by the native hierarchy depth. + let mut stack = vec![(root_index, false)]; + let mut active = vec![false; model.node_count]; + let mut visited = vec![false; model.node_count]; + let mut ordered = Vec::new(); + while let Some((index, exiting)) = stack.pop() { + if exiting { + active[index] = false; + visited[index] = true; + ordered.push(NodeId(u32::try_from(index).map_err(|_| { + MshError::InvalidGeometry(format!("Node38 index {index} is not addressable")) + })?)); + continue; + } + if active[index] { + return Err(MshError::InvalidGeometry(format!( + "linked Node38 query contains a cycle at node {index}" + ))); + } + if visited[index] { + continue; + } + + active[index] = true; + stack.push((index, true)); + if metadata[index].header_flags & 0x0010 == 0 { + let parent_word = u16::try_from(index).map_err(|_| { + MshError::InvalidGeometry(format!( + "Node38 index {index} cannot be represented by its parent field" + )) + })?; + for child in (0..model.node_count).rev() { + if metadata[child].parent_or_link_raw != u16::MAX + && metadata[child].parent_or_link_raw == parent_word + && metadata[child].header_flags & 0x0004 == 0 + { + stack.push((child, false)); + } + } + } + } + Ok(ordered) +} + +#[derive(Clone, Copy)] +struct PreparedNode { + source: BuildingMeshNode, + node_from_world: [f32; 16], +} + +fn prepare_node(node: BuildingMeshNode) -> Result<PreparedNode, &'static str> { + if !node.world_from_node.iter().all(|value| value.is_finite()) { + return Err("matrix contains a non-finite value"); + } + if node.world_from_node[12].abs() > f32::EPSILON + || node.world_from_node[13].abs() > f32::EPSILON + || node.world_from_node[14].abs() > f32::EPSILON + || (node.world_from_node[15] - 1.0).abs() > f32::EPSILON + { + return Err("matrix is not affine"); + } + let node_from_world = RawCameraTransform { + words: node.world_from_node.map(f32::to_bits), + } + .try_inverse_affine_row_major() + .ok_or("matrix is singular or its inverse is not finite")?; + if !node_from_world.iter().all(|value| value.is_finite()) { + return Err("matrix inverse contains a non-finite value"); + } + Ok(PreparedNode { + source: node, + node_from_world, + }) +} + +fn invalid_transformed_point(node: NodeId, label: &str) -> MshError { + MshError::InvalidGeometry(format!( + "building node {} transformed {label} to a non-finite point", + node.0 + )) +} + +fn transform_point(matrix: [f32; 16], point: [f32; 3]) -> Option<[f32; 3]> { + let transformed = [ + matrix[0] * point[0] + matrix[1] * point[1] + matrix[2] * point[2] + matrix[3], + matrix[4] * point[0] + matrix[5] * point[1] + matrix[6] * point[2] + matrix[7], + matrix[8] * point[0] + matrix[9] * point[1] + matrix[10] * point[2] + matrix[11], + ]; + transformed + .iter() + .all(|value| value.is_finite()) + .then_some(transformed) +} + +fn subtract3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] - right[0], left[1] - right[1], left[2] - right[2]] +} + +fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 { + left[0] * right[0] + left[1] * right[1] + left[2] * right[2] +} + +#[cfg(test)] +mod tests { + use super::{ + query_building_segment_world, BuildingMeshHit, BuildingMeshNode, MAX_INTERSECTION_VISITS, + }; + use fparkan_msh::{ + query_slot_segment, Batch, ModelAsset, MshError, NodeId, SegmentQueryMasks, Slot, SlotId, + TriangleDescriptor, + }; + + const IDENTITY: [f32; 16] = [ + 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ]; + const CAMERA_MASKS: SegmentQueryMasks = SegmentQueryMasks { + node_include: 0, + node_exclude: 0, + batch_include: 0, + batch_exclude: 0x0200, + triangle_include: 0, + triangle_exclude: 0x0024, + }; + + fn node(node: u32, world_from_node: [f32; 16]) -> BuildingMeshNode { + node_with_slot(node, 0, world_from_node) + } + + fn node_with_slot(node: u32, slot: u32, world_from_node: [f32; 16]) -> BuildingMeshNode { + BuildingMeshNode { + node: NodeId(node), + slot: SlotId(slot), + world_from_node, + } + } + + fn model_with_planes(planes: &[(f32, u16)]) -> ModelAsset { + model_with_slots(&[planes]) + } + + fn model_with_slots(planes_per_slot: &[&[(f32, u16)]]) -> ModelAsset { + model_with_slots_and_nodes(planes_per_slot, 2) + } + + fn model_with_slots_and_nodes( + planes_per_slot: &[&[(f32, u16)]], + node_count: usize, + ) -> ModelAsset { + let mut positions = Vec::new(); + let mut indices = Vec::new(); + let mut batches = Vec::new(); + let mut triangle_descriptors = Vec::new(); + let mut slots = Vec::new(); + for planes in planes_per_slot { + let descriptor_start = triangle_descriptors.len(); + let batch_start = batches.len(); + let min_y = planes.iter().map(|(y, _)| *y).fold(0.0, f32::min); + let max_y = planes.iter().map(|(y, _)| *y).fold(0.0, f32::max); + let center_y = (min_y + max_y) * 0.5; + let half_height = (max_y - min_y) * 0.5; + for (y, batch_flags) in *planes { + let base_vertex = positions.len(); + let index_start = indices.len(); + positions.extend([[-20.0, *y, -20.0], [0.0, *y, 20.0], [20.0, *y, -20.0]]); + indices.extend([0, 1, 2]); + batches.push(Batch { + batch_flags: *batch_flags, + material_index_hi: 0, + material_index: 0, + lightmap_index: 0, + local_batch_index: u16::MAX, + index_count: 3, + index_start: u32::try_from(index_start).expect("small synthetic index offset"), + vertex_count: 3, + base_vertex: u32::try_from(base_vertex).expect("small synthetic vertex offset"), + }); + triangle_descriptors.push(TriangleDescriptor { + tri_flags: 0, + opaque: [0; 4], + normal: [0, 32767, 0], + }); + } + slots.push(Slot { + tri_start: u16::try_from(descriptor_start) + .expect("small synthetic descriptor start"), + tri_count: u16::try_from(triangle_descriptors.len() - descriptor_start) + .expect("small synthetic descriptor count"), + batch_start: u16::try_from(batch_start).expect("small synthetic batch start"), + batch_count: u16::try_from(batches.len() - batch_start) + .expect("small synthetic batch count"), + aabb_min: [-20.0, min_y, -20.0], + aabb_max: [20.0, max_y, 20.0], + sphere_center: [0.0, center_y, 0.0], + sphere_radius: (20.0_f32.powi(2) * 2.0 + half_height.powi(2)).sqrt(), + opaque: [0; 5], + }); + } + + ModelAsset { + node_stride: 38, + node_count, + nodes_raw: vec![0; node_count * 38], + type16_present: false, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 0.0, + slots, + positions, + normals: None, + uv0: None, + uv1: None, + indices, + batches, + triangle_descriptors: Some(triangle_descriptors), + node_names: None, + animation: None, + } + } + + fn set_node_metadata(model: &mut ModelAsset, node: usize, flags: u16, parent: u16) { + let offset = node * model.node_stride; + model.nodes_raw[offset..offset + 2].copy_from_slice(&flags.to_le_bytes()); + model.nodes_raw[offset + 2..offset + 4].copy_from_slice(&parent.to_le_bytes()); + } + + fn set_batch_link(model: &mut ModelAsset, batch: usize, node: u16) { + model.batches[batch].local_batch_index = node; + } + + fn set_root_node_metadata(model: &mut ModelAsset) { + for node in 0..model.node_count { + set_node_metadata(model, node, 0, u16::MAX); + } + } + + fn translated(x: f32, y: f32, z: f32) -> [f32; 16] { + let mut matrix = IDENTITY; + matrix[3] = x; + matrix[7] = y; + matrix[11] = z; + matrix + } + + fn hit(result: Option<BuildingMeshHit>) -> BuildingMeshHit { + result.expect("segment should hit the synthetic plane") + } + + fn assert_point(actual: [f32; 3], expected: [f32; 3]) { + for axis in 0..3 { + assert!( + (actual[axis] - expected[axis]).abs() < 1.0e-4, + "{actual:?} != {expected:?}" + ); + } + } + + #[test] + fn translation_returns_a_world_space_hit() { + let result = query_building_segment_world( + &model_with_planes(&[(0.0, 0)]), + &[node(0, translated(4.0, 5.0, -3.0))], + [[4.0, 8.0, -3.0], [4.0, 2.0, -3.0]], + ) + .expect("valid world query"); + + let hit = hit(result); + assert_point(hit.position, [4.0, 5.0, -3.0]); + assert_eq!(hit.distance_squared, 9.0); + } + + #[test] + fn rotation_transforms_the_world_segment_into_node_space() { + let rotated = [ + 0.0, -1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ]; + let result = query_building_segment_world( + &model_with_planes(&[(0.0, 0)]), + &[node(0, rotated)], + [[-3.0, 0.0, 0.0], [3.0, 0.0, 0.0]], + ) + .expect("valid world query"); + + assert_point(hit(result).position, [0.0, 0.0, 0.0]); + } + + #[test] + fn nonuniform_scale_preserves_world_hit_coordinates() { + let scaled = [ + 2.0, 0.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.0, 1.0, + ]; + let result = query_building_segment_world( + &model_with_planes(&[(0.0, 0)]), + &[node(0, scaled)], + [[0.0, 2.0, 0.25], [0.0, -2.0, 0.25]], + ) + .expect("valid world query"); + + let hit = hit(result); + assert_point(hit.position, [0.0, 0.0, 0.25]); + assert_eq!(hit.distance_squared, 4.0); + } + + #[test] + fn mirrored_transform_uses_the_mirrored_world_side() { + let mirrored = [ + 1.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ]; + let result = query_building_segment_world( + &model_with_planes(&[(0.0, 0)]), + &[node(0, mirrored)], + [[0.0, -3.0, 0.0], [0.0, 3.0, 0.0]], + ) + .expect("valid mirrored world query"); + + assert_point(hit(result).position, [0.0, 0.0, 0.0]); + } + + #[test] + fn nearest_hit_is_ranked_in_world_space_from_original_start() { + let mut farther = translated(0.0, 0.0, 0.0); + let mut nearer = translated(0.0, 2.0, 0.0); + // Make local distances deliberately incomparable across nodes. + farther[5] = 2.0; + nearer[5] = 0.5; + let result = query_building_segment_world( + &model_with_planes(&[(0.0, 0)]), + &[node(0, farther), node(1, nearer)], + [[0.0, 5.0, 0.0], [0.0, -1.0, 0.0]], + ) + .expect("valid world query"); + + let hit = hit(result); + assert_eq!(hit.node, NodeId(1)); + assert_point(hit.position, [0.0, 2.0, 0.0]); + assert_eq!(hit.distance_squared, 9.0); + } + + #[test] + fn retries_from_bit8_helper_to_the_farther_wall() { + let helper_plane = [(2.0, 0x0008)]; + let wall_plane = [(0.0, 0)]; + let mut model = model_with_slots(&[&helper_plane, &wall_plane]); + set_root_node_metadata(&mut model); + set_batch_link(&mut model, 0, 1); + let result = query_building_segment_world( + &model, + &[ + node_with_slot(0, 0, IDENTITY), + node_with_slot(1, 1, IDENTITY), + ], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ) + .expect("valid world query"); + + let hit = hit(result); + assert_eq!(hit.node, NodeId(1)); + assert_eq!(hit.slot, SlotId(1)); + assert_eq!(hit.batch_index, 1); + assert_point(hit.position, [0.0, 0.0, 0.0]); + assert_eq!(hit.distance_squared, 9.0); + } + + #[test] + fn coplanar_helper_hit_is_requeried_instead_of_filtered_out() { + let mut model = model_with_slots(&[&[(0.0, 0)], &[(0.0, 0x0008)]]); + set_root_node_metadata(&mut model); + set_batch_link(&mut model, 1, 1); + let result = query_building_segment_world( + &model, + &[ + node_with_slot(1, 0, IDENTITY), + node_with_slot(0, 1, IDENTITY), + ], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ) + .expect("valid world query"); + + assert!(result.is_none()); + } + + #[test] + fn world_roundtrip_helper_cannot_be_reselected_after_portal_link() { + const START: [f32; 3] = [301.39682, 806.24396, 2.868942]; + const END: [f32; 3] = [304.3922, 806.4102, 2.868942]; + let world_from_node = [ + f32::from_bits(0xbd62_f745), + f32::from_bits(0xbf7f_9b4f), + 0.0, + f32::from_bits(0x4379_43ca), + f32::from_bits(0x3f7f_9b4f), + f32::from_bits(0xbd62_f745), + 0.0, + f32::from_bits(0x4448_b667), + 0.0, + 0.0, + 1.0, + f32::from_bits(0x4110_2494), + 0.0, + 0.0, + 0.0, + 1.0, + ]; + let mut model = model_with_slots_and_nodes(&[&[(-54.239994, 0x0008)]], 4); + set_batch_link(&mut model, 0, 2); + set_node_metadata(&mut model, 0, 0, u16::MAX); + set_node_metadata(&mut model, 1, 0x0104, 0); + set_node_metadata(&mut model, 2, 0x0004, 1); + set_node_metadata(&mut model, 3, 0x00d1, 2); + let source = node(1, world_from_node); + + // Reproduce the old all-node retry: the world-space first hit maps + // back to a local start just above this same helper plane. + let prepared = super::prepare_node(source).expect("invert actual AutoDemo matrix"); + let local_start = + super::transform_point(prepared.node_from_world, START).expect("finite local start"); + let local_end = + super::transform_point(prepared.node_from_world, END).expect("finite local endpoint"); + let first_hit = query_slot_segment( + &model, + NodeId(1), + SlotId(0), + [local_start, local_end], + CAMERA_MASKS, + ) + .expect("valid first helper query") + .expect("first helper crossing"); + let world_hit = + super::transform_point(world_from_node, first_hit.position).expect("finite world hit"); + let repeated_local_start = super::transform_point(prepared.node_from_world, world_hit) + .expect("finite round-tripped start"); + assert!(query_slot_segment( + &model, + NodeId(1), + SlotId(0), + [repeated_local_start, local_end], + CAMERA_MASKS, + ) + .expect("valid repeated helper query") + .is_some()); + + let result = query_building_segment_world(&model, &[source], [START, END]) + .expect("valid portal-linked world query"); + assert!(result.is_none()); + } + + #[test] + fn linked_subtree_finds_drawable_child_through_nondrawable_parent() { + let mut model = model_with_slots_and_nodes(&[&[(2.0, 0x0008)], &[(0.0, 0)]], 4); + set_root_node_metadata(&mut model); + set_batch_link(&mut model, 0, 2); + set_node_metadata(&mut model, 1, 0x0104, 0); + set_node_metadata(&mut model, 2, 0x0004, 1); + set_node_metadata(&mut model, 3, 0x00d1, 2); + + let result = query_building_segment_world( + &model, + &[ + node_with_slot(1, 0, IDENTITY), + node_with_slot(3, 1, IDENTITY), + ], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ) + .expect("valid linked-child query"); + + let hit = hit(result); + assert_eq!(hit.node, NodeId(3)); + assert_eq!(hit.slot, SlotId(1)); + assert_eq!(hit.batch_index, 1); + assert_point(hit.position, [0.0, 0.0, 0.0]); + } + + #[test] + fn linked_query_is_child_before_self_and_self_bit4_does_not_skip_geometry() { + let mut model = + model_with_slots_and_nodes(&[&[(2.0, 0x0008)], &[(0.0, 0)], &[(0.0, 0)]], 4); + set_root_node_metadata(&mut model); + set_batch_link(&mut model, 0, 2); + set_node_metadata(&mut model, 1, 0x0104, 0); + set_node_metadata(&mut model, 2, 0x0004, 1); + set_node_metadata(&mut model, 3, 0x00d1, 2); + + let result = query_building_segment_world( + &model, + &[ + node_with_slot(1, 0, IDENTITY), + node_with_slot(2, 1, IDENTITY), + node_with_slot(3, 2, IDENTITY), + ], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ) + .expect("valid child-first linked query"); + + let hit = hit(result); + // The equal-distance later candidate replaces the child result. + assert_eq!(hit.node, NodeId(2)); + assert_eq!(hit.slot, SlotId(1)); + } + + #[test] + fn linked_query_traverses_nondrawable_nodes_and_skips_child_bit4_branches() { + let mut model = model_with_slots_and_nodes( + &[&[(2.0, 0x0008)], &[(0.0, 0)], &[(0.0, 0)], &[(0.0, 0)]], + 7, + ); + set_root_node_metadata(&mut model); + set_batch_link(&mut model, 0, 2); + set_node_metadata(&mut model, 1, 0x0104, 0); + set_node_metadata(&mut model, 2, 0x0004, 1); + set_node_metadata(&mut model, 3, 0x0000, 2); + set_node_metadata(&mut model, 4, 0x0010, 3); + set_node_metadata(&mut model, 5, 0x0004, 3); + set_node_metadata(&mut model, 6, 0x0004, 2); + + let result = query_building_segment_world( + &model, + &[ + node_with_slot(1, 0, IDENTITY), + node_with_slot(4, 1, IDENTITY), + node_with_slot(5, 2, IDENTITY), + node_with_slot(6, 3, IDENTITY), + ], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ) + .expect("valid linked branch query"); + + let hit = hit(result); + assert_eq!(hit.node, NodeId(4)); + assert_eq!(hit.slot, SlotId(1)); + } + + #[test] + fn linked_query_keeps_native_batch_and_triangle_exclude_masks() { + let mut model = + model_with_slots_and_nodes(&[&[(2.0, 0x0008)], &[(0.0, 0)], &[(0.0, 0x0200)]], 5); + set_root_node_metadata(&mut model); + set_batch_link(&mut model, 0, 2); + set_node_metadata(&mut model, 1, 0x0104, 0); + set_node_metadata(&mut model, 2, 0x0004, 1); + set_node_metadata(&mut model, 3, 0x00d1, 2); + set_node_metadata(&mut model, 4, 0x00d1, 2); + model + .triangle_descriptors + .as_mut() + .expect("synthetic type-7 table")[1] + .tri_flags = 0x0024; + + let result = query_building_segment_world( + &model, + &[ + node_with_slot(1, 0, IDENTITY), + node_with_slot(3, 1, IDENTITY), + node_with_slot(4, 2, IDENTITY), + ], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ) + .expect("valid query with native exclusion masks"); + + assert!(result.is_none()); + } + + #[test] + fn linked_order_stops_descent_at_header_bit10_but_keeps_self() { + let mut model = model_with_slots_and_nodes(&[&[]], 5); + set_root_node_metadata(&mut model); + set_node_metadata(&mut model, 1, 0, 0); + set_node_metadata(&mut model, 2, 0x0014, 1); + set_node_metadata(&mut model, 3, 0, 2); + set_node_metadata(&mut model, 4, 0, 3); + + let order = super::linked_node_postorder(&model, NodeId(2)).expect("valid linked tree"); + + assert_eq!(order, [NodeId(2)]); + } + + #[test] + fn invalid_portal_link_and_linked_cycle_are_explicit_errors() { + let mut invalid_link = model_with_planes(&[(2.0, 0x0008)]); + set_root_node_metadata(&mut invalid_link); + set_batch_link(&mut invalid_link, 0, 2); + assert!(matches!( + query_building_segment_world( + &invalid_link, + &[node(0, IDENTITY)], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ), + Err(MshError::InvalidGeometry(message)) if message.contains("linked") + )); + + let mut cycle = model_with_slots_and_nodes(&[&[(2.0, 0x0008)]], 3); + set_batch_link(&mut cycle, 0, 1); + set_node_metadata(&mut cycle, 0, 0, u16::MAX); + set_node_metadata(&mut cycle, 1, 0, 2); + set_node_metadata(&mut cycle, 2, 0, 1); + assert!(matches!( + query_building_segment_world( + &cycle, + &[node(0, IDENTITY)], + [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]], + ), + Err(MshError::InvalidGeometry(message)) if message.contains("cycle") + )); + } + + #[test] + fn invalid_non_affine_singular_and_nonfinite_transforms_are_explicit_errors() { + let mut non_affine = IDENTITY; + non_affine[12] = 0.5; + assert!(matches!( + query_building_segment_world( + &model_with_planes(&[(0.0, 0)]), + &[node(0, non_affine)], + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + ), + Err(MshError::InvalidGeometry(message)) if message.contains("not affine") + )); + + let mut singular = IDENTITY; + singular[5] = 0.0; + assert!(matches!( + query_building_segment_world( + &model_with_planes(&[(0.0, 0)]), + &[node(0, singular)], + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + ), + Err(MshError::InvalidGeometry(message)) if message.contains("singular") + )); + + let mut nonfinite = IDENTITY; + nonfinite[0] = f32::NAN; + assert!(matches!( + query_building_segment_world( + &model_with_planes(&[(0.0, 0)]), + &[node(0, nonfinite)], + [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]], + ), + Err(MshError::InvalidGeometry(message)) if message.contains("non-finite") + )); + } + + #[test] + fn wall_after_198_helpers_is_accepted_as_the_199th_hit() { + let helper_count = MAX_INTERSECTION_VISITS - 2; + let mut planes = (0..helper_count) + .map(|index| ((helper_count - index) as f32, 0x0008)) + .collect::<Vec<_>>(); + planes.push((0.0, 0)); + let mut model = model_with_planes(&planes); + set_root_node_metadata(&mut model); + for batch in 0..helper_count { + set_batch_link(&mut model, batch, 0); + } + let result = query_building_segment_world( + &model, + &[node(0, IDENTITY)], + [[0.0, (helper_count + 1) as f32, 0.0], [0.0, -1.0, 0.0]], + ) + .expect("199th intersection is below the native limit"); + + let hit = hit(result); + assert_eq!(hit.batch_index, helper_count); + assert_point(hit.position, [0.0, 0.0, 0.0]); + } + + #[test] + fn wall_after_199_helpers_errors_as_the_200th_hit() { + let helper_count = MAX_INTERSECTION_VISITS - 1; + let mut planes = (0..helper_count) + .map(|index| ((helper_count - index) as f32, 0x0008)) + .collect::<Vec<_>>(); + planes.push((0.0, 0)); + let mut model = model_with_planes(&planes); + set_root_node_metadata(&mut model); + for batch in 0..helper_count { + set_batch_link(&mut model, batch, 0); + } + let result = query_building_segment_world( + &model, + &[node(0, IDENTITY)], + [[0.0, (helper_count + 1) as f32, 0.0], [0.0, -1.0, 0.0]], + ); + + assert!(matches!( + result, + Err(MshError::InvalidGeometry(message)) if message.contains("200") + )); + } + + fn compose_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] { + let mut result = [0.0; 16]; + for row in 0..4 { + for column in 0..4 { + for inner in 0..4 { + result[row * 4 + column] += left[row * 4 + inner] * right[inner * 4 + column]; + } + } + } + result + } + + fn pose_matrix(pose: fparkan_animation::Pose) -> [f32; 16] { + let x = rotate_unit_vector([1.0, 0.0, 0.0], pose.rotation); + let y = rotate_unit_vector([0.0, 1.0, 0.0], pose.rotation); + let z = rotate_unit_vector([0.0, 0.0, 1.0], pose.rotation); + [ + x[0], + y[0], + z[0], + pose.translation[0], + x[1], + y[1], + z[1], + pose.translation[1], + x[2], + y[2], + z[2], + pose.translation[2], + 0.0, + 0.0, + 0.0, + 1.0, + ] + } + + fn rotate_unit_vector(value: [f32; 3], rotation: [f32; 4]) -> [f32; 3] { + let [x, y, z, w] = rotation; + let tx = 2.0 * (y * value[2] - z * value[1]); + let ty = 2.0 * (z * value[0] - x * value[2]); + let tz = 2.0 * (x * value[1] - y * value[0]); + [ + value[0] + w * tx + (y * tz - z * ty), + value[1] + w * ty + (z * tx - x * tz), + value[2] + w * tz + (x * ty - y * tx), + ] + } + + fn scale_matrix(scale: [f32; 3]) -> [f32; 16] { + [ + scale[0], 0.0, 0.0, 0.0, 0.0, scale[1], 0.0, 0.0, 0.0, 0.0, scale[2], 0.0, 0.0, 0.0, + 0.0, 1.0, + ] + } + + fn node_times_from_bindings( + node_count: usize, + bindings: &[fparkan_assets::PreparedControlNodeBinding], + ) -> Vec<Option<fparkan_animation::AnimationTime>> { + use fparkan_animation::AnimationTime; + + let mut times = vec![Some(AnimationTime(0.0)); node_count]; + for binding in bindings { + if binding.flags & 0x04 != 0 { + continue; + } + assert!(binding.node_index < node_count); + let mut blend = if binding.flags & 0x01 != 0 { + let mut value = binding.initial_blend; + if value > 1.0 { + value -= 1.0; + } + if value < 0.0 { + value += 1.0; + } + value + } else { + binding.initial_blend.clamp(0.0, 1.0) + }; + if binding.flags & 0x02 != 0 { + blend = 1.0 - blend; + } + times[binding.node_index] = Some(AnimationTime( + (1.0 - blend) * binding.frame_a + blend * binding.frame_b, + )); + } + times + } + + fn nodes_for_hierarchy( + model: &ModelAsset, + poses: &[fparkan_animation::Pose], + root_from_body: [f32; 16], + owner_scale: [f32; 3], + ) -> Vec<BuildingMeshNode> { + use fparkan_msh::{selected_slot, Group, Lod}; + + let scale = scale_matrix(owner_scale); + (0..model.node_count) + .filter_map(|index| { + let node = NodeId(u32::try_from(index).expect("model node id fits u32")); + let slot = selected_slot(model, node, Lod(0), Group(0))?; + let slot_data = &model.slots[usize::try_from(slot.0).expect("slot id fits usize")]; + let batch_start = usize::from(slot_data.batch_start); + let batch_end = batch_start + usize::from(slot_data.batch_count); + if !model.batches[batch_start..batch_end] + .iter() + .any(|batch| batch.index_count != 0) + { + return None; + } + let node_pose = pose_matrix(*poses.get(index).expect("Node38 pose available")); + let body_from_node = compose_row_major(scale, node_pose); + let world_from_node = compose_row_major(root_from_body, body_from_node); + Some(BuildingMeshNode { + node, + slot, + world_from_node, + }) + }) + .collect() + } + + #[test] + #[ignore = "requires licensed corpus"] + fn factory_plant_world_query_preserves_native_fixture_hit_under_affine_transforms() { + use fparkan_assets::decode_nres_payload; + use fparkan_msh::{ + decode_msh, node38_fallback_hierarchy, selected_slot, validate_msh, Group, Lod, NodeId, + SlotId, + }; + use std::{path::PathBuf, sync::Arc}; + + const BODY_WALL: [[f32; 3]; 2] = [[-10.5, 22.16, 2.5], [-10.5, 19.16, 2.5]]; + const BODY_DOOR: [[f32; 3]; 2] = [[0.5, 22.16, 2.5], [0.5, 19.16, 2.5]]; + const BODY_REVERSED_WALL: [[f32; 3]; 2] = [BODY_WALL[1], BODY_WALL[0]]; + const BODY_HIT: [f32; 3] = [-10.5, 20.16, 2.5]; + const ROTATED_SCALED_TRANSLATED: [f32; 16] = [ + 0.0, -3.0, 0.0, 7.0, 2.0, 0.0, 0.0, -4.0, 0.0, 0.0, 0.5, 2.0, 0.0, 0.0, 0.0, 1.0, + ]; + + let root = PathBuf::from( + std::env::var_os("FPARKAN_CORPUS_PART1_ROOT") + .expect("FPARKAN_CORPUS_PART1_ROOT is required for licensed corpus tests"), + ); + let archive_bytes = std::fs::read(root.join("fortif.rlb")).expect("fortif.rlb bytes"); + let archive = decode_nres_payload(Arc::from(archive_bytes.into_boxed_slice())) + .expect("fortif.rlb archive"); + let entry = archive + .entries() + .iter() + .find(|entry| entry.name_bytes().eq_ignore_ascii_case(b"fr_l_plant.msh")) + .expect("plant factory MSH"); + let nested = decode_nres_payload(Arc::from( + archive + .payload(entry.id()) + .expect("MSH payload") + .to_vec() + .into_boxed_slice(), + )) + .expect("nested plant MSH"); + let model = validate_msh(&decode_msh(&nested).expect("plant MSH document")) + .expect("plant MSH model"); + let node_id = NodeId(1); + let slot = selected_slot(&model, node_id, Lod(0), Group(0)).expect("Node 1 slot"); + assert_eq!(slot, SlotId(0)); + + let pose = node38_fallback_hierarchy(&model) + .expect("Node38 fallback hierarchy") + .poses[1]; + assert_eq!(pose.rotation, [0.0, 0.0, 0.0, 1.0]); + let body_from_node = translated( + pose.translation[0], + pose.translation[1], + pose.translation[2], + ); + + for body_to_world in [IDENTITY, ROTATED_SCALED_TRANSLATED] { + let world_from_node = compose_row_major(body_to_world, body_from_node); + let node = BuildingMeshNode { + node: node_id, + slot, + world_from_node, + }; + let to_world = |point| { + super::transform_point(body_to_world, point).expect("finite fixture transform") + }; + let world_wall = [to_world(BODY_WALL[0]), to_world(BODY_WALL[1])]; + let expected_hit = to_world(BODY_HIT); + let hit = query_building_segment_world(&model, &[node], world_wall) + .expect("valid factory wall query") + .expect("solid factory wall hit"); + + assert_eq!(hit.node, node_id); + assert_eq!(hit.slot, slot); + assert_eq!(hit.batch_index, 5); + assert_eq!(hit.triangle_descriptor_index, 62); + for axis in 0..3 { + assert!( + (hit.position[axis] - expected_hit[axis]).abs() <= 1.0e-3, + "world hit {:?} != {:?}", + hit.position, + expected_hit + ); + } + let world_delta = [ + expected_hit[0] - world_wall[0][0], + expected_hit[1] - world_wall[0][1], + expected_hit[2] - world_wall[0][2], + ]; + let expected_distance_squared = super::dot3(world_delta, world_delta); + assert!( + (hit.distance_squared - expected_distance_squared).abs() <= 1.0e-2, + "world distance squared {} != {}", + hit.distance_squared, + expected_distance_squared + ); + + for body_segment in [BODY_DOOR, BODY_REVERSED_WALL] { + let world_segment = [to_world(body_segment[0]), to_world(body_segment[1])]; + assert!(query_building_segment_world(&model, &[node], world_segment) + .expect("valid factory miss query") + .is_none()); + } + } + } + + #[test] + #[ignore = "requires licensed AutoDemo corpus"] + fn autodemo_portal_uses_node3_authored_closed_and_open_poses() { + use crate::{ + create, load_mission_static_preview_roots, loaded_mission_assets, + loaded_mission_object_drafts, EngineServices, MissionMode, MissionRequest, + }; + use fparkan_animation::AnimationTime; + use fparkan_msh::{node38_fallback_hierarchy, node38_sampled_hierarchy_at_times}; + use fparkan_render::LegacyIron3dEulerTransform; + use fparkan_vfs::DirectoryVfs; + use std::{num::NonZeroUsize, path::PathBuf, sync::Arc}; + + const OBJECT_INDEX: usize = 4; + const BODY_DOOR: [[f32; 3]; 2] = [[0.5, 22.16, 2.5], [0.5, 19.16, 2.5]]; + + let root = PathBuf::from( + std::env::var_os("FPARKAN_CORPUS_PART1_ROOT") + .expect("FPARKAN_CORPUS_PART1_ROOT is required for licensed corpus tests"), + ); + let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(&root)))) + .expect("create SingleMission fixture engine"); + load_mission_static_preview_roots( + &mut engine, + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".to_string(), + mode: MissionMode::SingleMission, + }, + NonZeroUsize::MAX, + ) + .expect("load SingleMission preview roots"); + let assets = loaded_mission_assets(&engine).expect("loaded mission assets"); + let drafts = loaded_mission_object_drafts(&engine).expect("loaded object drafts"); + let draft = &drafts[OBJECT_INDEX]; + assert!(draft + .resource_name_raw + .eq_ignore_ascii_case(b"UNITS\\BUILDS\\PLANT\\wor_l_n1.dat")); + let visual_id = *assets + .visuals_for_object(OBJECT_INDEX) + .first() + .expect("PLANT root visual"); + let visual = assets.visual_by_id(visual_id).expect("PLANT visual"); + let model_id = visual.model_id.expect("PLANT MSH model"); + let model = &assets + .model_by_id(model_id) + .expect("prepared PLANT model") + .validated; + assert_eq!(model.node_count, 29); + + let controls = assets + .object_component_controls + .get(OBJECT_INDEX) + .and_then(|components| components.first()) + .and_then(Option::as_ref) + .expect("PLANT component CTLD bindings"); + let node3_binding = controls + .bindings + .iter() + .find(|binding| binding.node_index == 3) + .expect("Node3 CTLD binding"); + assert_eq!(node3_binding.frame_a, 0.0); + assert_eq!(node3_binding.frame_b, 1.0); + assert_eq!(node3_binding.initial_blend, 0.0); + + let mut node_times = node_times_from_bindings(model.node_count, &controls.bindings); + let closed_hierarchy = node38_sampled_hierarchy_at_times(model, &node_times) + .or_else(|| node38_fallback_hierarchy(model)) + .expect("initial Node38 hierarchy"); + assert_eq!(node_times[3], Some(AnimationTime(0.0))); + + let root_transform = LegacyIron3dEulerTransform { + translation: draft.position, + orientation_radians: draft.orientation_raw, + }; + let root_from_body = root_transform + .try_row_major() + .expect("finite PLANT root matrix"); + let world_door = BODY_DOOR.map(|point| { + root_transform + .try_transform_scaled_point(point, draft.scale) + .expect("finite BODY_DOOR world point") + }); + let closed_nodes = + nodes_for_hierarchy(model, &closed_hierarchy.poses, root_from_body, draft.scale); + let closed = query_building_segment_world(model, &closed_nodes, world_door) + .expect("closed-door building query") + .expect("authored time-0 doorway blocker"); + assert_eq!(closed.node, NodeId(3)); + assert_eq!(closed.slot, SlotId(25)); + assert_eq!(closed.batch_index, 100); + assert_eq!(closed.triangle_descriptor_index, 855); + assert_eq!(model.batches[closed.batch_index].batch_flags, 0x2000); + + node_times[3] = Some(AnimationTime(node3_binding.frame_b)); + let open_hierarchy = node38_sampled_hierarchy_at_times(model, &node_times) + .expect("authored Node3 time-1 hierarchy"); + let open_nodes = + nodes_for_hierarchy(model, &open_hierarchy.poses, root_from_body, draft.scale); + assert!(query_building_segment_world(model, &open_nodes, world_door) + .expect("time-1 building query") + .is_none()); + } +} |
