aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-runtime
diff options
context:
space:
mode:
Diffstat (limited to 'crates/fparkan-runtime')
-rw-r--r--crates/fparkan-runtime/Cargo.toml4
-rw-r--r--crates/fparkan-runtime/src/lib.rs4282
-rw-r--r--crates/fparkan-runtime/src/mesh_collision.rs1255
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());
+ }
+}