aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-runtime/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/fparkan-runtime/src')
-rw-r--r--crates/fparkan-runtime/src/lib.rs2770
1 files changed, 2679 insertions, 91 deletions
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs
index 17d942f..5cc2d86 100644
--- a/crates/fparkan-runtime/src/lib.rs
+++ b/crates/fparkan-runtime/src/lib.rs
@@ -2,26 +2,28 @@
//! Runtime orchestration for mission loading and world stepping.
use fparkan_assets::ClanBody;
-#[cfg(test)]
-use fparkan_assets::PreparedControlResourcePair;
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, ResearchError,
- ResearchTrfDocument, 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::{
FormulaTable, Handler19InitInput, ScriptInitResolution, ScriptInitResolveError,
ScriptInitWrite, ScriptInstruction, ScriptPackage, VarSet, VarSetDefault,
@@ -44,6 +46,7 @@ pub use fparkan_assets::MissionAssets;
pub use mesh_collision::{query_building_segment_world, BuildingMeshHit, BuildingMeshNode};
const MISSION_DECODED_PAYLOAD_CACHE_ENTRIES: usize = 256;
+const MAX_MISSION_AI_RECORD_SLOTS: usize = 1_000_000;
const FALLBACK_SCRIPT_VARSET_PATH: &str = "MISSIONS/SCRIPTS/varset.var";
const INITIAL_BEHAVIOR_RNG_STATE: u32 = 1;
const UNIT_DAT_CONTROL_CLASS_HIGH_BIT: u32 = 0x8000_0000;
@@ -204,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
@@ -224,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`.
@@ -495,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 {
@@ -680,6 +962,15 @@ pub struct MissionScriptBundle {
pub behavior_warm_start: Option<BehaviorWarmStartState>,
}
+/// Initial native record-slot words owned by one mission clan's AI instance.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MissionAiStartupState {
+ /// TMA clan index that owns this AI instance.
+ pub clan_index: usize,
+ /// Sentinel words appended during AI startup, in native insertion order.
+ pub record_slots: Vec<u32>,
+}
+
/// One decoded research table associated with its TMA clan owner and source path.
#[derive(Debug)]
pub struct MissionResearchTrfTable {
@@ -847,6 +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>,
@@ -974,7 +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>,
@@ -1046,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.
@@ -1225,6 +1530,15 @@ impl std::fmt::Display for EngineError {
clan_index,
message,
} => write!(f, "clan {clan_index}: script runtime failed: {message}"),
+ Self::MissionAiRecordSlots {
+ clan_index,
+ raw_count,
+ allocated_slots,
+ message,
+ } => write!(
+ f,
+ "clan {clan_index} AI initial slots (unsigned raw count {raw_count}, after {allocated_slots} mission slots): {message}"
+ ),
Self::Nres { path, source } => write!(f, "{path}: {source}"),
Self::ResearchTrf {
clan_index,
@@ -1285,6 +1599,7 @@ impl std::error::Error for EngineError {
| Self::VarSet { .. }
| Self::ScriptInit { .. }
| Self::ScriptRuntime { .. }
+ | Self::MissionAiRecordSlots { .. }
| Self::Movement(_)
| Self::ResearchTrfPath { .. }
| Self::PrototypeGraph { .. }
@@ -1444,7 +1759,7 @@ fn load_mission_with_options(
let research_trf_tables = if options.asset_scope == MissionAssetScope::Full
&& startup_input.is_some_and(|input| input.research_creation_gate)
{
- load_mission_research_trf_tables(&vfs, &mission, &loaded_scripts.bundles)?
+ load_mission_research_trf_tables(&vfs, &mission, &loaded_scripts.ai_startup_states)?
} else {
Vec::new()
};
@@ -1656,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)
@@ -1670,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,
@@ -1677,6 +2017,7 @@ fn load_mission_with_options(
prototype_span,
&resolved_prototypes,
native_control_class,
+ &mut resolve_factory2_pair,
);
match projection {
Ok(projection) => MissionControlDeviceTableState {
@@ -1720,6 +2061,21 @@ 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 {
@@ -1801,7 +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,
@@ -1883,6 +2241,15 @@ pub fn loaded_mission_script_bundles(engine: &Engine) -> Option<&[MissionScriptB
.map(|state| state.script_bundles.as_slice())
}
+/// Returns the per-clan AI record slots initialized during full mission startup.
+#[must_use]
+pub fn loaded_mission_ai_startup_states(engine: &Engine) -> Option<&[MissionAiStartupState]> {
+ engine
+ .loaded
+ .as_ref()
+ .map(|state| state.ai_startup_states.as_slice())
+}
+
/// Returns the per-clan TRF tables loaded during full mission startup.
#[must_use]
pub fn loaded_mission_research_trf_tables(engine: &Engine) -> Option<&[MissionResearchTrfTable]> {
@@ -2132,6 +2499,61 @@ pub fn loaded_mission_control_device_tables(
.map(|state| state.control_device_tables.as_slice())
}
+/// Returns the initial prepared Control grouping for full-load mission objects.
+#[must_use]
+pub fn loaded_mission_behavior_control_groupings(
+ engine: &Engine,
+) -> Option<&[MissionBehaviorControlGroupingState]> {
+ engine
+ .loaded
+ .as_ref()
+ .map(|state| state.behavior_control_groupings.as_slice())
+}
+
+fn project_mission_behavior_control_grouping(
+ table: &MissionControlDeviceTableState,
+) -> MissionBehaviorControlGroupingState {
+ let grouping = match &table.status {
+ MissionControlDeviceTableStatus::Available(devices) => {
+ Ok(build_mission_behavior_control_grouping(devices))
+ }
+ MissionControlDeviceTableStatus::Unavailable(reason) => Err(reason.clone()),
+ };
+ MissionBehaviorControlGroupingState {
+ original_id: table.original_id,
+ handle: table.handle,
+ grouping,
+ }
+}
+
+fn build_mission_behavior_control_grouping(
+ devices: &[EffectiveControlDevice],
+) -> MissionBehaviorControlGrouping {
+ let mut grouping = MissionBehaviorControlGrouping::default();
+ for (ordinal, device) in devices.iter().enumerate() {
+ match device.descriptor.tag {
+ 1 => {
+ grouping.groups.push(MissionBehaviorControlGroup {
+ header_device_ordinal: ordinal,
+ candidate_device_ordinals: Vec::new(),
+ tag30_device_ordinals: Vec::new(),
+ });
+ }
+ 2 => match grouping.groups.last_mut() {
+ Some(group) => group.candidate_device_ordinals.push(ordinal),
+ None => grouping.ungrouped_candidate_device_ordinals.push(ordinal),
+ },
+ 30 => {
+ if let Some(group) = grouping.groups.last_mut() {
+ group.tag30_device_ordinals.push(ordinal);
+ }
+ }
+ _ => {}
+ }
+ }
+ grouping
+}
+
/// Queries native Control global-table selector 1 (weighted ratio), 2 (sum), or 15.
/// `Ok(None)` preserves the original false/no-result behavior for an empty or
/// zero-denominator table or selector 15 without both cached factory rows. Other
@@ -2159,20 +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,
@@ -4274,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 {
@@ -4290,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)
}
@@ -5648,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,
@@ -5656,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,
@@ -5663,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(
@@ -5756,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,
@@ -5763,6 +6267,7 @@ fn build_control_device_projection(
prototype_span,
prototypes,
native_class,
+ &mut resolve_factory2_pair,
)
}
@@ -5773,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;
@@ -6081,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;
@@ -6121,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);
}
}
@@ -6149,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);
}
@@ -6467,14 +6992,14 @@ fn load_script_formula_table(
fn load_mission_research_trf_tables(
vfs: &Arc<dyn Vfs>,
mission: &MissionDocument,
- bundles: &[MissionScriptBundle],
+ ai_startup_states: &[MissionAiStartupState],
) -> Result<Vec<MissionResearchTrfTable>, EngineError> {
- let mut tables = Vec::with_capacity(bundles.len());
- for bundle in bundles {
- let Some(clan) = mission.clans.get(bundle.clan_index) else {
+ let mut tables = Vec::with_capacity(ai_startup_states.len());
+ for ai_state in ai_startup_states {
+ let Some(clan) = mission.clans.get(ai_state.clan_index) else {
return Err(EngineError::ScriptRuntime {
- clan_index: bundle.clan_index,
- message: "script bundle clan index is outside the decoded mission".to_string(),
+ clan_index: ai_state.clan_index,
+ message: "AI startup state clan index is outside the decoded mission".to_string(),
});
};
let second_resource = match &clan.body {
@@ -6496,7 +7021,7 @@ fn load_mission_research_trf_tables(
}
if !raw_path.is_ascii() {
return Err(EngineError::ResearchTrfPath {
- clan_index: bundle.clan_index,
+ clan_index: ai_state.clan_index,
path: raw_path.to_vec(),
});
}
@@ -6505,12 +7030,12 @@ fn load_mission_research_trf_tables(
let bytes = read_vfs(vfs, &path)?;
let document =
ResearchTrfDocument::decode(bytes).map_err(|source| EngineError::ResearchTrf {
- clan_index: bundle.clan_index,
+ clan_index: ai_state.clan_index,
path: path.as_str().to_string(),
source,
})?;
tables.push(MissionResearchTrfTable {
- clan_index: bundle.clan_index,
+ clan_index: ai_state.clan_index,
path: path.as_str().to_string(),
document,
});
@@ -6526,6 +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") {
@@ -6577,6 +7150,7 @@ fn load_mission_scripts(
};
Ok(LoadedMissionScripts {
bundles,
+ ai_startup_states,
varset,
init_states,
varset_states,
@@ -6819,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 {
@@ -6868,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>,
@@ -7232,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,
@@ -7276,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,
@@ -10062,6 +11715,7 @@ mod tests {
};
let mut scripts = LoadedMissionScripts {
bundles: vec![bundle(0, 18, Vec::new()), bundle(1, 19, vec![0, 1, 2])],
+ ai_startup_states: Vec::new(),
varset: Some(varset),
..LoadedMissionScripts::default()
};
@@ -10091,6 +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");
@@ -10471,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,
@@ -10478,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
@@ -11705,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)],
@@ -12813,12 +14722,14 @@ mod tests {
(
"MISSIONS/SCRIPTS/formula.var",
Arc::from(
- &b"VAR(DWORD, ClanBaseX, 0)\nVAR(DWORD, ClanBaseY, 0)\nVAR(DWORD, ClanID, 0)\nVAR(float, target, 0.5)\n"[..],
+ &b"VAR(DWORD, ClanBaseX, 0)\nVAR(DWORD, ClanBaseY, 0)\nVAR(DWORD, ClanID, 99)\nVAR(float, target, 0.5)\n"[..],
),
),
(
"MISSIONS/SCRIPTS/formula.fml",
- Arc::from(&b"// authored literal\n\nFUNCTION( , 0.8, )\nFUNCTION( later, target + 2, )\n"[..]),
+ Arc::from(
+ &b"// authored Init expression\n\nFUNCTION( , ClanID + 0.8, )\nFUNCTION( later, target * 2, )\n"[..],
+ ),
),
] {
files.insert(
@@ -12855,6 +14766,185 @@ mod tests {
}
#[test]
+ #[ignore = "requires licensed corpus"]
+ fn licensed_fml_variable_rows_resolve_from_shared_varset_defaults() {
+ fn formula_action(target: u32, row: u32) -> ScriptInstruction {
+ ScriptInstruction {
+ header_words: [u32::MAX, target, u32::MAX, u32::MAX, 6, 0, row],
+ references: Vec::new(),
+ }
+ }
+
+ fn init_event_index(package: &ScriptPackage) -> usize {
+ package
+ .events
+ .iter()
+ .position(|event| {
+ event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..])
+ })
+ .expect("Init event")
+ }
+
+ fn row_is_assigned(instructions: &[ScriptInstruction], row: u32, target: u32) -> bool {
+ instructions.iter().any(|instruction| {
+ instruction.header_words[0] == u32::MAX
+ && instruction.header_words[1] == target
+ && instruction.header_words[2] == u32::MAX
+ && instruction.header_words[3] == u32::MAX
+ && instruction.header_words[6] == row
+ })
+ }
+
+ let root = licensed_root("IS");
+ let script_dir = root.join("MISSIONS/SCRIPTS");
+ let declarations = fparkan_script::parse_varset(
+ &std::fs::read(script_dir.join("varset.var")).expect("read shared varset"),
+ )
+ .expect("parse shared varset");
+ let formulas3 = FormulaTable::parse(
+ &std::fs::read(script_dir.join("c1m3e.fml")).expect("read c1m3e.fml"),
+ );
+ let package3 = fparkan_script::decode(
+ &std::fs::read(script_dir.join("c1m3e.scr")).expect("read c1m3e.scr"),
+ )
+ .expect("decode c1m3e.scr");
+ let init3_index = init_event_index(&package3);
+ let init3_event_word = package3.events[init3_index].event_word;
+ let init3 = &package3.events[init3_index].instructions;
+ assert!(row_is_assigned(init3, 0, 40));
+ assert!(row_is_assigned(init3, 1, 41));
+ assert!(row_is_assigned(init3, 2, 164));
+ assert!(!row_is_assigned(init3, 19, 158));
+ assert!(!row_is_assigned(init3, 22, 174));
+ assert!(row_is_assigned(
+ &package3
+ .events
+ .iter()
+ .find(|event| event.name_raw.split(|byte| *byte == 0).next()
+ == Some(&b"Fort_Captured"[..]))
+ .expect("Fort_Captured event")
+ .instructions,
+ 19,
+ 158,
+ ));
+ assert!(row_is_assigned(
+ &package3
+ .events
+ .iter()
+ .find(|event| event.name_raw.split(|byte| *byte == 0).next()
+ == Some(&b"PBM_BUILDING_PROTECT_Start"[..]))
+ .expect("building-protection event")
+ .instructions,
+ 22,
+ 174,
+ ));
+ assert_eq!(formulas3.rows[19].expression_raw, b" 0.8 + fT");
+ assert_eq!(
+ formulas3.rows[22].expression_raw,
+ b" dBuildingProtectHits - dT2"
+ );
+
+ let f_temp = u32::try_from(
+ declarations
+ .declarations
+ .iter()
+ .position(|declaration| declaration.name == "fTemp")
+ .expect("fTemp declaration"),
+ )
+ .expect("fTemp index fits u32");
+ let d_temp3 = u32::try_from(
+ declarations
+ .declarations
+ .iter()
+ .position(|declaration| declaration.name == "dTemp3")
+ .expect("dTemp3 declaration"),
+ )
+ .expect("dTemp3 index fits u32");
+ assert_eq!((f_temp, d_temp3), (158, 174));
+
+ let mut synthetic3 = package3.clone();
+ synthetic3.events[init3_index].instructions =
+ vec![formula_action(f_temp, 19), formula_action(d_temp3, 22)];
+ assert_eq!(
+ declarations.resolve_init(
+ &synthetic3,
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas3),
+ ),
+ Ok(ScriptInitResolution::Executed {
+ event_index: init3_index,
+ event_word: init3_event_word,
+ writes: vec![
+ ScriptInitWrite {
+ index: f_temp,
+ value: VarSetDefault::FloatBits(0.8_f32.to_bits()),
+ },
+ ScriptInitWrite {
+ index: d_temp3,
+ value: VarSetDefault::Dword(2_000_000),
+ },
+ ],
+ })
+ );
+
+ let formulas4 = FormulaTable::parse(
+ &std::fs::read(script_dir.join("c1m4e.fml")).expect("read c1m4e.fml"),
+ );
+ let package4 = fparkan_script::decode(
+ &std::fs::read(script_dir.join("c1m4e.scr")).expect("read c1m4e.scr"),
+ )
+ .expect("decode c1m4e.scr");
+ let init4_index = init_event_index(&package4);
+ let init4_event_word = package4.events[init4_index].event_word;
+ let init4 = &package4.events[init4_index].instructions;
+ assert!(!row_is_assigned(init4, 0, f_temp));
+ assert!(row_is_assigned(
+ &package4
+ .events
+ .iter()
+ .find(|event| event.name_raw.split(|byte| *byte == 0).next()
+ == Some(&b"PBM_BUILDING_INF_CAPTURE_Start"[..]))
+ .expect("building-infantry-capture event")
+ .instructions,
+ 0,
+ f_temp,
+ ));
+ assert_eq!(formulas4.rows[0].expression_raw, b" fTemp + 0.00001");
+ let mut synthetic4 = package4.clone();
+ synthetic4.events[init4_index].instructions =
+ vec![formula_action(f_temp, 0), formula_action(f_temp, 0)];
+ assert_eq!(
+ declarations.resolve_init(
+ &synthetic4,
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas4),
+ ),
+ Ok(ScriptInitResolution::Executed {
+ event_index: init4_index,
+ event_word: init4_event_word,
+ writes: vec![
+ ScriptInitWrite {
+ index: f_temp,
+ value: VarSetDefault::FloatBits(0.00001_f32.to_bits()),
+ },
+ ScriptInitWrite {
+ index: f_temp,
+ value: VarSetDefault::FloatBits((0.00001_f32 + 0.00001_f32).to_bits()),
+ },
+ ],
+ })
+ );
+ }
+
+ #[test]
fn script_varset_prefers_bundle_adjacent_file_then_uses_shared_fallback() {
let mut personal_files = MemoryVfs::default();
personal_files.insert(
@@ -13021,6 +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,
@@ -13041,7 +15133,11 @@ mod tests {
references: vec![0, 1, 2],
},
fparkan_script::ScriptInstruction {
- header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0],
+ header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 6, 0, 0],
+ references: Vec::new(),
+ },
+ fparkan_script::ScriptInstruction {
+ header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 6, 0, 1],
references: Vec::new(),
},
],
@@ -13054,7 +15150,7 @@ mod tests {
base_path: "MISSIONS/SCRIPTS/default".to_string(),
script_path: "MISSIONS/SCRIPTS/default.scr".to_string(),
package: package.clone(),
- formula_table: None,
+ formula_table: Some(formula_table.clone()),
init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None,
};
@@ -13072,9 +15168,10 @@ mod tests {
preview_bundles[0].init_execution,
MissionScriptInitExecution::Unsupported {
event_index: 0,
- instruction_index: 1,
- error: ScriptInitResolveError::UnsupportedSelector {
- found: ScriptDispatchSelector::Handler(18),
+ instruction_index: 2,
+ error: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 1,
+ expression: b" x * 2".to_vec(),
},
}
);
@@ -13103,12 +15200,13 @@ mod tests {
Err(EngineError::ScriptInitUnsupported {
clan_index: 0,
event_index: 0,
- instruction_index: 1,
- source: ScriptInitResolveError::UnsupportedSelector {
- found: ScriptDispatchSelector::Handler(18),
+ instruction_index: 2,
+ source: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 1,
+ expression,
},
..
- })
+ }) if expression == b" x * 2"
));
let mut malformed_package = package.clone();
@@ -13550,6 +15648,62 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
+ fn full_load_builds_initial_behavior_control_groupings_for_autodemo() {
+ let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(
+ licensed_root("IS"),
+ ))))
+ .expect("engine");
+ load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Autodemo.00/data.tma".into(),
+ mode: MissionMode::AutoDemo,
+ },
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
+ )
+ .expect("load licensed AutoDemo with explicit startup inputs");
+
+ let groupings =
+ loaded_mission_behavior_control_groupings(&engine).expect("initial Behavior groupings");
+ let group = |header_device_ordinal, candidate_device_ordinals: &[usize]| {
+ MissionBehaviorControlGroup {
+ header_device_ordinal,
+ candidate_device_ordinals: candidate_device_ordinals.to_vec(),
+ tag30_device_ordinals: Vec::new(),
+ }
+ };
+ let expected = [
+ (0, vec![group(6, &[10, 11, 12, 13])]),
+ (1, vec![group(6, &[10, 11])]),
+ (2, vec![group(8, &[12, 13, 14])]),
+ (3, vec![group(6, &[10, 11, 12])]),
+ (4, Vec::new()),
+ (5, Vec::new()),
+ (6, Vec::new()),
+ (7, Vec::new()),
+ ];
+ assert_eq!(groupings.len(), expected.len());
+ for (original_id, expected_groups) in expected {
+ let state = groupings
+ .iter()
+ .find(|state| state.original_id == Some(OriginalObjectId(original_id)))
+ .expect("grouping for AutoDemo Control object");
+ assert_eq!(
+ state.grouping,
+ Ok(MissionBehaviorControlGrouping {
+ groups: expected_groups,
+ ungrouped_candidate_device_ordinals: Vec::new(),
+ }),
+ "original object {original_id}"
+ );
+ }
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
fn full_load_queries_control_selector_15_against_frozen_native_inputs() {
let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(
licensed_root("IS"),
@@ -13594,6 +15748,234 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
+ fn full_load_queries_indexed_control_selector_400_for_all_autodemo_devices() {
+ let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(
+ licensed_root("IS"),
+ ))))
+ .expect("engine");
+ load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Autodemo.00/data.tma".into(),
+ mode: MissionMode::AutoDemo,
+ },
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
+ )
+ .expect("load licensed AutoDemo with explicit startup inputs");
+
+ let tables = loaded_mission_control_device_tables(&engine).expect("Control tables");
+ let expected: [(u32, usize, &[u32]); 8] = [
+ (0, 33, &[0, 0, 0, 0, 0, 0, 16, 19, 16, 18, 27, 30, 31, 32]),
+ (1, 23, &[0, 0, 0, 0, 0, 0, 10, 13, 10, 11, 19, 22]),
+ (2, 26, &[0, 0, 0, 0, 0, 0, 0, 0, 8, 9, 8, 10, 19, 22, 25]),
+ (3, 29, &[0, 0, 0, 0, 0, 0, 16, 21, 16, 20, 26, 27, 28]),
+ (4, 32, &[5, 5, 1, 23, 3, 22, 7, 19, 16, 23, 5, 29]),
+ (5, 32, &[5, 5, 1, 23, 3, 22, 7, 19, 16, 23, 5, 29]),
+ (6, 22, &[17, 19, 19, 15, 13, 14, 10, 11, 4, 5, 7]),
+ (7, 22, &[17, 19, 19, 15, 13, 14, 10, 11, 4, 5, 7]),
+ ];
+ assert_eq!(tables.len(), expected.len());
+
+ let mut queried_devices = 0;
+ for (original_id, expected_provider_rows, expected_keys) in expected {
+ let table = tables
+ .iter()
+ .find(|table| table.original_id == Some(OriginalObjectId(original_id)))
+ .expect("AutoDemo Control table for original object");
+ let devices = match &table.status {
+ MissionControlDeviceTableStatus::Available(devices) => devices,
+ MissionControlDeviceTableStatus::Unavailable(reason) => {
+ panic!("Control table for object {original_id} is unavailable: {reason:?}");
+ }
+ };
+ let provider_rows = match &table.node_provider_status {
+ MissionControlNodeProviderTableStatus::Available(rows) => rows,
+ MissionControlNodeProviderTableStatus::Unavailable(reason) => {
+ panic!("provider rows for object {original_id} are unavailable: {reason:?}");
+ }
+ };
+ assert_eq!(
+ devices.len(),
+ expected_keys.len(),
+ "device count for {original_id}"
+ );
+ assert_eq!(
+ provider_rows.len(),
+ expected_provider_rows,
+ "provider row count for {original_id}"
+ );
+
+ for (device_index, expected_key) in expected_keys.iter().copied().enumerate() {
+ let device = &devices[device_index];
+ assert_eq!(
+ device.key, expected_key,
+ "provider key for object {original_id}, device {device_index}"
+ );
+ assert!(
+ (expected_key as usize) < provider_rows.len(),
+ "provider key for object {original_id}, device {device_index}"
+ );
+ let actual = query_loaded_mission_control_device_selector(
+ &engine,
+ table.handle,
+ device_index,
+ 0x400,
+ )
+ .expect("selector 0x400 query");
+ assert_eq!(
+ actual.map(f32::to_bits),
+ Some(0x3f80_0000),
+ "current provider value for object {original_id}, device {device_index}, key {expected_key}"
+ );
+ queried_devices += 1;
+ }
+ }
+ assert_eq!(queried_devices, 100);
+ }
+
+ #[test]
+ #[ignore = "requires licensed 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"),
@@ -14640,6 +17022,50 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
+ fn licensed_single_script_bundle_load_initializes_ai_slots_before_later_init_work() {
+ let root = licensed_root("IS");
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
+ let path = normalize_engine_path("mission", "MISSIONS/Single.01/data.tma")
+ .expect("Single.01 path");
+ let mission = decode_mission_payload(
+ read_vfs(&vfs, &path).expect("Single.01 TMA"),
+ TmaProfile::Strict,
+ )
+ .expect("decode Single.01 TMA");
+ assert_eq!(script_slots_test_tags(&mission), [9, 9]);
+
+ let full = load_mission_scripts(&vfs, &mission, MissionAssetScope::Full)
+ .expect("Full script bundle load");
+ assert_eq!(
+ full.ai_startup_states
+ .iter()
+ .map(|state| state.record_slots.len())
+ .collect::<Vec<_>>(),
+ [9, 9]
+ );
+ assert_eq!(
+ full.ai_startup_states
+ .iter()
+ .map(|state| state.clan_index)
+ .collect::<Vec<_>>(),
+ [0, 1]
+ );
+ assert!(full
+ .ai_startup_states
+ .iter()
+ .all(|state| { state.record_slots == [u32::MAX; 9] }));
+ assert!(full
+ .bundles
+ .iter()
+ .all(|bundle| bundle.init_execution == MissionScriptInitExecution::Missing));
+
+ let preview = load_mission_scripts(&vfs, &mission, MissionAssetScope::FirstMeshPreview)
+ .expect("preview script bundle load");
+ assert!(preview.ai_startup_states.is_empty());
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
fn licensed_single_preview_loads_each_formula_table_and_stops_at_handler43() {
let root = licensed_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
@@ -14654,6 +17080,10 @@ mod tests {
)
.expect("load licensed Single.01 preview");
+ assert!(loaded_mission_behavior_control_groupings(&engine)
+ .expect("preview grouping projection")
+ .is_empty());
+
let bundles = loaded_mission_script_bundles(&engine).expect("script bundles");
assert_eq!(bundles.len(), 2);
let first = bundles
@@ -15058,16 +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();
@@ -15112,11 +17557,13 @@ mod tests {
assert!(matches!(
error,
EngineError::ScriptInitUnsupported {
- source: ScriptInitResolveError::UnsupportedSelector {
- found: ScriptDispatchSelector::Handler(18),
+ instruction_index,
+ source: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 1,
+ expression,
},
..
- }
+ } if instruction_index == original_instruction_count + 1 && expression == b" fT * 2"
));
assert_eq!(loaded_mission(&engine), Some(&previous_loaded));
assert_eq!(
@@ -15510,6 +17957,15 @@ mod tests {
research_paths: &[&[u8]],
trf: Option<&[u8]>,
) -> Arc<dyn Vfs> {
+ research_startup_test_vfs_with_raw_tag(script_bases, research_paths, 9, trf)
+ }
+
+ fn research_startup_test_vfs_with_raw_tag(
+ script_bases: &[Option<&[u8]>],
+ research_paths: &[&[u8]],
+ raw_tag: i32,
+ trf: Option<&[u8]>,
+ ) -> Arc<dyn Vfs> {
fn push_u32(bytes: &mut Vec<u8>, value: u32) {
bytes.extend_from_slice(&value.to_le_bytes());
}
@@ -15537,7 +17993,7 @@ mod tests {
push_lp(&mut mission, script_base.unwrap_or_default());
push_u32(&mut mission, 0);
push_lp(&mut mission, research_path);
- push_u32(&mut mission, 9);
+ push_u32(&mut mission, raw_tag as u32);
push_u32(&mut mission, 0);
}
push_u32(&mut mission, 10);
@@ -15594,12 +18050,103 @@ mod tests {
}
#[test]
- fn full_load_keeps_independent_trf_tables_for_each_script_bundle_clan() {
+ fn ai_record_slot_budget_error_keeps_loaded_mission_clock_and_rng() {
+ let valid_vfs = research_startup_test_vfs_with_raw_tag(
+ &[Some(b"MISSIONS/SCRIPTS/shared"), None],
+ &[b"", b""],
+ 9,
+ None,
+ );
+ let mut engine = create(EngineServices::new(valid_vfs)).expect("startup test engine");
+ let request = || MissionRequest {
+ key: "MISSIONS/TEST/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ };
+ load_mission(
+ &mut engine,
+ request(),
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
+ )
+ .expect("initial valid full load");
+ let previous_mission = loaded_mission(&engine).expect("loaded mission").clone();
+ let previous_bundles = loaded_mission_script_bundles(&engine)
+ .expect("loaded script bundles")
+ .to_vec();
+ let previous_ai_startup_states = loaded_mission_ai_startup_states(&engine)
+ .expect("loaded AI startup states")
+ .to_vec();
+ assert_eq!(previous_ai_startup_states.len(), 2);
+ assert_eq!(
+ previous_ai_startup_states
+ .iter()
+ .map(|state| state.clan_index)
+ .collect::<Vec<_>>(),
+ [0, 1]
+ );
+ assert!(previous_ai_startup_states
+ .iter()
+ .all(|state| state.record_slots == [u32::MAX; 9]));
+ assert_eq!(previous_bundles.len(), 1);
+ assert_eq!(previous_bundles[0].clan_index, 0);
+ assert_eq!(
+ previous_bundles[0].behavior_warm_start,
+ Some(BehaviorWarmStartState {
+ first_time_ms: 100_000,
+ next_deadline_ms: 100_202,
+ cleared_words: [0; 5],
+ })
+ );
+ let previous_rng = engine.behavior_rng_state;
+ assert_ne!(previous_rng, INITIAL_BEHAVIOR_RNG_STATE);
+ let previous_world = fparkan_world::canonical_state_hash(&engine.world);
+
+ engine.services.vfs = Some(research_startup_test_vfs_with_raw_tag(
+ &[None, None],
+ &[b"", b""],
+ -1,
+ None,
+ ));
+ let error = load_mission(
+ &mut engine,
+ request(),
+ MissionStartupInput {
+ queue_time_ms: 100_500,
+ research_creation_gate: false,
+ },
+ )
+ .expect_err("negative raw tag is unsigned u32::MAX and exceeds the budget");
+
+ assert!(matches!(
+ error,
+ EngineError::MissionAiRecordSlots {
+ clan_index: 0,
+ raw_count: u32::MAX,
+ ..
+ }
+ ));
+ assert_eq!(loaded_mission(&engine), Some(&previous_mission));
+ assert_eq!(
+ loaded_mission_script_bundles(&engine),
+ Some(previous_bundles.as_slice())
+ );
+ assert_eq!(
+ loaded_mission_ai_startup_states(&engine),
+ Some(previous_ai_startup_states.as_slice())
+ );
+ assert_eq!(engine.behavior_rng_state, previous_rng);
+ assert_eq!(
+ fparkan_world::canonical_state_hash(&engine.world),
+ previous_world
+ );
+ }
+
+ #[test]
+ fn full_load_keeps_independent_trf_tables_for_each_ai_state_clan() {
let vfs = research_startup_test_vfs(
- &[
- Some(b"MISSIONS/SCRIPTS/shared"),
- Some(b"MISSIONS/SCRIPTS/shared"),
- ],
+ &[Some(b"MISSIONS/SCRIPTS/shared"), None],
&[
b"DATA/Research/shared.trf\0ignored.trf",
b"DATA/Research/shared.trf",
@@ -15622,13 +18169,21 @@ mod tests {
.expect("full load with explicit research gate");
let tables = loaded_mission_research_trf_tables(&engine).expect("TRF tables");
- assert_eq!(tables.len(), 2, "each loaded script clan owns a table");
+ assert_eq!(tables.len(), 2, "each AI state owner gets a table");
+ let bundles = loaded_mission_script_bundles(&engine).expect("script bundles");
+ assert_eq!(bundles.len(), 1, "only the scripted clan has a bundle");
+ assert_eq!(bundles[0].clan_index, 0);
+ let ai_states = loaded_mission_ai_startup_states(&engine).expect("AI states");
assert_eq!(
- loaded_mission_script_bundles(&engine)
- .expect("script bundles")
- .len(),
- 2
+ ai_states
+ .iter()
+ .map(|state| state.clan_index)
+ .collect::<Vec<_>>(),
+ [0, 1]
);
+ assert!(ai_states
+ .iter()
+ .all(|state| state.record_slots == [u32::MAX; 9]));
assert_eq!(tables[0].clan_index, 0);
assert_eq!(tables[0].path, "DATA/Research/shared.trf");
assert_eq!(tables[1].clan_index, 1);
@@ -15646,7 +18201,7 @@ mod tests {
}
#[test]
- fn full_load_skips_research_trf_when_gate_bundle_or_c_string_is_missing() {
+ fn full_load_skips_research_trf_when_gate_or_c_string_is_missing() {
for (script_bases, research_paths, gate, mode) in [
(
vec![Some(&b"MISSIONS/SCRIPTS/shared"[..])],
@@ -15666,12 +18221,6 @@ mod tests {
true,
MissionMode::SingleMission,
),
- (
- vec![None],
- vec![&b"DATA/Research/missing.trf"[..]],
- true,
- MissionMode::SingleMission,
- ),
] {
let vfs = research_startup_test_vfs(&script_bases, &research_paths, None);
let mut engine =
@@ -15695,10 +18244,36 @@ mod tests {
}
#[test]
- fn research_trf_read_and_decode_errors_keep_the_loaded_world_and_rng() {
- let initial_vfs = research_startup_test_vfs(
+ fn static_preview_skips_research_trf_reads() {
+ let vfs = research_startup_test_vfs(
&[Some(b"MISSIONS/SCRIPTS/shared")],
- &[b"DATA/Research/shared.trf"],
+ &[b"DATA/Research/missing.trf"],
+ None,
+ );
+ let mut engine = create(EngineServices::new(vfs)).expect("research startup test engine");
+
+ load_mission_static_preview(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/TEST/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ )
+ .expect("preview must not read research TRF paths");
+
+ assert!(loaded_mission_research_trf_tables(&engine)
+ .expect("TRF state")
+ .is_empty());
+ assert!(loaded_mission_ai_startup_states(&engine)
+ .expect("AI state")
+ .is_empty());
+ }
+
+ #[test]
+ fn no_script_ai_research_trf_read_and_decode_errors_keep_loaded_world_and_rng() {
+ let initial_vfs = research_startup_test_vfs(
+ &[Some(b"MISSIONS/SCRIPTS/shared"), None],
+ &[b"", b"DATA/Research/shared.trf"],
Some(&research_test_trf()),
);
let mut engine = create(EngineServices::new(initial_vfs)).expect("research startup engine");
@@ -15730,8 +18305,8 @@ mod tests {
for trf in [None, Some(&b"malformed"[..])] {
let vfs = research_startup_test_vfs(
- &[Some(b"MISSIONS/SCRIPTS/shared")],
- &[b"DATA/Research/shared.trf"],
+ &[Some(b"MISSIONS/SCRIPTS/shared"), None],
+ &[b"", b"DATA/Research/shared.trf"],
trf,
);
engine.services.vfs = Some(vfs);
@@ -15775,7 +18350,7 @@ mod tests {
clan_index, path, ..
},
) => {
- assert_eq!(clan_index, 0);
+ assert_eq!(clan_index, 1);
assert_eq!(path, "DATA/Research/shared.trf");
}
(_, error) => panic!("unexpected TRF failure: {error:?}"),
@@ -15786,8 +18361,8 @@ mod tests {
#[test]
fn research_trf_c_string_paths_reject_non_ascii_bytes() {
let vfs = research_startup_test_vfs(
- &[Some(b"MISSIONS/SCRIPTS/shared")],
- &[b"DATA/Research/\xe9.trf"],
+ &[Some(b"MISSIONS/SCRIPTS/shared"), None],
+ &[b"", b"DATA/Research/\xe9.trf"],
None,
);
let mut engine = create(EngineServices::new(vfs)).expect("research startup test engine");
@@ -15805,14 +18380,14 @@ mod tests {
.expect_err("non-ASCII TRF path must fail explicitly");
assert!(matches!(
error,
- EngineError::ResearchTrfPath { clan_index: 0, path }
+ EngineError::ResearchTrfPath { clan_index: 1, path }
if path == b"DATA/Research/\xe9.trf"
));
}
#[test]
#[ignore = "requires licensed corpus"]
- fn licensed_campaign_full_load_creates_trf_tables_for_script_clan_owners() {
+ fn licensed_campaign_full_load_creates_trf_tables_for_ai_clan_owners() {
let root = licensed_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
let mut engine = create(EngineServices::new(vfs)).expect("engine");
@@ -15872,24 +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],
});
}
@@ -15897,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)
}