aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/fparkan-assets/src/lib.rs511
-rw-r--r--crates/fparkan-render/src/lib.rs127
-rw-r--r--crates/fparkan-runtime/Cargo.toml1
-rw-r--r--crates/fparkan-runtime/src/lib.rs2770
-rw-r--r--crates/fparkan-script/src/lib.rs486
5 files changed, 3757 insertions, 138 deletions
diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs
index 8976a55..a1ae4ec 100644
--- a/crates/fparkan-assets/src/lib.rs
+++ b/crates/fparkan-assets/src/lib.rs
@@ -2112,6 +2112,33 @@ impl<R: ResourceRepository> AssetManager<R> {
) -> Result<MissionAssetPlan, AssetError> {
build_mission_asset_plan_with_repository(&self.repository, prototypes)
}
+
+ /// Reads raw opcodes from the validated initial Control effect group.
+ ///
+ /// A complete no-effect section or an out-of-range raw initial-group
+ /// selector returns `Some(Vec::new())`; unsupported shapes return `None`.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AssetError`] when the Control resource is malformed.
+ pub fn initial_control_effect_opcodes(
+ &self,
+ source: &ResourceKey,
+ model_node_count: usize,
+ prototype_index: usize,
+ ) -> Result<Option<Vec<u32>>, AssetError> {
+ let Some(payload) = read_optional_key(&self.repository, source, Some("component control"))?
+ else {
+ return Ok(None);
+ };
+ let (_, opcodes) = decode_component_control_payload_with_opcodes(
+ &payload,
+ source.clone(),
+ model_node_count,
+ prototype_index,
+ )?;
+ Ok(opcodes)
+ }
}
/// Produces a count-only plan from a prototype graph.
@@ -3867,7 +3894,7 @@ fn control_i32_at(payload: &[u8], offset: usize) -> Option<i32> {
}
const CONTROL_TYPED_COMMON_PARSER_TAGS: &[u32] =
- &[1, 2, 3, 4, 5, 8, 9, 10, 12, 13, 15, 19, 21, 25, 26, 27];
+ &[1, 2, 3, 4, 5, 8, 9, 10, 12, 13, 15, 17, 19, 21, 25, 26, 27];
const CONTROL_TYPED_RECORD_FIXED_BYTES: usize = 0xB0;
const CONTROL_TYPED_RECORD_COUNT_OFFSET: usize = 0xAC;
const CONTROL_TYPED_RECORD_LIMIT: usize = 4096;
@@ -3881,6 +3908,7 @@ const CONTROL_EFFECT_ROW_BYTES: usize = 100;
struct DecodedComponentEffectSection {
section: PreparedControlEffectSection,
initial_effect_group: Option<usize>,
+ initial_group_opcodes: Option<Vec<u32>>,
typed_records: Vec<PreparedControlTypedRecord>,
}
@@ -3905,6 +3933,7 @@ where
}
},
initial_effect_group: None,
+ initial_group_opcodes: (cursor == payload.len()).then(Vec::new),
typed_records: Vec::new(),
});
}
@@ -3926,6 +3955,7 @@ where
typed_record_tag: Some(tag),
},
initial_effect_group: None,
+ initial_group_opcodes: None,
typed_records,
});
}
@@ -4015,8 +4045,9 @@ where
}
if effect_group_count == 0 {
+ let complete = cursor == payload.len();
return Ok(DecodedComponentEffectSection {
- section: if cursor == payload.len() {
+ section: if complete {
PreparedControlEffectSection::Parsed(Vec::new())
} else {
PreparedControlEffectSection::Unsupported {
@@ -4025,6 +4056,7 @@ where
}
},
initial_effect_group: None,
+ initial_group_opcodes: complete.then(Vec::new),
typed_records,
});
}
@@ -4043,6 +4075,7 @@ where
.filter(|group| *group < effect_group_count);
cursor = group_header_end;
+ let mut initial_group_opcodes = Some(Vec::new());
let mut effect_rows = Vec::new();
for table_index in 0..effect_group_count {
let row_count = usize::try_from(
@@ -4069,7 +4102,14 @@ where
for row_index in 0..row_count {
let row_start = rows_start + row_index * CONTROL_EFFECT_ROW_BYTES;
let row = &payload[row_start..row_start + CONTROL_EFFECT_ROW_BYTES];
- if control_u32_at(row, 0x0C) != Some(4) {
+ let opcode = control_u32_at(row, 0x0C).expect("fixed effect row has opcode");
+ if initial_effect_group == Some(table_index) {
+ initial_group_opcodes
+ .as_mut()
+ .expect("valid initial group has an opcode list")
+ .push(opcode);
+ }
+ if opcode != 4 {
continue;
}
let raw_condition = [
@@ -4108,12 +4148,14 @@ where
typed_record_tag: None,
},
initial_effect_group: None,
+ initial_group_opcodes: None,
typed_records,
});
}
Ok(DecodedComponentEffectSection {
section: PreparedControlEffectSection::Parsed(effect_rows),
initial_effect_group,
+ initial_group_opcodes,
typed_records,
})
}
@@ -4130,6 +4172,21 @@ fn decode_component_control_payload(
model_node_count: usize,
prototype_index: usize,
) -> Result<PreparedComponentControl, AssetError> {
+ decode_component_control_payload_with_opcodes(
+ payload,
+ source,
+ model_node_count,
+ prototype_index,
+ )
+ .map(|(control, _)| control)
+}
+
+fn decode_component_control_payload_with_opcodes(
+ payload: &[u8],
+ source: ResourceKey,
+ model_node_count: usize,
+ prototype_index: usize,
+) -> Result<(PreparedComponentControl, Option<Vec<u32>>), AssetError> {
const HEADER_BYTES: usize = 5 * 4;
const FIXED_SECTION_BYTES: usize = 108;
const SECTION_FIXED_BYTES: usize = 156;
@@ -4267,18 +4324,22 @@ fn decode_component_control_payload(
&invalid,
)?;
- Ok(PreparedComponentControl {
- source,
- native_profile_words,
- model_node_count,
- bindings,
- timeline_rows,
- typed_records: decoded_effect.typed_records,
- node_data: None,
- effect_section: decoded_effect.section,
- initial_effect_group: decoded_effect.initial_effect_group,
- point_list: None,
- })
+ let initial_group_opcodes = decoded_effect.initial_group_opcodes;
+ Ok((
+ PreparedComponentControl {
+ source,
+ native_profile_words,
+ model_node_count,
+ bindings,
+ timeline_rows,
+ typed_records: decoded_effect.typed_records,
+ node_data: None,
+ effect_section: decoded_effect.section,
+ initial_effect_group: decoded_effect.initial_effect_group,
+ point_list: None,
+ },
+ initial_group_opcodes,
+ ))
}
fn sibling_name(key: &ResourceKey, extension: &str) -> Result<ResourceName, AssetError> {
@@ -4377,7 +4438,8 @@ impl Hasher for StableHasher {
#[cfg(test)]
mod tests {
use super::*;
- use fparkan_prototype::build_prototype_graph;
+ use fparkan_binary::{sha256, sha256_hex};
+ use fparkan_prototype::{build_prototype_graph, resolve_prototype_all};
use fparkan_resource::{resource_name, CachedResourceRepository};
use fparkan_vfs::{DirectoryVfs, MemoryVfs, Vfs};
use std::path::PathBuf;
@@ -6534,6 +6596,132 @@ mod tests {
}
#[test]
+ fn component_control_tag17_shares_common_cursor_and_preserves_raw_record_tails() {
+ let source = ResourceKey {
+ archive: parse_path("controls.rlb").expect("archive"),
+ name: resource_name(b"parts.ctl"),
+ type_id: None,
+ };
+ let mut payload = component_control_payload(&[(1, 0.0, 3.0, 0.5, 0)]);
+ payload[12..16].copy_from_slice(&2_u32.to_le_bytes());
+ payload[16..20].copy_from_slice(&1_u32.to_le_bytes());
+
+ let tag17_start = payload.len();
+ append_typed_record(&mut payload, 17, 2);
+ payload[tag17_start + 0x14..tag17_start + 0x18]
+ .copy_from_slice(&0xAABB_CCDDu32.to_le_bytes());
+ payload[tag17_start + 0xAC..tag17_start + 0xB0].copy_from_slice(&2_u32.to_le_bytes());
+ payload.splice(
+ tag17_start + 0xB0..tag17_start + 0xB0,
+ [0x1122_3344u32.to_le_bytes(), 0x5566_7788u32.to_le_bytes()].concat(),
+ );
+ payload.extend_from_slice(b"fx\0");
+
+ let known_start = payload.len();
+ append_typed_record(&mut payload, 4, 3);
+ payload[known_start + 0x28..known_start + 0x2C]
+ .copy_from_slice(&0xCAFE_D00Du32.to_le_bytes());
+ payload[known_start + 0xAC..known_start + 0xB0].copy_from_slice(&1_u32.to_le_bytes());
+ payload.splice(
+ known_start + 0xB0..known_start + 0xB0,
+ 0x0102_0304u32.to_le_bytes(),
+ );
+ payload.extend_from_slice(b"ctl\0");
+
+ let group_header = payload.len();
+ payload.resize(group_header + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0);
+ append_effect_table(
+ &mut payload,
+ &[effect_row(
+ 4,
+ [4, 5, 6],
+ [12, 13, 14],
+ 15,
+ "effects.rlb",
+ "first",
+ )],
+ );
+
+ let control = decode_component_control_payload(&payload, source, 2, 0)
+ .expect("tag 17 and the following known record share the typed cursor");
+
+ assert_eq!(
+ control
+ .typed_records
+ .iter()
+ .map(|record| record.tag)
+ .collect::<Vec<_>>(),
+ [17, 4]
+ );
+ assert_eq!(control.typed_records[0].fixed_words[5], 0xAABB_CCDD);
+ assert_eq!(control.typed_records[0].values, [0x1122_3344, 0x5566_7788]);
+ assert_eq!(control.typed_records[0].string_bytes, b"fx");
+ assert_eq!(
+ control.typed_records[0].gate_provider,
+ PreparedControlGateProvider::NotApplicable
+ );
+ assert_eq!(control.typed_records[1].fixed_words[10], 0xCAFE_D00D);
+ assert_eq!(control.typed_records[1].values, [0x0102_0304]);
+ assert_eq!(control.typed_records[1].string_bytes, b"ctl");
+ assert_eq!(control.initial_effect_group, Some(0));
+ assert_eq!(
+ control.effect_section,
+ PreparedControlEffectSection::Parsed(vec![PreparedControlEffectRow {
+ table_index: 0,
+ row_index: 0,
+ raw_condition: [4, 5, 6],
+ point_offsets: [12, 13, 14],
+ selector: 15,
+ resource: PreparedControlResourcePair {
+ archive: fixed_name("effects.rlb"),
+ name: fixed_name("first"),
+ },
+ }])
+ );
+ }
+
+ #[test]
+ fn component_control_truncated_tag17_frames_are_rejected() {
+ let source = ResourceKey {
+ archive: parse_path("controls.rlb").expect("archive"),
+ name: resource_name(b"parts.ctl"),
+ type_id: None,
+ };
+ let mut valid = component_control_payload(&[]);
+ valid[12..16].copy_from_slice(&1_u32.to_le_bytes());
+ let record_start = valid.len();
+ append_typed_record(&mut valid, 17, 2);
+ valid[record_start + 0xAC..record_start + 0xB0].copy_from_slice(&2_u32.to_le_bytes());
+ valid.splice(
+ record_start + 0xB0..record_start + 0xB0,
+ [0x1234_5678u32.to_le_bytes(), 0x89AB_CDEFu32.to_le_bytes()].concat(),
+ );
+ valid.extend_from_slice(b"xy\0");
+
+ let assert_invalid = |payload: Vec<u8>, label: &str| {
+ assert!(
+ matches!(
+ decode_component_control_payload(&payload, source.clone(), 0, 0),
+ Err(AssetError::InvalidPrototype(_))
+ ),
+ "truncated recognized tag 17 {label} must be InvalidPrototype"
+ );
+ };
+
+ for (end, label) in [
+ (record_start + 0xAF, "prefix"),
+ (record_start + 0xB0 + 4, "values"),
+ (record_start + 0xB0 + 8, "length word"),
+ (record_start + 0xB0 + 8 + 4 + 2, "string bytes"),
+ ] {
+ assert_invalid(valid[..end].to_vec(), label);
+ }
+ let mut bad_nul = valid;
+ *bad_nul.last_mut().expect("string has trailing NUL") = b'!';
+ assert_invalid(bad_nul, "trailing NUL");
+ }
+
+ #[test]
fn component_control_malformed_recognized_typed_tail_is_rejected() {
let source = ResourceKey {
archive: parse_path("controls.rlb").expect("archive"),
@@ -6677,6 +6865,138 @@ mod tests {
}
#[test]
+ fn initial_control_effect_opcodes_keep_raw_selected_rows_in_order() {
+ let source = ResourceKey {
+ archive: parse_path("controls.rlb").expect("archive"),
+ name: resource_name(b"parts.ctl"),
+ type_id: None,
+ };
+ let mut payload = component_control_payload(&[]);
+ payload[16..20].copy_from_slice(&1_u32.to_le_bytes());
+ let group_header = payload.len();
+ payload.resize(group_header + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0);
+ let opcodes = [1, 4, 4, 12];
+ append_effect_table(
+ &mut payload,
+ &opcodes.map(|opcode| effect_row(opcode, [0; 3], [0; 3], 0, "", "")),
+ );
+
+ let (control, decoded_opcodes) =
+ decode_component_control_payload_with_opcodes(&payload, source.clone(), 2, 0)
+ .expect("complete selected group");
+ assert_eq!(decoded_opcodes, Some(opcodes.to_vec()));
+ let PreparedControlEffectSection::Parsed(rows) = control.effect_section else {
+ panic!("opcode-4 projection should remain parsed");
+ };
+ assert_eq!(
+ rows.iter().map(|row| row.row_index).collect::<Vec<_>>(),
+ [1, 2]
+ );
+
+ let repository = repository_with_archives(&[("controls.rlb", &[(b"parts.ctl", &payload)])]);
+ let manager = AssetManager::new(repository);
+ assert_eq!(
+ manager
+ .initial_control_effect_opcodes(&source, 2, 0)
+ .expect("public raw-opcode scanner"),
+ Some(opcodes.to_vec())
+ );
+ }
+
+ #[test]
+ fn initial_control_effect_opcodes_distinguish_complete_empty_from_unknown_shapes() {
+ let source = |name: &[u8]| ResourceKey {
+ archive: parse_path("controls.rlb").expect("archive"),
+ name: resource_name(name),
+ type_id: None,
+ };
+ let empty = component_control_payload(&[]);
+
+ let mut selected_noop = component_control_payload(&[]);
+ selected_noop[16..20].copy_from_slice(&1_u32.to_le_bytes());
+ let noop_header = selected_noop.len();
+ selected_noop.resize(noop_header + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0);
+ append_effect_table(&mut selected_noop, &[]);
+
+ let mut unsupported = component_control_payload(&[]);
+ unsupported[12..16].copy_from_slice(&1_u32.to_le_bytes());
+ append_typed_record(&mut unsupported, 99, 0);
+
+ let mut trailing_data = component_control_payload(&[]);
+ trailing_data.push(0xAA);
+ let repository = repository_with_archives(&[(
+ "controls.rlb",
+ &[
+ (b"empty.ctl", &empty),
+ (b"noop.ctl", &selected_noop),
+ (b"unsupported.ctl", &unsupported),
+ (b"trailing.ctl", &trailing_data),
+ ],
+ )]);
+ let manager = AssetManager::new(repository);
+
+ assert_eq!(
+ manager
+ .initial_control_effect_opcodes(&source(b"empty.ctl"), 2, 0)
+ .expect("complete empty CTLD"),
+ Some(Vec::new())
+ );
+ assert_eq!(
+ manager
+ .initial_control_effect_opcodes(&source(b"noop.ctl"), 2, 0)
+ .expect("complete no-op selected group"),
+ Some(Vec::new())
+ );
+ for name in [b"unsupported.ctl".as_slice(), b"trailing.ctl".as_slice()] {
+ assert_eq!(
+ manager
+ .initial_control_effect_opcodes(&source(name), 2, 0)
+ .expect("unsupported but bounded shape"),
+ None,
+ "{} remains unavailable",
+ String::from_utf8_lossy(name)
+ );
+ }
+ }
+
+ #[test]
+ fn initial_control_effect_opcodes_treat_out_of_range_initial_indices_as_known_empty() {
+ let make_payload = |raw_initial_group: u32| {
+ let mut payload = component_control_payload(&[]);
+ payload[16..20].copy_from_slice(&1_u32.to_le_bytes());
+ let group_header = payload.len();
+ payload.resize(group_header + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0);
+ payload[group_header..group_header + 4]
+ .copy_from_slice(&raw_initial_group.to_le_bytes());
+ append_effect_table(&mut payload, &[effect_row(1, [0; 3], [0; 3], 0, "", "")]);
+ payload
+ };
+ let sentinel = make_payload(u32::MAX);
+ let exact_count = make_payload(1);
+ let repository = repository_with_archives(&[(
+ "controls.rlb",
+ &[
+ (b"sentinel.ctl", &sentinel),
+ (b"exact-count.ctl", &exact_count),
+ ],
+ )]);
+ let manager = AssetManager::new(repository);
+ let sources =
+ [b"sentinel.ctl".as_slice(), b"exact-count.ctl".as_slice()].map(|name| ResourceKey {
+ archive: parse_path("controls.rlb").expect("archive"),
+ name: resource_name(name),
+ type_id: None,
+ });
+ let results = sources.map(|source| {
+ manager
+ .initial_control_effect_opcodes(&source, 2, 0)
+ .expect("complete CTLD frames")
+ });
+
+ assert_eq!(results, [Some(Vec::new()), Some(Vec::new())]);
+ }
+
+ #[test]
fn tag21_gate_provider_comes_from_validated_explicit_ndp() {
let source = ResourceKey {
archive: parse_path("parts.rlb").expect("CTL archive"),
@@ -8327,6 +8647,165 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
+ fn tag17_bareobject_controls_preserve_explicit_assets_and_real_effect_rows() {
+ let root = fixture_root("IS");
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
+ let repository = CachedResourceRepository::new(Arc::clone(&vfs));
+ let cases = [
+ (
+ "bm_m_01",
+ "bm_m_01.ctl",
+ "bm_m_a.cpt",
+ "bm_m_01.ndp",
+ "e45658e9f3a6b72519eb47bd03e3f41ebde12ab361361297ccb916d0aed5894d",
+ 0x3F66_6666,
+ 0x458C_A000,
+ ["engine_mis_m", "smoke_m_ms_01", "gunf_missile_m"],
+ ),
+ (
+ "bm_l_01",
+ "bm_l_01.ctl",
+ "bm_l_a.cpt",
+ "bm_l_01.ndp",
+ "14fadfa27a4d4eab6ab9758f76a89031e085e880b42085219fa117562ab398cc",
+ 0x3F59_999A,
+ 0x457A_0000,
+ ["engine_mis_l", "smoke_l_ms_01", "gunf_missile_l"],
+ ),
+ ];
+
+ for (
+ root_name,
+ control_name,
+ point_name,
+ ndp_name,
+ payload_hash,
+ word11_bits,
+ word13_bits,
+ names,
+ ) in cases
+ {
+ let prototypes = resolve_prototype_all(
+ &repository,
+ vfs.as_ref(),
+ &resource_name(root_name.as_bytes()),
+ )
+ .expect("resolve the original bare-object registry entry");
+ assert_eq!(prototypes.len(), 1, "{root_name}");
+ let prototype = &prototypes[0];
+ let control_source = prototype
+ .control_resource
+ .clone()
+ .expect("bare object has an explicit CTL reference");
+ let point_source = prototype
+ .point_resource
+ .clone()
+ .expect("bare object has an explicit CPT reference");
+ let ndp_source = prototype
+ .node_data_resource
+ .clone()
+ .expect("bare object has an explicit NDP reference");
+ assert_eq!(control_source.archive.as_str(), "weapon.rlb");
+ assert_eq!(control_source.name, resource_name(control_name.as_bytes()));
+ assert_eq!(point_source.archive.as_str(), "weapon.rlb");
+ assert_eq!(point_source.name, resource_name(point_name.as_bytes()));
+ assert_eq!(ndp_source.archive.as_str(), "weapon.rlb");
+ assert_eq!(ndp_source.name, resource_name(ndp_name.as_bytes()));
+
+ let archive_id = repository
+ .open_archive(&control_source.archive)
+ .expect("open the referenced CTL archive");
+ let entry = repository
+ .find(archive_id, &control_source.name)
+ .expect("find the referenced CTL")
+ .expect("referenced CTL exists");
+ let payload = repository.read(entry).expect("read the referenced CTL");
+ assert_eq!(
+ sha256_hex(&sha256(payload.as_slice())),
+ payload_hash,
+ "{root_name} CTL payload hash"
+ );
+
+ let assets = prepare_mission_assets_with_repository(
+ &repository,
+ &[0..prototypes.len()],
+ &prototypes,
+ )
+ .expect("prepare the resolved object through the production asset path");
+ assert_eq!(assets.object_component_controls.len(), 1, "{root_name}");
+ assert_eq!(assets.object_component_controls[0].len(), 1, "{root_name}");
+ let control = assets.object_component_controls[0][0]
+ .as_ref()
+ .expect("the resolved component has prepared control");
+ assert_eq!(control.source, control_source, "{root_name} CTL source");
+ let point_list = control
+ .point_list
+ .as_ref()
+ .expect("the parsed effect rows load the explicitly registered CPT");
+ assert_eq!(point_list.source, point_source, "{root_name} CPT source");
+ assert!(
+ point_list.points.len() >= 3,
+ "{root_name} effect point indices"
+ );
+ let node_data = control
+ .node_data
+ .as_ref()
+ .expect("the explicit NDP remains attached");
+ assert_eq!(node_data.source, ndp_source, "{root_name} NDP source");
+ assert_eq!(node_data.rows.len(), 1, "{root_name} NDP rows");
+
+ assert_eq!(control.typed_records.len(), 1, "{root_name}");
+ let record = &control.typed_records[0];
+ assert_eq!(record.tag, 17, "{root_name}");
+ let mut expected_fixed_words = [0_u32; 44];
+ expected_fixed_words[0] = 17;
+ expected_fixed_words[3..=6].fill(u32::MAX);
+ expected_fixed_words[9] = 0x3F80_0000;
+ expected_fixed_words[10] = 0x3F80_0000;
+ expected_fixed_words[11] = word11_bits;
+ expected_fixed_words[12] = 0x43FA_0000;
+ expected_fixed_words[13] = word13_bits;
+ assert_eq!(
+ record.fixed_words, expected_fixed_words,
+ "{root_name} raw words"
+ );
+ assert!(record.values.is_empty(), "{root_name} values");
+ assert!(record.string_bytes.is_empty(), "{root_name} string");
+
+ let expected_rows = names
+ .iter()
+ .enumerate()
+ .map(|(row_index, name)| {
+ let mut raw_name = fixed_name(name);
+ match row_index {
+ 0 => raw_name[13..15].copy_from_slice(b"rd"),
+ 1 => raw_name[14..16].copy_from_slice(b"rd"),
+ _ => {}
+ }
+ PreparedControlEffectRow {
+ table_index: 0,
+ row_index,
+ raw_condition: [0; 3],
+ point_offsets: [0, 1, 2],
+ selector: u32::try_from(row_index + 1).expect("small effect selector"),
+ resource: PreparedControlResourcePair {
+ archive: fixed_name("effects.rlb"),
+ name: raw_name,
+ },
+ }
+ })
+ .collect::<Vec<_>>();
+ assert_eq!(control.initial_effect_group, Some(0), "{root_name}");
+ assert_eq!(
+ control.effect_section,
+ PreparedControlEffectSection::Parsed(expected_rows),
+ "{root_name} parsed type-4 rows"
+ );
+ }
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
fn prepares_real_unit_asset_plan() {
let root = fixture_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs
index 08a285d..ad96204 100644
--- a/crates/fparkan-render/src/lib.rs
+++ b/crates/fparkan-render/src/lib.rs
@@ -361,6 +361,64 @@ pub enum LegacyCullMode {
FrontFace,
}
+/// CPU diffuse alpha for a colourless MSH batch carrying flag `0x0008`.
+///
+/// `packet_distance` is the distance to the packet's first accepted vertex,
+/// after its object transform. Flag `0x0010` takes precedence over `0x0040`.
+/// Returns `None` for invalid distance or projection inputs. Callers must keep
+/// the material alpha for batches with a type-16 colour stream or without `0x0008`.
+#[must_use]
+pub fn legacy_msh_portal_alpha(
+ batch_flags: u16,
+ packet_distance: f32,
+ horizontal_fov: f32,
+) -> Option<f32> {
+ if !packet_distance.is_finite()
+ || packet_distance < 0.0
+ || !horizontal_fov.is_finite()
+ || horizontal_fov <= 0.0
+ {
+ return None;
+ }
+ let (distance, near, far, slope) = if batch_flags & 0x0010 != 0 {
+ (
+ packet_distance.sqrt(),
+ 2.6 / horizontal_fov,
+ 7.799_999_7 / horizontal_fov,
+ (1.0 / (7.799_999_7 - 2.6)) * horizontal_fov,
+ )
+ } else if batch_flags & 0x0040 != 0 {
+ return Some(0.0);
+ } else {
+ let near = 75.0 * 1.3;
+ let far = 95.0 * 1.3;
+ (
+ packet_distance,
+ horizontal_fov.recip() * near,
+ horizontal_fov.recip() * far,
+ (1.0 / (far - near)) * horizontal_fov,
+ )
+ };
+ Some(if distance < near {
+ 0.0
+ } else if distance > far {
+ 1.0
+ } else {
+ ((distance - near) * slope).clamp(0.0, 1.0)
+ })
+}
+
+/// Raster depth adjustment applied to one pipeline variant.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub enum LegacyDepthBias {
+ /// Preserve the interpolated depth exactly.
+ #[default]
+ Disabled,
+ /// Move projected decals toward the camera so they pass against their
+ /// coplanar receiver under a less-or-equal depth test.
+ ProjectedDecal,
+}
+
/// Legacy fixed-function state that changes graphics-pipeline structure.
///
/// Alpha reference is deliberately not present: it is dynamic material data,
@@ -375,6 +433,8 @@ pub struct LegacyPipelineState {
pub cull: LegacyCullMode,
/// Whether alpha-test shader logic is enabled.
pub alpha_test: bool,
+ /// Raster depth bias for projected decals.
+ pub depth_bias: LegacyDepthBias,
}
impl LegacyPipelineState {
@@ -405,15 +465,16 @@ impl LegacyPipelineState {
/// Canonical, backend-neutral key for a graphics-pipeline variant.
///
/// The value is explicitly packed rather than hashed, so captures and caches
-/// remain stable across processes and Rust toolchain updates. Three bits are
-/// reserved for the five native material blend modes.
+/// remain stable across processes and Rust toolchain updates. Bits 0..7 encode
+/// blend, depth, cull and alpha-test state; bit 8 distinguishes projected
+/// decal depth bias.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
-pub struct PipelineKey(u8);
+pub struct PipelineKey(u16);
impl PipelineKey {
/// Returns the canonical packed representation.
#[must_use]
- pub const fn packed(self) -> u8 {
+ pub const fn packed(self) -> u16 {
self.0
}
}
@@ -437,7 +498,11 @@ impl From<LegacyPipelineState> for PipelineKey {
LegacyCullMode::BackFace => 1,
LegacyCullMode::FrontFace => 2,
};
- Self(blend | (depth << 3) | (cull << 5) | (u8::from(state.alpha_test) << 7))
+ let depth_bias = match state.depth_bias {
+ LegacyDepthBias::Disabled => 0,
+ LegacyDepthBias::ProjectedDecal => 1 << 8,
+ };
+ Self(blend | (depth << 3) | (cull << 5) | (u16::from(state.alpha_test) << 7) | depth_bias)
}
}
@@ -445,6 +510,40 @@ impl From<LegacyPipelineState> for PipelineKey {
mod tests {
use super::*;
+ #[test]
+ fn portal_alpha_matches_native_packet_distance_vectors() {
+ // GOG Terrain+1B0F0 with its real +246A0/+247B0 packet methods,
+ // Ngi32 RSQRTSS dispatch, x87 CW=007F, FOV=1.04.
+ for (distance, flags, expected) in [
+ (0.499_938_96, 0x18, 0.0),
+ (4.999_542, 0x18, 0.0),
+ (9.999_084, 0x18, 0.132_426_6),
+ (25.000_572, 0x18, 0.500_011_44),
+ (59.999_084, 0x18, 1.0),
+ (85.012_48, 0x08, 0.0),
+ (109.994_125, 0x08, 0.649_765_2),
+ (25.000_572, 0x58, 0.500_011_44),
+ (110.0, 0x48, 0.0),
+ ] {
+ let actual = legacy_msh_portal_alpha(flags, distance, 1.04).unwrap();
+ assert!(
+ (actual - expected).abs() < 0.000_002,
+ "{distance}: {actual}"
+ );
+ }
+ assert_eq!(legacy_msh_portal_alpha(8, 60.0, 1.3), Some(0.0));
+ assert!((legacy_msh_portal_alpha(8, 85.0, 1.3).unwrap() - 0.5).abs() < 0.000_001);
+ assert_eq!(legacy_msh_portal_alpha(8, 110.0, 1.3), Some(1.0));
+ for (distance, fov) in [
+ (f32::NAN, 1.3),
+ (-1.0, 1.3),
+ (1.0, 0.0),
+ (1.0, f32::INFINITY),
+ ] {
+ assert_eq!(legacy_msh_portal_alpha(8, distance, fov), None);
+ }
+ }
+
fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
let mut result = [0.0; 16];
for row in 0..4 {
@@ -614,6 +713,24 @@ mod tests {
}
#[test]
+ fn projected_decal_bias_is_a_distinct_preserved_pipeline_state() {
+ let ordinary = LegacyPipelineState::default();
+ let decal = LegacyPipelineState {
+ depth_bias: LegacyDepthBias::ProjectedDecal,
+ ..ordinary
+ };
+ assert_eq!(ordinary.depth_bias, LegacyDepthBias::Disabled);
+ assert_ne!(PipelineKey::from(ordinary), PipelineKey::from(decal));
+ assert_eq!(
+ decal
+ .with_material_category(1)
+ .expect("native category is represented")
+ .depth_bias,
+ LegacyDepthBias::ProjectedDecal
+ );
+ }
+
+ #[test]
fn native_material_categories_have_distinct_pipeline_keys() {
let base = LegacyPipelineState {
depth: LegacyDepthMode::TestWrite,
diff --git a/crates/fparkan-runtime/Cargo.toml b/crates/fparkan-runtime/Cargo.toml
index 8eb3e84..80e5b78 100644
--- a/crates/fparkan-runtime/Cargo.toml
+++ b/crates/fparkan-runtime/Cargo.toml
@@ -7,6 +7,7 @@ repository.workspace = true
[dependencies]
fparkan-assets = { path = "../fparkan-assets" }
+fparkan-fx = { path = "../fparkan-fx" }
fparkan-msh = { path = "../fparkan-msh" }
fparkan-path = { path = "../fparkan-path" }
fparkan-platform = { path = "../fparkan-platform" }
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)
}
diff --git a/crates/fparkan-script/src/lib.rs b/crates/fparkan-script/src/lib.rs
index 7959ce8..20821fe 100644
--- a/crates/fparkan-script/src/lib.rs
+++ b/crates/fparkan-script/src/lib.rs
@@ -3,7 +3,7 @@
//!
//! This module preserves the layout proven by the GOG `ai.dll` reader. It
//! deliberately does not implement a general bytecode VM. It contains a
-//! narrow resolver for confirmed `Init` handlers and numeric FML literals.
+//! narrow resolver for confirmed `Init` handlers and bounded FML expressions.
use fparkan_binary::{checked_allocation_len, Cursor, DecodeError, Limits};
use std::sync::Arc;
@@ -214,7 +214,7 @@ pub enum ScriptInitResolveError {
/// Number of preserved FML rows.
rows: usize,
},
- /// The referenced FML row is not one numeric token.
+ /// The referenced FML row is outside the supported atom-expression subset.
UnsupportedFormulaExpression {
/// Zero-based FML row index.
index: u32,
@@ -259,7 +259,7 @@ impl std::fmt::Display for ScriptInitResolveError {
),
Self::UnsupportedFormulaExpression { index, expression } => write!(
formatter,
- "formula {index} expression {:?} is not one numeric literal",
+ "formula {index} expression {:?} is outside the supported atom and binary +/− subset",
String::from_utf8_lossy(expression)
),
Self::DwordConversionOutOfRange { index, value_bits } => write!(
@@ -736,6 +736,7 @@ impl VarSet {
/// sentinel selector is accepted only for the proven formula assignment
/// with control and copy-source words both equal to `0xffffffff`; the
/// referenced row is evaluated only when that instruction is reached.
+ /// Formula atoms read defaults and earlier successful writes in this event.
///
/// # Errors
///
@@ -753,10 +754,24 @@ impl VarSet {
return Ok(ScriptInitResolution::Missing);
};
+ let mut values = self
+ .declarations
+ .iter()
+ .map(|declaration| declaration.default_value)
+ .collect::<Vec<_>>();
let mut writes = Vec::new();
for (instruction_index, instruction) in event.instructions.iter().enumerate() {
- match self.resolve_init_instruction(instruction, input, formulas)? {
+ match self.resolve_init_instruction(instruction, input, formulas, &values)? {
InitInstructionResolution::Executed(instruction_writes) => {
+ for write in &instruction_writes {
+ let Some(value) = values.get_mut(write.index as usize) else {
+ return Err(ScriptInitResolveError::VarSetIndexOutOfBounds {
+ index: write.index,
+ declarations: self.declarations.len(),
+ });
+ };
+ *value = write.value;
+ }
writes.extend(instruction_writes);
}
InitInstructionResolution::Unsupported(error) => {
@@ -781,6 +796,7 @@ impl VarSet {
instruction: &ScriptInstruction,
input: Handler19InitInput,
formulas: Option<&FormulaTable>,
+ values: &[VarSetDefault],
) -> Result<InitInstructionResolution, ScriptInitResolveError> {
match instruction.dispatch_selector() {
ScriptDispatchSelector::Handler(19) => {
@@ -842,22 +858,23 @@ impl VarSet {
));
};
let formula_index = instruction.header_words[6];
- let value = match formulas.evaluate_numeric_literal(formula_index) {
- Ok(value) => value,
- Err(FormulaLiteralError::IndexOutOfBounds { index, rows }) => {
- return Err(ScriptInitResolveError::FormulaIndexOutOfBounds {
- index,
- rows,
- });
- }
- Err(FormulaLiteralError::UnsupportedExpression { index, expression }) => {
- return Ok(InitInstructionResolution::Unsupported(
- ScriptInitResolveError::UnsupportedFormulaExpression {
- index,
- expression,
- },
- ));
- }
+ let Some(formula) = formulas.rows.get(formula_index as usize) else {
+ return Err(ScriptInitResolveError::FormulaIndexOutOfBounds {
+ index: formula_index,
+ rows: formulas.rows.len(),
+ });
+ };
+ let Some(value) = parse_init_formula_expression(
+ &formula.expression_raw,
+ &self.declarations,
+ values,
+ ) else {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: formula_index,
+ expression: formula.expression_raw.clone(),
+ },
+ ));
};
let target_index = instruction.header_words[1];
let Some(target) = self.declarations.get(target_index as usize) else {
@@ -866,6 +883,20 @@ impl VarSet {
declarations: self.declarations.len(),
});
};
+ let Some(current_target) = values.get(target_index as usize) else {
+ return Err(ScriptInitResolveError::VarSetIndexOutOfBounds {
+ index: target_index,
+ declarations: values.len(),
+ });
+ };
+ if !varset_value_matches_type(target.type_name, *current_target) {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: formula_index,
+ expression: formula.expression_raw.clone(),
+ },
+ ));
+ }
let value = match target.type_name {
VarSetType::Float => VarSetDefault::FloatBits(value.to_bits()),
VarSetType::Dword => {
@@ -1357,6 +1388,113 @@ fn native_decimal_prefix_len(token: &[u8]) -> Option<usize> {
Some(index)
}
+fn parse_init_formula_expression(
+ expression: &[u8],
+ declarations: &[VarSetDeclaration],
+ values: &[VarSetDefault],
+) -> Option<f32> {
+ let (left, left_end) = parse_init_formula_atom(expression, 0, declarations, values)?;
+ let operator_index = skip_native_formula_space(expression, left_end);
+ let Some(&operator @ (b'+' | b'-')) = expression.get(operator_index) else {
+ return expression[left_end..]
+ .iter()
+ .all(|byte| *byte == 0 || *byte <= 32)
+ .then_some(left);
+ };
+ let (right, right_end) =
+ parse_init_formula_atom(expression, operator_index + 1, declarations, values)?;
+ if !expression[right_end..]
+ .iter()
+ .all(|byte| *byte == 0 || *byte <= 32)
+ {
+ return None;
+ }
+ Some(match operator {
+ b'+' => right + left,
+ b'-' => left - right,
+ _ => unreachable!(),
+ })
+}
+
+fn parse_init_formula_atom(
+ expression: &[u8],
+ start: usize,
+ declarations: &[VarSetDeclaration],
+ values: &[VarSetDefault],
+) -> Option<(f32, usize)> {
+ let start = skip_native_formula_space(expression, start);
+ let remaining = expression.get(start..)?;
+ let first = *remaining.first()?;
+ let token_len = if first.is_ascii_digit() {
+ native_numeric_token_len(remaining)?
+ } else {
+ native_variable_token_len(remaining)?
+ };
+ let token = &remaining[..token_len];
+ if is_native_operator_atom(token) {
+ return None;
+ }
+
+ let value = if let Some(index) = declarations
+ .iter()
+ .position(|declaration| declaration.name.as_bytes() == token)
+ {
+ let declaration = &declarations[index];
+ let value = *values.get(index)?;
+ varset_value_as_f32(declaration.type_name, value)?
+ } else {
+ parse_native_numeric_literal(token)?
+ };
+ Some((value, start + token_len))
+}
+
+fn native_variable_token_len(token: &[u8]) -> Option<usize> {
+ let first = *token.first()?;
+ if first == b'^' || !is_native_variable_byte(first) {
+ return None;
+ }
+ Some(
+ token
+ .iter()
+ .take_while(|byte| is_native_variable_byte(**byte))
+ .count(),
+ )
+}
+
+fn is_native_variable_byte(byte: u8) -> bool {
+ byte.is_ascii() && ((byte > b'@' && byte < b'{') || byte.is_ascii_digit() || byte == b'_')
+}
+
+fn is_native_operator_atom(token: &[u8]) -> bool {
+ matches!(
+ token,
+ b"+" | b"-" | b"*" | b"/" | b"^" | b"&" | b"|" | b"!" | b"A" | b"B" | b"N" | b"S"
+ )
+}
+
+fn skip_native_formula_space(expression: &[u8], start: usize) -> usize {
+ expression[start..]
+ .iter()
+ .position(|byte| *byte == 0 || *byte > 32)
+ .map_or(expression.len(), |offset| start + offset)
+}
+
+fn varset_value_as_f32(type_name: VarSetType, value: VarSetDefault) -> Option<f32> {
+ match (type_name, value) {
+ (VarSetType::Float, VarSetDefault::FloatBits(bits)) => Some(f32::from_bits(bits)),
+ (VarSetType::Dword, VarSetDefault::Dword(value)) => Some(value as f32),
+ _ => None,
+ }
+}
+
+fn varset_value_matches_type(type_name: VarSetType, value: VarSetDefault) -> bool {
+ matches!(
+ (type_name, value),
+ (VarSetType::Float, VarSetDefault::FloatBits(_))
+ | (VarSetType::Dword, VarSetDefault::Dword(_))
+ )
+}
+
fn formula_dword_value(value: f32) -> Option<u32> {
const SIGNED_QWORD_LIMIT: f64 = 9_223_372_036_854_775_808.0;
let value = f64::from(value);
@@ -1822,6 +1960,49 @@ mod tests {
};
use fparkan_binary::{DecodeError, Limits};
+ fn init_package(instructions: Vec<ScriptInstruction>) -> ScriptPackage {
+ ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0,
+ instructions,
+ }],
+ trailing_bytes: Vec::new(),
+ raw: std::sync::Arc::from(&b""[..]),
+ }
+ }
+
+ fn init_formula_assignment(target: u32, formula: u32) -> ScriptInstruction {
+ ScriptInstruction {
+ header_words: [u32::MAX, target, u32::MAX, u32::MAX, 6, 0, formula],
+ references: Vec::new(),
+ }
+ }
+
+ fn init_handler19() -> ScriptInstruction {
+ ScriptInstruction {
+ header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX],
+ references: vec![0, 1, 2],
+ }
+ }
+
+ fn init_input() -> Handler19InitInput {
+ Handler19InitInput {
+ first_x87_word: 10,
+ second_x87_word: 20,
+ third_word: 30,
+ }
+ }
+
+ fn one_formula(expression: &[u8]) -> FormulaTable {
+ let mut source = b"FUNCTION(test, ".to_vec();
+ source.extend_from_slice(expression);
+ source.extend_from_slice(b", )\n");
+ FormulaTable::parse(&source)
+ }
+
fn handler_two_input(
scalar_1: f32,
scalar_4: f32,
@@ -2625,12 +2806,265 @@ STRING( 8, ignored, ignored, ignored)\r\n";
}
#[test]
+ fn script_init_formula_variables_read_prior_writes_and_self() {
+ let varset = parse_varset(
+ b"VAR(DWORD, source, 0)\nVAR(DWORD, y, 0)\nVAR(DWORD, id, 0)\nVAR(float, target, 0)\nVAR(DWORD, integer, 0)\n",
+ )
+ .expect("numeric declarations");
+ let formulas = FormulaTable::parse(
+ b"FUNCTION(literal, 7.0, )\nFUNCTION(add, source + 2.0, )\nFUNCTION(subtract, target - source, )\nFUNCTION(self, target + 1.0, )\nFUNCTION(integer, 10.75 + 0.5, )\nFUNCTION(exponent, target + 1e-2, )\n",
+ );
+ let package = init_package(vec![
+ init_handler19(),
+ init_formula_assignment(3, 0),
+ init_formula_assignment(3, 1),
+ init_formula_assignment(3, 2),
+ init_formula_assignment(3, 3),
+ init_formula_assignment(4, 4),
+ init_formula_assignment(3, 5),
+ ]);
+
+ assert_eq!(
+ varset.resolve_init(&package, init_input(), Some(&formulas),),
+ Ok(ScriptInitResolution::Executed {
+ event_index: 0,
+ event_word: 0,
+ writes: vec![
+ ScriptInitWrite {
+ index: 0,
+ value: VarSetDefault::Dword(10),
+ },
+ ScriptInitWrite {
+ index: 1,
+ value: VarSetDefault::Dword(20),
+ },
+ ScriptInitWrite {
+ index: 2,
+ value: VarSetDefault::Dword(30),
+ },
+ ScriptInitWrite {
+ index: 3,
+ value: VarSetDefault::FloatBits(7.0_f32.to_bits()),
+ },
+ ScriptInitWrite {
+ index: 3,
+ value: VarSetDefault::FloatBits(12.0_f32.to_bits()),
+ },
+ ScriptInitWrite {
+ index: 3,
+ value: VarSetDefault::FloatBits(2.0_f32.to_bits()),
+ },
+ ScriptInitWrite {
+ index: 3,
+ value: VarSetDefault::FloatBits(3.0_f32.to_bits()),
+ },
+ ScriptInitWrite {
+ index: 4,
+ value: VarSetDefault::Dword(11),
+ },
+ ScriptInitWrite {
+ index: 3,
+ value: VarSetDefault::FloatBits(3.01_f32.to_bits()),
+ },
+ ],
+ })
+ );
+ }
+
+ #[test]
+ fn script_init_formula_atoms_use_native_token_and_name_rules() {
+ let varset = parse_varset(
+ b"VAR(float, dup, 1)\nVAR(float, dup, 2)\nVAR(float, Name, 3)\nVAR(float, name, 4)\nVAR(float, 1e-2, 41)\nVAR(float, A, 8)\nVAR(float, A1, 9)\nVAR(DWORD, high, 4294967295)\nVAR(float, large, 16777216)\nVAR(float, target, 0)\nVAR(DWORD, integer, 0)\n",
+ )
+ .expect("numeric declarations");
+ let formulas = FormulaTable::parse(
+ b"FUNCTION(duplicate, dup + 0, )\nFUNCTION(upper, Name + 0, )\nFUNCTION(lower, name + 0, )\nFUNCTION(numeric_name, 1e-2 + 0, )\nFUNCTION(long_operator_prefix, A1 + 0, )\nFUNCTION(unsigned_dword, high + 0, )\nFUNCTION(negative_exponent, 5e-2 + 2e-2, )\nFUNCTION(positive_exponent, 1e+2 + 2e-2, )\nFUNCTION(binary32_rounding, 16777216 + 1, )\nFUNCTION(cancellation, target - 16777215, )\nFUNCTION(dword_target, 10.75 + 0.5, )\nFUNCTION(single_variable, large, )\nFUNCTION(negative_dword_wrap, 2 - 3, )\nFUNCTION(unsigned_dword_wrap, high + 0, )\n",
+ );
+ let package = init_package(
+ (0..14)
+ .map(|formula| {
+ init_formula_assignment(
+ if matches!(formula, 10 | 12 | 13) {
+ 10
+ } else {
+ 9
+ },
+ formula,
+ )
+ })
+ .collect(),
+ );
+
+ let resolved = varset
+ .resolve_init(
+ &package,
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas),
+ )
+ .expect("resolve Init")
+ .clone();
+ assert_eq!(
+ resolved,
+ ScriptInitResolution::Executed {
+ event_index: 0,
+ event_word: 0,
+ writes: vec![
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(1.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(3.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(4.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(41.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(9.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(4_294_967_296.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits((0.05_f32 + 0.02_f32).to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits((100.0_f32 + 0.02_f32).to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(16_777_216.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(1.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 10,
+ value: VarSetDefault::Dword(11)
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(16_777_216.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 10,
+ value: VarSetDefault::Dword(u32::MAX)
+ },
+ ScriptInitWrite {
+ index: 10,
+ value: VarSetDefault::Dword(0)
+ },
+ ],
+ }
+ );
+ assert!(formulas.evaluate_numeric_literal(0).is_err());
+ }
+
+ #[test]
+ fn script_init_rejects_unsupported_formula_variable_forms() {
+ let varset = parse_varset(
+ b"VAR(float, target, 1)\nVAR(float, A, 2)\nVAR(float, A1, 3)\nVAR(float, ^name, 4)\n",
+ )
+ .expect("numeric declarations");
+ for expression in [
+ &b"target * 2"[..],
+ b"-1",
+ b"target + 1 + 2",
+ b"missing + 1",
+ b"A + 1",
+ b"^name + 1",
+ b"name\xc3\xa9 + 1",
+ ] {
+ let formulas = one_formula(expression);
+ let package = init_package(vec![init_formula_assignment(0, 0)]);
+ assert!(
+ matches!(
+ varset.resolve_init(
+ &package,
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas),
+ ),
+ Ok(ScriptInitResolution::Unsupported {
+ error: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 0,
+ ..
+ },
+ ..
+ })
+ ),
+ "accepted unsupported formula {:?}",
+ String::from_utf8_lossy(expression)
+ );
+ }
+
+ let mut inconsistent = varset;
+ inconsistent.declarations[0].default_value = VarSetDefault::Dword(1);
+ let formulas = one_formula(b"target + 1");
+ let package = init_package(vec![init_formula_assignment(0, 0)]);
+ assert!(matches!(
+ inconsistent.resolve_init(
+ &package,
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas),
+ ),
+ Ok(ScriptInitResolution::Unsupported {
+ error: ScriptInitResolveError::UnsupportedFormulaExpression { index: 0, .. },
+ ..
+ })
+ ));
+
+ let mut inconsistent_target =
+ parse_varset(b"VAR(float, target, 1)\n").expect("numeric declaration");
+ inconsistent_target.declarations[0].default_value = VarSetDefault::Dword(1);
+ let formulas = one_formula(b"2.0 + 1");
+ assert!(matches!(
+ inconsistent_target.resolve_init(
+ &init_package(vec![init_formula_assignment(0, 0)]),
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas),
+ ),
+ Ok(ScriptInitResolution::Unsupported {
+ error: ScriptInitResolveError::UnsupportedFormulaExpression { index: 0, .. },
+ ..
+ })
+ ));
+ }
+
+ #[test]
fn script_init_keeps_unsupported_formula_expressions_lazy_and_transactional() {
- let varset = parse_varset(b"VAR(DWORD, integer, 0)\n").expect("numeric declaration");
+ let varset = parse_varset(b"VAR(DWORD, target, 0)\nVAR(DWORD, integer, 0)\n")
+ .expect("numeric declaration");
let formulas =
- FormulaTable::parse(b"FUNCTION(unsupported, target + 2, )\nFUNCTION(literal, 7.5, )\n");
+ FormulaTable::parse(b"FUNCTION(unsupported, target * 2, )\nFUNCTION(literal, 7.5, )\n");
let action = |formula_index| ScriptInstruction {
- header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 6, 0, formula_index],
+ header_words: [u32::MAX, 1, u32::MAX, u32::MAX, 6, 0, formula_index],
references: Vec::new(),
};
let package = |formula_index| ScriptPackage {
@@ -2656,7 +3090,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
event_index: 0,
event_word: 0,
writes: vec![ScriptInitWrite {
- index: 0,
+ index: 1,
value: VarSetDefault::Dword(7),
}],
})
@@ -2668,7 +3102,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
instruction_index: 0,
error: ScriptInitResolveError::UnsupportedFormulaExpression {
index: 0,
- expression: b" target + 2".to_vec(),
+ expression: b" target * 2".to_vec(),
},
})
);
@@ -2682,7 +3116,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
instruction_index: 1,
error: ScriptInitResolveError::UnsupportedFormulaExpression {
index: 0,
- expression: b" target + 2".to_vec(),
+ expression: b" target * 2".to_vec(),
},
})
);