aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/fparkan-assets/src/lib.rs1865
-rw-r--r--crates/fparkan-assets/src/research.rs1151
-rw-r--r--crates/fparkan-font/Cargo.toml12
-rw-r--r--crates/fparkan-font/src/lib.rs402
-rw-r--r--crates/fparkan-fx/src/ambient_op3.rs218
-rw-r--r--crates/fparkan-msh/src/lib.rs924
-rw-r--r--crates/fparkan-runtime/Cargo.toml3
-rw-r--r--crates/fparkan-runtime/src/lib.rs2753
-rw-r--r--crates/fparkan-runtime/src/mesh_collision.rs1255
-rw-r--r--crates/fparkan-script/src/lib.rs1476
-rw-r--r--crates/fparkan-terrain/src/lib.rs16
11 files changed, 9777 insertions, 298 deletions
diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs
index 2ee829f..9cc6b5b 100644
--- a/crates/fparkan-assets/src/lib.rs
+++ b/crates/fparkan-assets/src/lib.rs
@@ -10,8 +10,8 @@ pub use fparkan_material::{
sample_material_normalized_phase, sample_material_phase, Mat0Document, MaterialCoefficients,
MaterialError, MaterialPhase, MaterialPhaseSample,
};
-use fparkan_mission_format::{decode_tma, decode_tma_land_path, ClanBody};
-pub use fparkan_mission_format::{LpString, MissionDocument, MissionError, TmaProfile};
+use fparkan_mission_format::{decode_tma, decode_tma_land_path};
+pub use fparkan_mission_format::{ClanBody, LpString, MissionDocument, MissionError, TmaProfile};
use fparkan_msh::{decode_msh, validate_msh, ModelAsset, MshError};
use fparkan_nres::{decode as decode_nres, ReadProfile};
pub use fparkan_nres::{NresDocument, NresError};
@@ -28,6 +28,11 @@ pub use fparkan_terrain_format::{BuildCategory, TerrainFormatError};
use fparkan_texm::{decode_mip_rgba8, decode_texm, TexmDocument, TexmError};
pub use fparkan_texm::{PageRect, RgbaImage};
use fparkan_vfs::DirectoryVfs;
+mod research;
+pub use research::{
+ calc_full_research_cost, ResearchCandidate, ResearchCandidateSet, ResearchCost, ResearchError,
+ ResearchTrfDocument,
+};
use std::collections::{hash_map::Entry, HashMap};
use std::fmt;
use std::hash::{Hash, Hasher};
@@ -1905,16 +1910,12 @@ impl MissionAssets {
visual_count: self.visuals.len(),
model_count: self.models.len(),
material_count: self.materials.len(),
- texture_count: self
- .textures
- .iter()
- .filter(|texture| texture.usage == PreparedTextureUsage::Diffuse)
- .count(),
+ texture_count: self.visuals.iter().map(|visual| visual.texture_count).sum(),
lightmap_count: self
- .textures
+ .visuals
.iter()
- .filter(|texture| texture.usage == PreparedTextureUsage::Lightmap)
- .count(),
+ .map(|visual| visual.lightmap_count)
+ .sum(),
}
}
}
@@ -3866,7 +3867,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;
@@ -4376,7 +4377,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;
@@ -4478,6 +4480,1476 @@ mod tests {
));
}
+ #[test]
+ fn research_trf_reader_preserves_rows_and_uses_low_u16_alias_link() {
+ let bytes = research_cycle_fixture(3, &[0; 5]);
+ let document = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
+ .expect("synthetic TRF");
+
+ assert_eq!(document.row_count(), 5);
+ assert_eq!(document.find_key(b"a").expect("key lookup"), Some(0));
+ let row = document.row_words(0).expect("first raw row");
+ assert_eq!(row[0], 10.0_f32.to_bits());
+ assert_eq!(row[1], 1.0_f32.to_bits());
+ assert_eq!(row[8], 0x09FF_0000);
+ assert_eq!(document.row_words(2).expect("third row")[8], 0x09FF_0002);
+ assert_eq!(document.flag_byte(0), Some(0));
+ assert_eq!(document.relation_2_3(0).expect("ordered links"), [3, 1]);
+ assert!(document
+ .relation_4_5(0)
+ .expect("empty optional stream")
+ .is_empty());
+ }
+
+ #[test]
+ fn research_trf_reader_rejects_the_native_trf0_attribute_guard() {
+ let bytes = research_cycle_fixture(4, &[0; 5]);
+
+ assert!(ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())).is_err());
+ }
+
+ #[test]
+ fn research_cost_matches_native_cycle_dedup_nonzero_result() {
+ let bytes = research_cycle_fixture(3, &[0; 5]);
+ let research = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
+ .expect("synthetic TRF");
+ let candidate = research_unit_dat(&[b"A", b"B", b"C", b"B"], &[3, 0, 0, 0], &[1; 4]);
+
+ assert_eq!(
+ calc_full_research_cost(&candidate, &research).expect("cost"),
+ ResearchCost {
+ ore: 15.0,
+ power: 150.0,
+ }
+ );
+ }
+
+ #[test]
+ fn research_candidate_set_selects_later_lower_cost_and_updates_native_statuses() {
+ let costs = [(200.0, 0.0), (40.0, 0.0)];
+ let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
+ let empty_groups = [&[][..], &[][..]];
+ let bytes = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0x05, 0x05],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &[0, 1],
+ relations_23: &empty_groups,
+ relations_45: &empty_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ let research = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
+ .expect("synthetic research table");
+ let first_path = parse_path("units/first.dat").expect("first candidate path");
+ let second_path = parse_path("units/second.dat").expect("second candidate path");
+ let mut vfs = MemoryVfs::default();
+ vfs.insert(
+ first_path.clone(),
+ Arc::from(research_unit_dat_payload(&[b"A"], &[0], &[1]).into_boxed_slice()),
+ );
+ vfs.insert(
+ second_path.clone(),
+ Arc::from(research_unit_dat_payload(&[b"B"], &[0], &[1]).into_boxed_slice()),
+ );
+ let mut candidates = ResearchCandidateSet {
+ entries: vec![
+ ResearchCandidate {
+ path: first_path,
+ status: 0,
+ },
+ ResearchCandidate {
+ path: second_path,
+ status: 0,
+ },
+ ],
+ selected_index: 0x1234_5678,
+ loaded: true,
+ status: 0,
+ };
+
+ candidates
+ .refresh_selection(&vfs, Some(&research))
+ .expect("refresh research candidates");
+
+ assert_eq!(
+ candidates.selected_index, 1,
+ "later lower-cost candidate wins"
+ );
+ assert_eq!(
+ candidates.status, 0,
+ "candidate scan status is cleared before reads"
+ );
+ assert_eq!(
+ candidates
+ .entries
+ .iter()
+ .map(|entry| entry.status)
+ .collect::<Vec<_>>(),
+ [0, 0],
+ "both eligible candidates pass predicate two with false status"
+ );
+ }
+
+ #[test]
+ fn research_candidate_set_guards_and_empty_catalog_match_native_state() {
+ let research = research_selector_document(&[0x05], &[(10.0, 0.0)], &[(b"A", 0)], &[0]);
+ let default = ResearchCandidateSet::default();
+ assert!(default.entries.is_empty());
+ assert_eq!(default.selected_index, 0);
+ assert!(!default.loaded);
+ assert_eq!(default.status, 0);
+ let mut candidates = research_candidate_set(&[("missing.dat", 7)], 41, false, 9);
+ candidates
+ .refresh_selection(&MemoryVfs::default(), Some(&research))
+ .expect("unloaded catalog is a no-op");
+ assert_eq!(candidates.selected_index, 41);
+ assert_eq!(candidates.status, 9);
+ assert_eq!(candidates.entries[0].status, 7);
+
+ candidates.loaded = true;
+ candidates
+ .refresh_selection(&MemoryVfs::default(), None)
+ .expect("missing context is a no-op");
+ assert_eq!(candidates.selected_index, 41);
+ assert_eq!(candidates.status, 9);
+ assert_eq!(candidates.entries[0].status, 7);
+
+ let mut empty = research_candidate_set(&[], 63, true, 0);
+ empty
+ .refresh_selection(&MemoryVfs::default(), Some(&research))
+ .expect("loaded empty catalog refresh");
+ assert_eq!(empty.selected_index, 63);
+ assert_eq!(empty.status, 1);
+ }
+
+ #[test]
+ fn research_candidate_set_skips_nonzero_status_without_reading_missing_path() {
+ let research = research_selector_document(&[0x05], &[(10.0, 0.0)], &[(b"A", 0)], &[0]);
+ let mut candidates = research_candidate_set(&[("missing.dat", 1)], 52, true, 0);
+
+ candidates
+ .refresh_selection(&MemoryVfs::default(), Some(&research))
+ .expect("nonzero entry status skips VFS read");
+
+ assert_eq!(candidates.selected_index, 52);
+ assert_eq!(candidates.status, 1);
+ assert_eq!(candidates.entries[0].status, 1);
+ }
+
+ #[test]
+ fn research_candidate_set_short_circuits_false_predicates_on_parsed_dat() {
+ let research = research_selector_document(&[0x03], &[(10.0, 0.0)], &[(b"A", 0)], &[0]);
+ let first_path = "units/predicate-false.dat";
+ let second_path = "units/missing-key.dat";
+ let mut vfs = MemoryVfs::default();
+ insert_research_dat(
+ &mut vfs,
+ first_path,
+ research_unit_dat_payload(&[b"A"], &[0], &[1]),
+ );
+ insert_research_dat(
+ &mut vfs,
+ second_path,
+ research_unit_dat_payload(&[b"Z"], &[0], &[1]),
+ );
+ let mut candidates =
+ research_candidate_set(&[(first_path, 0), (second_path, 0)], 0x1234_5678, true, 0);
+
+ candidates
+ .refresh_selection(&vfs, Some(&research))
+ .expect("false native predicates skip without cost errors");
+
+ assert_eq!(candidates.selected_index, 0x1234_5678);
+ assert_eq!(candidates.status, 0);
+ assert_eq!(
+ candidates
+ .entries
+ .iter()
+ .map(|entry| entry.status)
+ .collect::<Vec<_>>(),
+ [0, 0]
+ );
+ }
+
+ #[test]
+ fn research_candidate_set_prunes_kind_zero_subtree_before_non_ascii_lookup() {
+ let research = research_selector_document(&[0x05], &[(10.0, 0.0)], &[(b"A", 0)], &[0]);
+ let path = "units/pruned-non-ascii-grandchild.dat";
+ let non_ascii = [0xC0];
+ let keys: [&[u8]; 3] = [b"A", b"", &non_ascii];
+ let mut vfs = MemoryVfs::default();
+ insert_research_dat(
+ &mut vfs,
+ path,
+ research_unit_dat_payload(&keys, &[1, 1, 0], &[1, 0, 1]),
+ );
+ let mut candidates = research_candidate_set(&[(path, 0)], 79, true, 0);
+
+ candidates
+ .refresh_selection(&vfs, Some(&research))
+ .expect("kind-zero subtree is pruned before its non-ASCII key");
+
+ assert_eq!(candidates.selected_index, 0);
+ assert_eq!(candidates.status, 0);
+ assert_eq!(candidates.entries[0].status, 0);
+ }
+
+ #[test]
+ fn research_candidate_set_keeps_second_predicate_true_status_and_selection() {
+ let research = research_selector_document(&[0x07], &[(10.0, 0.0)], &[(b"A", 0)], &[0]);
+ let path = "units/second-predicate-true.dat";
+ let mut vfs = MemoryVfs::default();
+ insert_research_dat(
+ &mut vfs,
+ path,
+ research_unit_dat_payload(&[b"A"], &[0], &[1]),
+ );
+ let mut candidates = research_candidate_set(&[(path, 0)], 73, true, 0);
+
+ candidates
+ .refresh_selection(&vfs, Some(&research))
+ .expect("second predicate true is a candidate skip");
+
+ assert_eq!(candidates.selected_index, 73);
+ assert_eq!(candidates.status, 0);
+ assert_eq!(candidates.entries[0].status, 1);
+ }
+
+ #[test]
+ fn research_candidate_set_skips_read_and_parse_failures_then_selects_later_candidate() {
+ let research = research_selector_document(
+ &[0x05, 0x05],
+ &[(200.0, 0.0), (40.0, 0.0)],
+ &[(b"A", 0), (b"B", 1)],
+ &[0, 1],
+ );
+ let wrong_magic = "units/wrong-magic.dat";
+ let excessive_children = "units/excessive-children.dat";
+ let winner = "units/winner.dat";
+ let mut vfs = MemoryVfs::default();
+ let mut wrong_magic_bytes = research_unit_dat_payload(&[b"A"], &[0], &[1]);
+ wrong_magic_bytes[..4].copy_from_slice(&0x1234_5678_u32.to_le_bytes());
+ insert_research_dat(&mut vfs, wrong_magic, wrong_magic_bytes);
+ let mut excessive_children_bytes = research_unit_dat_payload(&[b"A"], &[0], &[1]);
+ excessive_children_bytes[8 + 108..8 + 112].copy_from_slice(&u32::MAX.to_le_bytes());
+ insert_research_dat(&mut vfs, excessive_children, excessive_children_bytes);
+ insert_research_dat(
+ &mut vfs,
+ winner,
+ research_unit_dat_payload(&[b"B"], &[0], &[1]),
+ );
+ let mut candidates = research_candidate_set(
+ &[
+ ("units/missing.dat", 0),
+ (wrong_magic, 0),
+ (excessive_children, 0),
+ (winner, 0),
+ ],
+ 91,
+ true,
+ 0,
+ );
+
+ candidates
+ .refresh_selection(&vfs, Some(&research))
+ .expect("read and parse failures are skipped");
+
+ assert_eq!(candidates.selected_index, 3);
+ assert_eq!(candidates.status, 0);
+ assert_eq!(
+ candidates
+ .entries
+ .iter()
+ .map(|entry| entry.status)
+ .collect::<Vec<_>>(),
+ [0, 0, 0, 0]
+ );
+ }
+
+ #[test]
+ fn research_candidate_set_uses_strict_bound_and_keeps_first_tie() {
+ let tie_research = research_selector_document(
+ &[0x05, 0x05],
+ &[(40.0, 0.0), (40.0, 0.0)],
+ &[(b"A", 0), (b"B", 1)],
+ &[0, 1],
+ );
+ let mut tie_vfs = MemoryVfs::default();
+ insert_research_dat(
+ &mut tie_vfs,
+ "units/tie-a.dat",
+ research_unit_dat_payload(&[b"A"], &[0], &[1]),
+ );
+ insert_research_dat(
+ &mut tie_vfs,
+ "units/tie-b.dat",
+ research_unit_dat_payload(&[b"B"], &[0], &[1]),
+ );
+ let mut tied = research_candidate_set(
+ &[("units/tie-a.dat", 0), ("units/tie-b.dat", 0)],
+ 99,
+ true,
+ 0,
+ );
+ tied.refresh_selection(&tie_vfs, Some(&tie_research))
+ .expect("strict comparison keeps first tied candidate");
+ assert_eq!(tied.selected_index, 0);
+
+ for ore in [f32::from_bits(0x4b18_967f), f32::NAN] {
+ let research = research_selector_document(&[0x05], &[(ore, 0.0)], &[(b"A", 0)], &[0]);
+ let mut vfs = MemoryVfs::default();
+ insert_research_dat(
+ &mut vfs,
+ "units/no-improvement.dat",
+ research_unit_dat_payload(&[b"A"], &[0], &[1]),
+ );
+ let mut candidates =
+ research_candidate_set(&[("units/no-improvement.dat", 0)], 0xABCD, true, 0);
+ candidates
+ .refresh_selection(&vfs, Some(&research))
+ .expect("equal bound and NaN cost do not select");
+ assert_eq!(candidates.selected_index, 0xABCD);
+ assert_eq!(candidates.entries[0].status, 0);
+ }
+ }
+
+ #[test]
+ fn research_candidate_set_ignores_tail_after_first_root_tree() {
+ let research = research_selector_document(
+ &[0x05, 0x05],
+ &[(200.0, 0.0), (40.0, 0.0)],
+ &[(b"A", 0), (b"B", 1)],
+ &[0, 1],
+ );
+ let trailing = "units/trailing-three-bytes.dat";
+ let orphan = "units/orphan-record.dat";
+ let mut vfs = MemoryVfs::default();
+ let mut trailing_bytes = research_unit_dat_payload(&[b"A"], &[0], &[1]);
+ trailing_bytes[4..8].copy_from_slice(&0xA5A5_5A5A_u32.to_le_bytes());
+ trailing_bytes.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
+ let (decoded, tree) = research::decode_candidate_first_tree(&trailing_bytes)
+ .expect("first complete root with trailing bytes");
+ assert_eq!(
+ &decoded.header_opaque[4..8],
+ &0xA5A5_5A5A_u32.to_le_bytes(),
+ "second header DWORD remains opaque"
+ );
+ assert_eq!(decoded.records.len(), 1);
+ assert_eq!(tree.len(), 1);
+ insert_research_dat(&mut vfs, trailing, trailing_bytes);
+
+ let mut orphan_bytes = research_unit_dat_payload(&[b"B"], &[0], &[1]);
+ let orphan_record = research_unit_dat_payload(&[b"Z"], &[0], &[1]);
+ orphan_bytes.extend_from_slice(&orphan_record[8..]);
+ insert_research_dat(&mut vfs, orphan, orphan_bytes);
+ let mut candidates = research_candidate_set(&[(trailing, 0), (orphan, 0)], 99, true, 0);
+
+ candidates
+ .refresh_selection(&vfs, Some(&research))
+ .expect("suffix records are outside the first root tree");
+
+ assert_eq!(candidates.selected_index, 1);
+ assert_eq!(candidates.status, 0);
+ }
+
+ #[test]
+ fn research_candidate_set_preserves_incremental_state_on_non_ascii_error() {
+ let non_ascii = [0xC0];
+ let research = research_selector_document(
+ &[0x05, 0x05],
+ &[(10.0, 0.0), (20.0, 0.0)],
+ &[(b"A", 0), (&non_ascii, 1)],
+ &[0, 1],
+ );
+ let mut vfs = MemoryVfs::default();
+ insert_research_dat(
+ &mut vfs,
+ "units/research-a.dat",
+ research_unit_dat_payload(&[b"A"], &[0], &[1]),
+ );
+ insert_research_dat(
+ &mut vfs,
+ "units/research-non-ascii.dat",
+ research_unit_dat_payload(&[&non_ascii], &[0], &[1]),
+ );
+ let mut candidates = research_candidate_set(
+ &[
+ ("units/research-a.dat", 0),
+ ("units/research-non-ascii.dat", 0),
+ ],
+ 0xAABB_CCDD,
+ true,
+ 1,
+ );
+
+ assert!(matches!(
+ candidates.refresh_selection(&vfs, Some(&research)),
+ Err(ResearchError::UnsupportedNonAsciiLookup { .. })
+ ));
+
+ assert_eq!(candidates.selected_index, 0);
+ assert_eq!(candidates.status, 0);
+ assert_eq!(
+ candidates
+ .entries
+ .iter()
+ .map(|entry| entry.status)
+ .collect::<Vec<_>>(),
+ [0, 0]
+ );
+ }
+
+ #[test]
+ fn research_candidate_set_skips_empty_table_cost_after_pruned_tree() {
+ let research = research_selector_document(&[], &[], &[], &[]);
+ let path = "units/empty-table-kind-zero.dat";
+ let mut vfs = MemoryVfs::default();
+ insert_research_dat(
+ &mut vfs,
+ path,
+ research_unit_dat_payload(&[b""], &[0], &[0]),
+ );
+ let mut candidates = research_candidate_set(&[(path, 0)], 37, true, 0);
+
+ candidates
+ .refresh_selection(&vfs, Some(&research))
+ .expect("native cost failure skips this candidate");
+
+ assert_eq!(candidates.selected_index, 37);
+ assert_eq!(candidates.status, 0);
+ assert_eq!(candidates.entries[0].status, 1);
+ }
+
+ #[test]
+ fn research_trf_reader_preserves_first_alias_raw_link_and_unused_tails() {
+ let costs = [(1.0, 10.0), (2.0, 20.0)];
+ let aliases = [(b"Key".as_slice(), 0), (b"kEy".as_slice(), 1)];
+ let row_alias_links = [1, 0];
+ let empty_groups = [&[][..], &[][..]];
+ let bytes = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0x84, 0x05],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &empty_groups,
+ relations_45: &empty_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ let document = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
+ .expect("synthetic TRF with unused payload tails");
+
+ assert_eq!(document.row_count(), 2);
+ assert_eq!(document.find_key(b"KEY").expect("alias lookup"), Some(0));
+ assert_eq!(document.find_key(b"missing").expect("missing lookup"), None);
+ assert_eq!(document.row_name(0).expect("raw low-u16 link"), b"kEy");
+ assert_eq!(document.row_words(0).expect("raw row")[8], 0x09FF_0001);
+ assert_eq!(document.flag_byte(0), Some(0x84));
+ assert_eq!(document.relation_2_3(0).expect("empty group"), &[]);
+ assert_eq!(document.relation_4_5(0).expect("empty second group"), &[]);
+
+ let trf0 = document
+ .archive()
+ .entries()
+ .iter()
+ .find(|entry| entry.meta().type_id == u32::from_le_bytes(*b"TRF0"))
+ .expect("retained TRF0 chunk");
+ assert!(document
+ .archive()
+ .payload(trf0.id())
+ .expect("retained payload")
+ .ends_with(RESEARCH_TEST_TAIL));
+ }
+
+ #[test]
+ fn research_trf_reader_rejects_required_chunk_and_attribute_errors() {
+ let cycle_costs = [
+ (1.0, 10.0),
+ (2.0, 20.0),
+ (3.0, 30.0),
+ (4.0, 40.0),
+ (5.0, 50.0),
+ ];
+ let names: [&[u8]; 5] = [b"A", b"B", b"C", b"D", b"E"];
+ let aliases: Vec<_> = names
+ .iter()
+ .enumerate()
+ .map(|(index, name)| (*name, u16::try_from(index).expect("row id")))
+ .collect();
+ let row_alias_links = [0, 1, 2, 3, 4];
+ let no_groups = [&[][..]; 5];
+
+ let invalid_attr = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 4,
+ flags: &[0; 5],
+ costs: &cycle_costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(invalid_attr.into_boxed_slice())),
+ Err(ResearchError::InvalidTrf0Attribute { got: 4 })
+ ));
+
+ let missing_required = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 5],
+ costs: &cycle_costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[u32::from_le_bytes(*b"TRF8")],
+ });
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(missing_required.into_boxed_slice())),
+ Err(ResearchError::MissingChunk { type_id })
+ if type_id == u32::from_le_bytes(*b"TRF8")
+ ));
+ }
+
+ #[test]
+ fn research_trf_reader_zero_fills_short_flags_and_rejects_long_flags() {
+ let costs = [(1.0, 10.0), (2.0, 20.0)];
+ let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
+ let row_alias_links = [0, 1];
+ let no_groups = [&[][..], &[][..]];
+ let short = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0x84],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ let document = ResearchTrfDocument::decode(Arc::from(short.into_boxed_slice()))
+ .expect("short TRF1 is zero-filled by native loader");
+ assert_eq!(document.flag_byte(0), Some(0x84));
+ assert_eq!(document.flag_byte(1), Some(0));
+
+ let long = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 3],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(long.into_boxed_slice())),
+ Err(ResearchError::FlagsTooLong {
+ row_count: 2,
+ actual: 3
+ })
+ ));
+ }
+
+ #[test]
+ fn research_trf_reader_checks_alias_and_pair_bounds_and_non_ascii_policy() {
+ let costs = [(1.0, 10.0), (2.0, 20.0)];
+ let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
+ let row_alias_links = [0, 1];
+ let no_groups = [&[][..], &[][..]];
+ let make_bytes = || {
+ research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 2],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ })
+ };
+
+ let mut invalid_name_offset = make_bytes();
+ research_set_payload_u16(
+ &mut invalid_name_offset,
+ u32::from_le_bytes(*b"TRFB"),
+ 0,
+ u16::MAX,
+ );
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(invalid_name_offset.into_boxed_slice())),
+ Err(ResearchError::InvalidNameOffset {
+ pair_index: 0,
+ offset: u16::MAX
+ })
+ ));
+
+ let mut unterminated = make_bytes();
+ research_set_payload_byte(&mut unterminated, u32::from_le_bytes(*b"TRF6"), 1, b'X');
+ research_set_payload_byte(&mut unterminated, u32::from_le_bytes(*b"TRF6"), 3, b'X');
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(unterminated.into_boxed_slice())),
+ Err(ResearchError::UnterminatedName {
+ pair_index: 0,
+ offset: 0
+ })
+ ));
+
+ let mut invalid_record_pair = make_bytes();
+ research_set_payload_u32(
+ &mut invalid_record_pair,
+ u32::from_le_bytes(*b"TRF0"),
+ 32,
+ 0x09FF_0002,
+ );
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(invalid_record_pair.into_boxed_slice())),
+ Err(ResearchError::InvalidRecordPair {
+ record_index: 0,
+ pair_index: 2,
+ pair_count: 2
+ })
+ ));
+
+ let mut invalid_alias_row = make_bytes();
+ research_set_payload_u16(&mut invalid_alias_row, u32::from_le_bytes(*b"TRFB"), 2, 7);
+ let document = ResearchTrfDocument::decode(Arc::from(invalid_alias_row.into_boxed_slice()))
+ .expect("alias row is checked when selected");
+ assert!(matches!(
+ document.find_key(b"A"),
+ Err(ResearchError::InvalidResearchIndex {
+ type_id,
+ index: 7,
+ row_count: 2
+ }) if type_id == u32::from_le_bytes(*b"TRFB")
+ ));
+
+ let non_ascii_aliases = [(b"\xC4".as_slice(), 0), (b"A".as_slice(), 1)];
+ let non_ascii = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 2],
+ costs: &costs,
+ aliases: &non_ascii_aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ let document = ResearchTrfDocument::decode(Arc::from(non_ascii.into_boxed_slice()))
+ .expect("non-ASCII names are preserved");
+ assert!(matches!(
+ document.find_key(b"A"),
+ Err(ResearchError::UnsupportedNonAsciiLookup { .. })
+ ));
+ assert!(matches!(
+ document.find_key(b"\xE4"),
+ Err(ResearchError::UnsupportedNonAsciiLookup { .. })
+ ));
+ }
+
+ #[test]
+ fn research_trf_reader_checks_optional_streams_group_bounds_and_relation_ids() {
+ let costs = [(1.0, 10.0), (2.0, 20.0)];
+ let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
+ let row_alias_links = [0, 1];
+ let no_groups = [&[][..], &[][..]];
+ let missing_zero_streams = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 2],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: false,
+ include_trf5: false,
+ omitted_chunks: &[],
+ });
+ let document =
+ ResearchTrfDocument::decode(Arc::from(missing_zero_streams.into_boxed_slice()))
+ .expect("missing optional streams with zero IDs are empty");
+ assert_eq!(
+ document.relation_2_3(0).expect("empty optional stream"),
+ &[]
+ );
+ assert_eq!(
+ document.relation_4_5(0).expect("empty optional stream"),
+ &[]
+ );
+
+ let positive_group = [&[1_u32][..], &[][..]];
+ let missing_trf3 = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 2],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &positive_group,
+ relations_45: &no_groups,
+ include_trf3: false,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(missing_trf3.into_boxed_slice())),
+ Err(ResearchError::MissingRelationStream { type_id, required_ids: 1 })
+ if type_id == u32::from_le_bytes(*b"TRF3")
+ ));
+
+ let out_of_range_23 = [&[7_u32][..], &[][..]];
+ let out_of_range_45 = [&[][..], &[9_u32][..]];
+ let invalid_ids = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 2],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &out_of_range_23,
+ relations_45: &out_of_range_45,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ let document = ResearchTrfDocument::decode(Arc::from(invalid_ids.into_boxed_slice()))
+ .expect("indices are checked when a group is accessed");
+ assert!(matches!(
+ document.relation_2_3(0),
+ Err(ResearchError::InvalidResearchIndex { type_id, index: 7, row_count: 2 })
+ if type_id == u32::from_le_bytes(*b"TRF3")
+ ));
+ assert!(matches!(
+ document.relation_4_5(1),
+ Err(ResearchError::InvalidResearchIndex { type_id, index: 9, row_count: 2 })
+ if type_id == u32::from_le_bytes(*b"TRF5")
+ ));
+ assert!(matches!(
+ document.relation_2_3(usize::MAX),
+ Err(ResearchError::MissingRelationGroup { type_id, .. })
+ if type_id == u32::from_le_bytes(*b"TRF2")
+ ));
+
+ let one_group = [&[][..]];
+ let short_table = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 2],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &one_group,
+ relations_45: &one_group,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ let document = ResearchTrfDocument::decode(Arc::from(short_table.into_boxed_slice()))
+ .expect("relation tables have their own counts");
+ assert!(matches!(
+ document.relation_2_3(1),
+ Err(ResearchError::MissingRelationGroup { type_id, index: 1, group_count: 1 })
+ if type_id == u32::from_le_bytes(*b"TRF2")
+ ));
+ assert!(matches!(
+ document.relation_4_5(1),
+ Err(ResearchError::MissingRelationGroup { type_id, index: 1, group_count: 1 })
+ if type_id == u32::from_le_bytes(*b"TRF4")
+ ));
+ }
+
+ #[test]
+ fn research_trf_reader_checks_count_prefixes_without_large_allocations() {
+ let costs = [(1.0, 10.0), (2.0, 20.0)];
+ let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
+ let row_alias_links = [0, 1];
+ let no_groups = [&[][..], &[][..]];
+ let mut bytes = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 2],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ research_set_nres_attr1(&mut bytes, u32::from_le_bytes(*b"TRF2"), 3);
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())),
+ Err(ResearchError::Binary(
+ fparkan_binary::DecodeError::UnexpectedEof { .. }
+ ))
+ ));
+ }
+
+ #[test]
+ fn research_cost_preserves_native_tree_order_and_f32_rounding() {
+ let costs = [(16_777_216.0, 20.0), (-16_777_216.0, 30.0), (1.0, 10.0)];
+ let aliases_forward = [
+ (b"A".as_slice(), 2),
+ (b"B".as_slice(), 0),
+ (b"C".as_slice(), 1),
+ ];
+ let aliases_reversed = [
+ (b"A".as_slice(), 0),
+ (b"B".as_slice(), 1),
+ (b"C".as_slice(), 2),
+ ];
+ let row_alias_links = [0, 1, 2];
+ let no_groups = [&[][..], &[][..], &[][..]];
+ let make_research = |aliases: &[(&[u8], u16)]| {
+ let bytes = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0x05; 3],
+ costs: &costs,
+ aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())).expect("f32 order TRF")
+ };
+ let candidate = research_unit_dat(&[b"A", b"B", b"C", b"B"], &[3, 0, 0, 0], &[1; 4]);
+
+ let forward = calc_full_research_cost(&candidate, &make_research(&aliases_forward))
+ .expect("forward order cost");
+ assert_eq!(forward.ore.to_bits(), 0.0_f32.to_bits());
+ assert_eq!(forward.power.to_bits(), 60.0_f32.to_bits());
+
+ let reversed = calc_full_research_cost(&candidate, &make_research(&aliases_reversed))
+ .expect("reversed order cost");
+ assert_eq!(reversed.ore.to_bits(), 1.0_f32.to_bits());
+ assert_eq!(reversed.power.to_bits(), 60.0_f32.to_bits());
+ }
+
+ #[test]
+ fn research_cost_obeys_subtree_and_record_gates() {
+ let costs = [
+ (1.0, 10.0),
+ (2.0, 20.0),
+ (3.0, 30.0),
+ (4.0, 40.0),
+ (5.0, 50.0),
+ ];
+ let names: [&[u8]; 5] = [b"A", b"B", b"C", b"D", b"E"];
+ let aliases: Vec<_> = names
+ .iter()
+ .enumerate()
+ .map(|(index, name)| (*name, u16::try_from(index).expect("row id")))
+ .collect();
+ let row_alias_links = [0, 1, 2, 3, 4];
+ let relations_45 = [&[][..]; 5];
+ let candidate = research_unit_dat(&[b"A", b"B", b"C", b"B"], &[3, 0, 0, 0], &[1; 4]);
+
+ let recursion_gate = research_cycle_fixture(3, &[0x05, 0x05, 0x05, 0x04, 0x04]);
+ let research = ResearchTrfDocument::decode(Arc::from(recursion_gate.into_boxed_slice()))
+ .expect("recursion-gate TRF");
+ assert_eq!(
+ calc_full_research_cost(&candidate, &research).expect("cost"),
+ ResearchCost {
+ ore: 6.0,
+ power: 60.0
+ }
+ );
+
+ let include_gate = research_cycle_fixture(3, &[0x04, 0x05, 0x05, 0x06, 0x04]);
+ let research = ResearchTrfDocument::decode(Arc::from(include_gate.into_boxed_slice()))
+ .expect("include-gate TRF");
+ assert_eq!(
+ calc_full_research_cost(&candidate, &research).expect("cost"),
+ ResearchCost {
+ ore: 6.0,
+ power: 60.0
+ }
+ );
+
+ let no_groups = [&[][..]; 5];
+ let isolated = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0x04; 5],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &relations_45,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ let research = ResearchTrfDocument::decode(Arc::from(isolated.into_boxed_slice()))
+ .expect("isolated TRF");
+ let child_pruned = research_unit_dat(&[b"A", b"B", b"C"], &[1, 1, 0], &[1, 0, 1]);
+ assert_eq!(
+ calc_full_research_cost(&child_pruned, &research).expect("pruned child cost"),
+ ResearchCost {
+ ore: 1.0,
+ power: 10.0
+ }
+ );
+ let root_pruned = research_unit_dat(&[b"A", b"B"], &[1, 0], &[0, 1]);
+ assert_eq!(
+ calc_full_research_cost(&root_pruned, &research).expect("pruned root cost"),
+ ResearchCost {
+ ore: 0.0,
+ power: 0.0
+ }
+ );
+ }
+
+ #[test]
+ fn research_cost_rejects_unknown_keys_unterminated_keys_and_missing_groups() {
+ let costs = [(1.0, 10.0), (2.0, 20.0)];
+ let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
+ let row_alias_links = [0, 1];
+ let no_groups = [&[][..], &[][..]];
+ let make_document = |groups: &[&[u32]]| {
+ let bytes = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0x04; 2],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
+ .expect("research table")
+ };
+
+ let no_group_document = make_document(&[]);
+ let candidate = research_unit_dat(&[b"A"], &[0], &[1]);
+ assert!(matches!(
+ calc_full_research_cost(&candidate, &no_group_document),
+ Err(ResearchError::MissingRelationGroup {
+ type_id,
+ index: 0,
+ group_count: 0
+ }) if type_id == u32::from_le_bytes(*b"TRF2")
+ ));
+
+ let valid_groups = [&[][..], &[][..]];
+ let document = make_document(&valid_groups);
+ let unknown = research_unit_dat(&[b"Z"], &[0], &[1]);
+ assert!(matches!(
+ calc_full_research_cost(&unknown, &document),
+ Err(ResearchError::UnknownResearchKey {
+ component_index: 0,
+ ..
+ })
+ ));
+ let unterminated = research_unit_dat(&[&[b'A'; 32]], &[0], &[1]);
+ assert!(matches!(
+ calc_full_research_cost(&unterminated, &document),
+ Err(ResearchError::MissingCandidateKeyTerminator { component_index: 0 })
+ ));
+ }
+
+ #[test]
+ #[ignore = "requires licensed Part 1 corpus"]
+ fn licensed_scream_trf_and_12hel1_dat_exercise_research_cost() {
+ let root = fixture_root("IS");
+ let trf =
+ std::fs::read(root.join("MISSIONS/SCRIPTS/scream.trf")).expect("licensed scream.trf");
+ let research = ResearchTrfDocument::decode(Arc::from(trf.into_boxed_slice()))
+ .expect("decode licensed research table");
+ assert_eq!(research.row_count(), 368);
+ assert_eq!(research.find_key(b"R_T_02").expect("R_T_02"), Some(205));
+ assert_eq!(research.row_name(205).expect("row 205 name"), b"R_T_02");
+ assert_eq!(
+ &research.row_words(205).expect("row 205 words")[..4],
+ &[0, 0, 0x40A0_0000, 0x4140_0000]
+ );
+ assert_eq!(research.flag_byte(205), Some(0x07));
+ assert!(research
+ .relation_2_3(205)
+ .expect("row 205 TRF2/TRF3 group")
+ .is_empty());
+ assert_eq!(
+ research.relation_4_5(205).expect("row 205 second group"),
+ &[196]
+ );
+
+ let bytes =
+ std::fs::read(root.join("UNITS/UNITS/AI/12hel1.dat")).expect("licensed 12hel1.dat");
+ let candidate = fparkan_prototype::decode_unit_dat(&bytes).expect("decode Unit DAT");
+ calc_full_research_cost(&candidate, &research).expect("calculate candidate research cost");
+ }
+
+ #[test]
+ #[ignore = "requires licensed Part 1 corpus"]
+ fn licensed_part1_ai_candidate_prefixes_and_ordered_research_scan() {
+ let root = fixture_root("IS");
+ let vfs = DirectoryVfs::new(&root);
+ let table_path = parse_path("MISSIONS/SCRIPTS/scream.trf").expect("research path");
+ let table_bytes = vfs.read(&table_path).expect("licensed scream.trf");
+ let research = ResearchTrfDocument::decode(table_bytes).expect("decode scream.trf");
+ let prefix = parse_path("UNITS/UNITS/AI").expect("AI unit directory");
+ let mut candidate_paths = vfs
+ .list(&prefix)
+ .expect("list Part 1 AI assets")
+ .into_iter()
+ .filter_map(|entry| {
+ entry
+ .path
+ .as_bytes()
+ .to_ascii_lowercase()
+ .ends_with(b".dat")
+ .then_some(entry.path)
+ })
+ .collect::<Vec<_>>();
+ candidate_paths.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes()));
+ assert!(
+ !candidate_paths.is_empty(),
+ "Part 1 AI catalog has DAT files"
+ );
+ assert!(candidate_paths
+ .iter()
+ .all(|path| path.as_bytes().is_ascii()));
+ assert!(candidate_paths
+ .windows(2)
+ .all(|pair| pair[0].as_bytes() <= pair[1].as_bytes()));
+
+ for path in &candidate_paths {
+ let bytes = vfs
+ .read(path)
+ .unwrap_or_else(|error| panic!("{}: {error}", path.as_str()));
+ research::decode_candidate_first_tree(&bytes)
+ .unwrap_or_else(|error| panic!("{}: {error}", path.as_str()));
+ }
+
+ let mut candidates = ResearchCandidateSet {
+ entries: candidate_paths
+ .iter()
+ .cloned()
+ .map(|path| ResearchCandidate { path, status: 0 })
+ .collect(),
+ selected_index: 0,
+ loaded: true,
+ status: 0xA5,
+ };
+ candidates
+ .refresh_selection(&vfs, Some(&research))
+ .expect("scan Part 1 AI candidates in byte-sorted supplied order");
+ assert!(
+ usize::try_from(candidates.selected_index)
+ .is_ok_and(|index| index < candidate_paths.len()),
+ "selected index remains within the supplied catalog"
+ );
+ assert_eq!(
+ candidates.status, 0,
+ "at least one catalog entry was scanned"
+ );
+ }
+
+ #[test]
+ #[ignore = "requires licensed Part 1 corpus"]
+ fn licensed_part1_research_trf_files_all_decode() {
+ fn collect_trf_files(directory: &std::path::Path, files: &mut Vec<PathBuf>) {
+ for entry in std::fs::read_dir(directory).expect("read licensed directory") {
+ let entry = entry.expect("read directory entry");
+ let path = entry.path();
+ let kind = entry.file_type().expect("read entry type");
+ if kind.is_dir() {
+ collect_trf_files(&path, files);
+ } else if !kind.is_symlink()
+ && path
+ .extension()
+ .map(|extension| extension.to_string_lossy().eq_ignore_ascii_case("trf"))
+ .unwrap_or(false)
+ {
+ files.push(path);
+ }
+ }
+ }
+
+ let root = fixture_root("IS");
+ let mut files = Vec::new();
+ collect_trf_files(&root, &mut files);
+ files.sort();
+ assert!(!files.is_empty(), "licensed corpus contains no .trf files");
+ for path in files {
+ let bytes = std::fs::read(&path).expect("read TRF resource");
+ ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
+ .unwrap_or_else(|error| panic!("{}: {error}", path.display()));
+ }
+ }
+
+ const RESEARCH_TEST_TAIL: &[u8] = &[0xA5, 0x5A];
+
+ struct ResearchFixtureSpec<'a> {
+ trf0_attr2: u32,
+ flags: &'a [u8],
+ costs: &'a [(f32, f32)],
+ aliases: &'a [(&'a [u8], u16)],
+ row_alias_links: &'a [u16],
+ relations_23: &'a [&'a [u32]],
+ relations_45: &'a [&'a [u32]],
+ include_trf3: bool,
+ include_trf5: bool,
+ omitted_chunks: &'a [u32],
+ }
+
+ fn research_selector_document(
+ flags: &[u8],
+ costs: &[(f32, f32)],
+ aliases: &[(&[u8], u16)],
+ row_alias_links: &[u16],
+ ) -> ResearchTrfDocument {
+ let empty_groups = vec![&[][..]; costs.len()];
+ let bytes = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags,
+ costs,
+ aliases,
+ row_alias_links,
+ relations_23: &empty_groups,
+ relations_45: &empty_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
+ .expect("synthetic research selector table")
+ }
+
+ fn research_candidate_set(
+ entries: &[(&str, u8)],
+ selected_index: u32,
+ loaded: bool,
+ status: u8,
+ ) -> ResearchCandidateSet {
+ ResearchCandidateSet {
+ entries: entries
+ .iter()
+ .map(|(path, status)| ResearchCandidate {
+ path: parse_path(path).expect("candidate path"),
+ status: *status,
+ })
+ .collect(),
+ selected_index,
+ loaded,
+ status,
+ }
+ }
+
+ fn insert_research_dat(vfs: &mut MemoryVfs, path: &str, bytes: Vec<u8>) {
+ vfs.insert(
+ parse_path(path).expect("candidate path"),
+ Arc::from(bytes.into_boxed_slice()),
+ );
+ }
+
+ fn research_fixture(spec: ResearchFixtureSpec<'_>) -> Vec<u8> {
+ assert_eq!(spec.costs.len(), spec.row_alias_links.len());
+
+ let mut rows = Vec::with_capacity(spec.costs.len());
+ for ((ore, power), pair_index) in spec.costs.iter().zip(spec.row_alias_links) {
+ let mut row = [0_u32; 10];
+ row[0] = power.to_bits();
+ row[1] = ore.to_bits();
+ row[8] = 0x09FF_0000 | u32::from(*pair_index);
+ rows.push(row);
+ }
+ let rows_payload = research_words_payload(
+ &rows
+ .iter()
+ .flat_map(|row| row.iter().copied())
+ .collect::<Vec<_>>(),
+ );
+
+ let mut name_pool = Vec::new();
+ let mut pairs = Vec::with_capacity(spec.aliases.len());
+ for (name, row_index) in spec.aliases {
+ let name_offset = u16::try_from(name_pool.len()).expect("test pool offset");
+ name_pool.extend_from_slice(name);
+ name_pool.push(0);
+ pairs.push((name_offset, *row_index));
+ }
+ let pairs_payload = research_pairs_payload(&pairs);
+ let (counts_23, ids_23) = research_relation_payload(spec.relations_23);
+ let (counts_45, ids_45) = research_relation_payload(spec.relations_45);
+ let with_tail = |mut payload: Vec<u8>| {
+ payload.extend_from_slice(RESEARCH_TEST_TAIL);
+ payload
+ };
+
+ let mut payloads = vec![
+ (
+ u32::from_le_bytes(*b"TRF0"),
+ with_tail(rows_payload),
+ spec.trf0_attr2,
+ ),
+ (u32::from_le_bytes(*b"TRF1"), spec.flags.to_vec(), 0),
+ (u32::from_le_bytes(*b"TRFB"), with_tail(pairs_payload), 0),
+ (u32::from_le_bytes(*b"TRF6"), name_pool, 0),
+ (u32::from_le_bytes(*b"TRF7"), Vec::new(), 0),
+ (u32::from_le_bytes(*b"TRF8"), Vec::new(), 0),
+ (u32::from_le_bytes(*b"TRF9"), Vec::new(), 0),
+ (u32::from_le_bytes(*b"TRFA"), Vec::new(), 0),
+ (u32::from_le_bytes(*b"TRF2"), with_tail(counts_23), 0),
+ (u32::from_le_bytes(*b"TRF4"), with_tail(counts_45), 0),
+ ];
+ if spec.include_trf3 {
+ payloads.push((u32::from_le_bytes(*b"TRF3"), with_tail(ids_23), 0));
+ }
+ if spec.include_trf5 {
+ payloads.push((u32::from_le_bytes(*b"TRF5"), with_tail(ids_45), 0));
+ }
+
+ let entries: Vec<_> = payloads
+ .iter()
+ .filter(|(type_id, _, _)| !spec.omitted_chunks.contains(type_id))
+ .map(|(type_id, payload, attr2)| TestNresEntry {
+ name: b"ResTree",
+ payload,
+ type_id: *type_id,
+ attr2: *attr2,
+ })
+ .collect();
+ let mut archive = build_nres_with_meta(&entries);
+ for (type_id, count) in [
+ (u32::from_le_bytes(*b"TRF0"), spec.costs.len()),
+ (u32::from_le_bytes(*b"TRF1"), spec.flags.len()),
+ (u32::from_le_bytes(*b"TRFB"), spec.aliases.len()),
+ (u32::from_le_bytes(*b"TRF2"), spec.relations_23.len()),
+ (u32::from_le_bytes(*b"TRF4"), spec.relations_45.len()),
+ ] {
+ if !spec.omitted_chunks.contains(&type_id) {
+ research_set_nres_attr1(
+ &mut archive,
+ type_id,
+ u32::try_from(count).expect("test count"),
+ );
+ }
+ }
+ if spec.include_trf3 && !spec.omitted_chunks.contains(&u32::from_le_bytes(*b"TRF3")) {
+ let count: usize = spec.relations_23.iter().map(|group| group.len()).sum();
+ research_set_nres_attr1(
+ &mut archive,
+ u32::from_le_bytes(*b"TRF3"),
+ u32::try_from(count).expect("test dependency count"),
+ );
+ }
+ if spec.include_trf5 && !spec.omitted_chunks.contains(&u32::from_le_bytes(*b"TRF5")) {
+ let count: usize = spec.relations_45.iter().map(|group| group.len()).sum();
+ research_set_nres_attr1(
+ &mut archive,
+ u32::from_le_bytes(*b"TRF5"),
+ u32::try_from(count).expect("test dependency count"),
+ );
+ }
+ archive
+ }
+
+ fn research_relation_payload(groups: &[&[u32]]) -> (Vec<u8>, Vec<u8>) {
+ let counts: Vec<_> = groups
+ .iter()
+ .map(|group| u32::try_from(group.len()).expect("test dependency group count"))
+ .collect();
+ let ids: Vec<_> = groups
+ .iter()
+ .flat_map(|group| group.iter().copied())
+ .collect();
+ (
+ research_words_payload(&counts),
+ research_words_payload(&ids),
+ )
+ }
+
+ fn research_set_payload_bytes(bytes: &mut [u8], type_id: u32, offset: usize, value: &[u8]) {
+ let entry_count = u32::from_le_bytes(bytes[8..12].try_into().expect("entry count"));
+ let total_size = u32::from_le_bytes(bytes[12..16].try_into().expect("total size"));
+ let directory_start = usize::try_from(total_size - entry_count * 64).expect("directory");
+ for entry in bytes[directory_start..].chunks_exact(64) {
+ if u32::from_le_bytes(entry[..4].try_into().expect("type id")) != type_id {
+ continue;
+ }
+ let payload_start = usize::try_from(u32::from_le_bytes(
+ entry[56..60].try_into().expect("payload offset"),
+ ))
+ .expect("payload offset");
+ let payload_len = usize::try_from(u32::from_le_bytes(
+ entry[12..16].try_into().expect("payload size"),
+ ))
+ .expect("payload size");
+ let end = offset.checked_add(value.len()).expect("payload end");
+ assert!(end <= payload_len, "test mutation stays within payload");
+ bytes[payload_start + offset..payload_start + end].copy_from_slice(value);
+ return;
+ }
+ panic!("NRes entry type {type_id:#010x} was not built");
+ }
+
+ fn research_set_payload_byte(bytes: &mut [u8], type_id: u32, offset: usize, value: u8) {
+ research_set_payload_bytes(bytes, type_id, offset, &[value]);
+ }
+
+ fn research_set_payload_u16(bytes: &mut [u8], type_id: u32, offset: usize, value: u16) {
+ research_set_payload_bytes(bytes, type_id, offset, &value.to_le_bytes());
+ }
+
+ fn research_set_payload_u32(bytes: &mut [u8], type_id: u32, offset: usize, value: u32) {
+ research_set_payload_bytes(bytes, type_id, offset, &value.to_le_bytes());
+ }
+
+ fn research_cycle_fixture(trf0_attr2: u32, flags: &[u8]) -> Vec<u8> {
+ let costs = [
+ (1.0_f32, 10.0_f32),
+ (2.0_f32, 20.0_f32),
+ (3.0_f32, 30.0_f32),
+ (4.0_f32, 40.0_f32),
+ (5.0_f32, 50.0_f32),
+ ];
+ let mut pool = Vec::new();
+ let mut pairs = Vec::new();
+ let mut rows = Vec::new();
+ for (index, ((ore, power), name)) in costs
+ .iter()
+ .zip([b"A".as_slice(), b"B", b"C", b"D", b"E"])
+ .enumerate()
+ {
+ let offset = u16::try_from(pool.len()).expect("pool offset");
+ pool.extend_from_slice(name);
+ pool.push(0);
+ pairs.push((offset, u16::try_from(index).expect("row index")));
+ rows.push([
+ power.to_bits(),
+ ore.to_bits(),
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0x09FF_0000 | u32::try_from(index).expect("pair index"),
+ 0,
+ ]);
+ }
+
+ let rows_payload = research_words_payload(
+ &rows
+ .iter()
+ .flat_map(|row| row.iter().copied())
+ .collect::<Vec<_>>(),
+ );
+ let pairs_payload = research_pairs_payload(&pairs);
+ let count_23 = [2_u32, 0, 0, 1, 1];
+ let ids_23 = [3_u32, 1, 4, 0];
+ let count_45 = [0_u32; 5];
+ let count_23_payload = research_words_payload(&count_23);
+ let ids_23_payload = research_words_payload(&ids_23);
+ let count_45_payload = research_words_payload(&count_45);
+ let payloads: [(u32, &[u8], u32); 11] = [
+ (
+ u32::from_le_bytes(*b"TRF0"),
+ rows_payload.as_slice(),
+ trf0_attr2,
+ ),
+ (u32::from_le_bytes(*b"TRF1"), flags, 0),
+ (u32::from_le_bytes(*b"TRFB"), pairs_payload.as_slice(), 0),
+ (u32::from_le_bytes(*b"TRF6"), pool.as_slice(), 0),
+ (u32::from_le_bytes(*b"TRF7"), &[][..], 0),
+ (u32::from_le_bytes(*b"TRF8"), &[][..], 0),
+ (u32::from_le_bytes(*b"TRF9"), &[][..], 0),
+ (u32::from_le_bytes(*b"TRFA"), &[][..], 0),
+ (u32::from_le_bytes(*b"TRF2"), &count_23_payload, 0),
+ (u32::from_le_bytes(*b"TRF3"), &ids_23_payload, 0),
+ (u32::from_le_bytes(*b"TRF4"), &count_45_payload, 0),
+ ];
+ let entries: Vec<_> = payloads
+ .iter()
+ .map(|(type_id, payload, attr2)| TestNresEntry {
+ name: b"ResTree",
+ payload,
+ type_id: *type_id,
+ attr2: *attr2,
+ })
+ .collect();
+ let mut archive = build_nres_with_meta(&entries);
+ for (type_id, count) in [
+ (u32::from_le_bytes(*b"TRF0"), 5),
+ (u32::from_le_bytes(*b"TRF1"), 5),
+ (u32::from_le_bytes(*b"TRFB"), 5),
+ (u32::from_le_bytes(*b"TRF2"), 5),
+ (u32::from_le_bytes(*b"TRF3"), 3),
+ (u32::from_le_bytes(*b"TRF4"), 5),
+ ] {
+ research_set_nres_attr1(&mut archive, type_id, count);
+ }
+ archive
+ }
+
+ fn research_words_payload(words: &[u32]) -> Vec<u8> {
+ words.iter().flat_map(|word| word.to_le_bytes()).collect()
+ }
+
+ fn research_pairs_payload(pairs: &[(u16, u16)]) -> Vec<u8> {
+ pairs
+ .iter()
+ .flat_map(|(offset, index)| offset.to_le_bytes().into_iter().chain(index.to_le_bytes()))
+ .collect()
+ }
+
+ fn research_set_nres_attr1(bytes: &mut [u8], type_id: u32, count: u32) {
+ let entry_count = u32::from_le_bytes(bytes[8..12].try_into().expect("entry count"));
+ let total_size = u32::from_le_bytes(bytes[12..16].try_into().expect("total size"));
+ let directory_start = usize::try_from(total_size - entry_count * 64).expect("directory");
+ for entry in bytes[directory_start..].chunks_exact_mut(64) {
+ if u32::from_le_bytes(entry[..4].try_into().expect("type id")) == type_id {
+ entry[4..8].copy_from_slice(&count.to_le_bytes());
+ return;
+ }
+ }
+ panic!("NRes entry type {type_id:#010x} was not built");
+ }
+
+ fn research_unit_dat_payload(keys: &[&[u8]], child_counts: &[u32], kinds: &[u32]) -> Vec<u8> {
+ assert_eq!(keys.len(), child_counts.len());
+ assert_eq!(keys.len(), kinds.len());
+ let mut bytes = vec![0; 8 + keys.len() * 112];
+ bytes[..4].copy_from_slice(&0x0000_F0F1_u32.to_le_bytes());
+ for (index, key) in keys.iter().enumerate() {
+ let offset = 8 + index * 112;
+ bytes[offset + 32..offset + 32 + key.len()].copy_from_slice(key);
+ bytes[offset + 64..offset + 68].copy_from_slice(&kinds[index].to_le_bytes());
+ bytes[offset + 108..offset + 112].copy_from_slice(&child_counts[index].to_le_bytes());
+ }
+ bytes
+ }
+
+ fn research_unit_dat(
+ keys: &[&[u8]],
+ child_counts: &[u32],
+ kinds: &[u32],
+ ) -> fparkan_prototype::UnitDat {
+ fparkan_prototype::decode_unit_dat(&research_unit_dat_payload(keys, child_counts, kinds))
+ .expect("candidate Unit DAT")
+ }
+
fn resource_device_record(tag: u32, key: u32, base_bits: u32) -> PreparedControlTypedRecord {
let mut fixed_words = [0; 44];
fixed_words[1] = key;
@@ -5063,6 +6535,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"),
@@ -6678,6 +8276,90 @@ mod tests {
}
#[test]
+ fn mission_asset_plan_counts_texture_and_lightmap_requests() {
+ let msh = minimal_model_archive();
+ let mat0 = mat0_with_texture(b"TEX_A");
+ let texm = texm_payload();
+ let repo = repository_with_archives_meta(&[
+ (
+ "static.rlb",
+ &[
+ TestNresEntry {
+ name: b"tree.msh",
+ payload: &msh,
+ type_id: 0x4853_454D,
+ attr2: 0,
+ },
+ TestNresEntry {
+ name: b"tree.wea",
+ payload: b"2\n0 MAT_A\n1 MAT_A\n\nLIGHTMAPS\n2\n0 LM_A\n1 LM_A\n",
+ type_id: WEAR_KIND,
+ attr2: 0,
+ },
+ ],
+ ),
+ (
+ "material.lib",
+ &[TestNresEntry {
+ name: b"MAT_A",
+ payload: &mat0,
+ type_id: MAT0_KIND,
+ attr2: 0,
+ }],
+ ),
+ (
+ TEXTURES_ARCHIVE,
+ &[TestNresEntry {
+ name: b"TEX_A",
+ payload: &texm,
+ type_id: 0,
+ attr2: 0,
+ }],
+ ),
+ (
+ LIGHTMAP_ARCHIVE,
+ &[TestNresEntry {
+ name: b"LM_A",
+ payload: &texm,
+ type_id: 0,
+ attr2: 0,
+ }],
+ ),
+ ]);
+ let mesh_key = ResourceKey {
+ archive: parse_path("static.rlb").expect("archive"),
+ name: resource_name(b"tree.msh"),
+ type_id: Some(0x4853_454D),
+ };
+ let prototype = EffectivePrototype {
+ key: fparkan_prototype::PrototypeKey(resource_name(b"tree")),
+ geometry: PrototypeGeometry::Mesh(mesh_key.clone()),
+ source: fparkan_prototype::PrototypeSource::DirectArchive,
+ source_type: None,
+ dependencies: vec![mesh_key],
+ control_resource: None,
+ point_resource: None,
+ node_data_resource: None,
+ bas_resource: None,
+ };
+ let (assets, report) = prepare_mission_assets_profiled_with_repository(
+ &repo,
+ &[0..1],
+ &[prototype],
+ AssetPreparationLimits::default(),
+ )
+ .expect("prepared assets");
+ let plan = assets.to_plan();
+
+ assert_eq!(assets.visuals.len(), 1);
+ assert_eq!(assets.visuals[0].texture_count, 2);
+ assert_eq!(assets.visuals[0].lightmap_count, 2);
+ assert_eq!(assets.textures.len(), 2);
+ assert_eq!(report.texture_count, 2);
+ assert_eq!((plan.texture_count, plan.lightmap_count), (2, 2));
+ }
+
+ #[test]
fn asset_preparation_limits_reject_texture_pixel_budget() {
let (repo, prototypes) = collision_fixture();
@@ -6772,6 +8454,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-assets/src/research.rs b/crates/fparkan-assets/src/research.rs
new file mode 100644
index 0000000..b261928
--- /dev/null
+++ b/crates/fparkan-assets/src/research.rs
@@ -0,0 +1,1151 @@
+use crate::{decode_nres_payload, NresDocument};
+use fparkan_binary::{checked_allocation_len, checked_count_bytes, Cursor, DecodeError, Limits};
+use fparkan_nres::{EntryId, NresEntry};
+use fparkan_path::NormalizedPath;
+use fparkan_prototype::{
+ decode_unit_dat, unit_component_tree, PrototypeError, UnitComponentRecord,
+ UnitComponentTreeNode, UnitDat,
+};
+use fparkan_vfs::Vfs;
+use std::collections::HashMap;
+use std::fmt;
+use std::sync::Arc;
+
+const TRF0: u32 = u32::from_le_bytes(*b"TRF0");
+const TRF1: u32 = u32::from_le_bytes(*b"TRF1");
+const TRF2: u32 = u32::from_le_bytes(*b"TRF2");
+const TRF3: u32 = u32::from_le_bytes(*b"TRF3");
+const TRF4: u32 = u32::from_le_bytes(*b"TRF4");
+const TRF5: u32 = u32::from_le_bytes(*b"TRF5");
+const TRF6: u32 = u32::from_le_bytes(*b"TRF6");
+const TRF7: u32 = u32::from_le_bytes(*b"TRF7");
+const TRF8: u32 = u32::from_le_bytes(*b"TRF8");
+const TRF9: u32 = u32::from_le_bytes(*b"TRF9");
+const TRFA: u32 = u32::from_le_bytes(*b"TRFA");
+const TRFB: u32 = u32::from_le_bytes(*b"TRFB");
+const UNIT_DAT_MAGIC: u32 = 0x0000_F0F1;
+const UNIT_DAT_HEADER_BYTES: usize = 8;
+const UNIT_DAT_RECORD_BYTES: usize = 112;
+const INITIAL_RESEARCH_COST_BOUND: f32 = f32::from_bits(0x4b18_967f);
+
+const RESEARCH_CHUNKS: [u32; 12] = [
+ TRF0, TRF1, TRF2, TRF3, TRF4, TRF5, TRF6, TRF7, TRF8, TRF9, TRFA, TRFB,
+];
+
+/// Ore and power outputs from one native CalcFullResearchCost call.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ResearchCost {
+ /// First native output, labeled ore by the Calc debug string.
+ pub ore: f32,
+ /// Second native output, labeled power by the Calc debug string.
+ pub power: f32,
+}
+
+/// One decoded MisLoad research table and its original NRes archive.
+#[derive(Debug)]
+pub struct ResearchTrfDocument {
+ archive: NresDocument,
+ records: Vec<[u32; 10]>,
+ flags: Vec<u8>,
+ aliases: Vec<ResearchAlias>,
+ name_pool: Vec<u8>,
+ relation_2_3: ResearchRelations,
+ relation_4_5: ResearchRelations,
+}
+
+/// One ordered research candidate and its native per-entry status byte.
+#[derive(Clone, Debug)]
+pub struct ResearchCandidate {
+ /// Unit DAT path read through the caller's VFS.
+ pub path: NormalizedPath,
+ /// Native status: zero entries are considered by the next refresh.
+ pub status: u8,
+}
+
+/// Mutable candidate catalog and selected-index state for the research chooser.
+#[derive(Clone, Debug, Default)]
+pub struct ResearchCandidateSet {
+ /// Candidate entries in native supplied order.
+ pub entries: Vec<ResearchCandidate>,
+ /// Selected candidate index, preserved when no lower-cost winner exists.
+ pub selected_index: u32,
+ /// Whether the candidate catalog has been loaded.
+ pub loaded: bool,
+ /// Native candidate-set scan status byte.
+ pub status: u8,
+}
+
+impl ResearchCandidateSet {
+ /// Refreshes this set's native selector state from the supplied research table.
+ ///
+ /// # Errors
+ ///
+ /// Returns explicit errors for unsupported lookup semantics or malformed
+ /// research-table fields reached by the predicates.
+ /// Missing paths, invalid candidate DAT framing and an empty-table cost
+ /// failure skip only that entry.
+ pub fn refresh_selection(
+ &mut self,
+ vfs: &dyn Vfs,
+ research: Option<&ResearchTrfDocument>,
+ ) -> Result<(), ResearchError> {
+ let Some(research) = research else {
+ return Ok(());
+ };
+ if !self.loaded {
+ return Ok(());
+ }
+
+ self.status = 1;
+ let mut running_bound = INITIAL_RESEARCH_COST_BOUND;
+ for index in 0..self.entries.len() {
+ if self.entries[index].status != 0 {
+ continue;
+ }
+
+ self.status = 0;
+ let Ok(bytes) = vfs.read(&self.entries[index].path) else {
+ continue;
+ };
+ let Ok((candidate, tree)) = decode_candidate_first_tree(&bytes) else {
+ continue;
+ };
+
+ let first = first_research_predicate(&candidate, &tree, research)?;
+ self.entries[index].status = u8::from(first);
+ if !first {
+ continue;
+ }
+
+ let second = second_research_predicate(&candidate, &tree, research)?;
+ self.entries[index].status = u8::from(second);
+ let cost = match calc_full_research_cost(&candidate, research) {
+ Ok(cost) => cost,
+ Err(ResearchError::EmptyResearchTable) => continue,
+ Err(error) => return Err(error),
+ };
+ if !second && cost.ore < running_bound {
+ let selected_index = u32::try_from(index)
+ .map_err(|_| ResearchError::Binary(DecodeError::IntegerOverflow))?;
+ running_bound = cost.ore;
+ self.selected_index = selected_index;
+ }
+ }
+ Ok(())
+ }
+}
+
+#[derive(Clone, Debug)]
+struct ResearchAlias {
+ name_range: std::ops::Range<usize>,
+ row_index: u16,
+}
+
+#[derive(Debug)]
+struct ResearchRelations {
+ table_id: u32,
+ group_starts: Vec<usize>,
+ indices: Vec<u32>,
+}
+
+#[derive(Debug)]
+struct Chunk<'a> {
+ entry: &'a NresEntry,
+ payload: &'a [u8],
+}
+
+/// TRF decoding and cost-calculation error.
+#[derive(Debug)]
+pub enum ResearchError {
+ /// The outer NRes archive is malformed.
+ Nres(fparkan_nres::NresError),
+ /// A bounded TRF field is truncated or overflows.
+ Binary(DecodeError),
+ /// A native-required TRF chunk is absent.
+ MissingChunk { type_id: u32 },
+ /// A recognized TRF chunk occurs more than once.
+ DuplicateChunk { type_id: u32 },
+ /// The native loader requires TRF0 attribute 2 to equal three.
+ InvalidTrf0Attribute { got: u32 },
+ /// A TRF1 payload is too large for the native zero-initialized flag buffer.
+ FlagsTooLong { row_count: usize, actual: usize },
+ /// A TRFB pair points outside the TRF6 name pool.
+ InvalidNameOffset { pair_index: usize, offset: u16 },
+ /// A TRF6 string does not end in NUL before the pool ends.
+ UnterminatedName { pair_index: usize, offset: u16 },
+ /// A TRF0 row's low 16-bit pair link is outside TRFB.
+ InvalidRecordPair {
+ record_index: usize,
+ pair_index: u16,
+ pair_count: usize,
+ },
+ /// A key or relation resolves to a research row outside TRF0.
+ InvalidResearchIndex {
+ type_id: u32,
+ index: u32,
+ row_count: usize,
+ },
+ /// A relation table has no group at this research row.
+ MissingRelationGroup {
+ type_id: u32,
+ index: usize,
+ group_count: usize,
+ },
+ /// A required non-empty relation stream is absent.
+ MissingRelationStream { type_id: u32, required_ids: u64 },
+ /// A candidate Unit DAT resource field is not NUL-terminated.
+ MissingCandidateKeyTerminator { component_index: usize },
+ /// No TRFB key matches an enabled candidate component.
+ UnknownResearchKey {
+ component_index: usize,
+ key: Vec<u8>,
+ },
+ /// A non-ASCII lookup would require an unverified locale-specific fold.
+ UnsupportedNonAsciiLookup {
+ query: Vec<u8>,
+ alias: Option<Vec<u8>>,
+ },
+ /// The research table has no rows.
+ EmptyResearchTable,
+ /// The candidate Unit DAT has no root component.
+ EmptyCandidateTree,
+ /// The existing Unit DAT tree builder rejected the child-count framing.
+ CandidateTree(PrototypeError),
+}
+
+impl fmt::Display for ResearchError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Nres(source) => write!(formatter, "{source}"),
+ Self::Binary(source) => write!(formatter, "{source}"),
+ Self::MissingChunk { type_id } => {
+ write!(formatter, "research NRes is missing chunk {type_id:#010x}")
+ }
+ Self::DuplicateChunk { type_id } => {
+ write!(formatter, "research NRes has duplicate chunk {type_id:#010x}")
+ }
+ Self::InvalidTrf0Attribute { got } => {
+ write!(formatter, "TRF0 attribute 2 is {got}, expected 3")
+ }
+ Self::FlagsTooLong { row_count, actual } => write!(
+ formatter,
+ "TRF1 has {actual} flags, exceeding the {row_count}-byte native buffer"
+ ),
+ Self::InvalidNameOffset { pair_index, offset } => write!(
+ formatter,
+ "TRFB pair {pair_index} name offset {offset} is outside TRF6"
+ ),
+ Self::UnterminatedName { pair_index, offset } => write!(
+ formatter,
+ "TRFB pair {pair_index} name at TRF6 offset {offset} is not NUL-terminated"
+ ),
+ Self::InvalidRecordPair {
+ record_index,
+ pair_index,
+ pair_count,
+ } => write!(
+ formatter,
+ "TRF0 row {record_index} links to TRFB pair {pair_index}, but there are {pair_count} pairs"
+ ),
+ Self::InvalidResearchIndex {
+ type_id,
+ index,
+ row_count,
+ } => write!(
+ formatter,
+ "research index {index} from {type_id:#010x} exceeds TRF0 row count {row_count}"
+ ),
+ Self::MissingRelationGroup {
+ type_id,
+ index,
+ group_count,
+ } => write!(
+ formatter,
+ "{type_id:#010x} has no relation group for row {index} (group count {group_count})"
+ ),
+ Self::MissingRelationStream {
+ type_id,
+ required_ids,
+ } => write!(
+ formatter,
+ "{type_id:#010x} is absent but its count table requires {required_ids} indices"
+ ),
+ Self::MissingCandidateKeyTerminator { component_index } => write!(
+ formatter,
+ "candidate component {component_index} has no NUL in its 32-byte resource key"
+ ),
+ Self::UnknownResearchKey {
+ component_index,
+ key,
+ } => write!(
+ formatter,
+ "candidate component {component_index} key {:?} is absent from TRFB",
+ String::from_utf8_lossy(key)
+ ),
+ Self::UnsupportedNonAsciiLookup { query, alias } => {
+ if let Some(alias) = alias {
+ write!(
+ formatter,
+ "research key comparison between {:?} and {:?} needs unverified non-ASCII case folding",
+ String::from_utf8_lossy(query),
+ String::from_utf8_lossy(alias)
+ )
+ } else {
+ write!(
+ formatter,
+ "research key {:?} needs unverified non-ASCII case folding",
+ String::from_utf8_lossy(query)
+ )
+ }
+ }
+ Self::EmptyResearchTable => write!(formatter, "research table has no rows"),
+ Self::EmptyCandidateTree => write!(formatter, "candidate Unit DAT has no root component"),
+ Self::CandidateTree(source) => write!(formatter, "{source}"),
+ }
+ }
+}
+
+impl std::error::Error for ResearchError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Nres(source) => Some(source),
+ Self::Binary(source) => Some(source),
+ Self::CandidateTree(source) => Some(source),
+ Self::MissingChunk { .. }
+ | Self::DuplicateChunk { .. }
+ | Self::InvalidTrf0Attribute { .. }
+ | Self::FlagsTooLong { .. }
+ | Self::InvalidNameOffset { .. }
+ | Self::UnterminatedName { .. }
+ | Self::InvalidRecordPair { .. }
+ | Self::InvalidResearchIndex { .. }
+ | Self::MissingRelationGroup { .. }
+ | Self::MissingRelationStream { .. }
+ | Self::MissingCandidateKeyTerminator { .. }
+ | Self::UnknownResearchKey { .. }
+ | Self::UnsupportedNonAsciiLookup { .. }
+ | Self::EmptyResearchTable
+ | Self::EmptyCandidateTree => None,
+ }
+ }
+}
+
+impl From<fparkan_nres::NresError> for ResearchError {
+ fn from(value: fparkan_nres::NresError) -> Self {
+ Self::Nres(value)
+ }
+}
+
+impl From<DecodeError> for ResearchError {
+ fn from(value: DecodeError) -> Self {
+ Self::Binary(value)
+ }
+}
+
+impl From<PrototypeError> for ResearchError {
+ fn from(value: PrototypeError) -> Self {
+ Self::CandidateTree(value)
+ }
+}
+
+impl ResearchTrfDocument {
+ /// Decodes the native research table chunks from an NRes archive.
+ ///
+ /// NRes validates the archive framing and payload ranges. This reader then
+ /// validates only the TRF bytes needed by its row, key, and relation views.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error for malformed NRes framing, a missing required chunk,
+ /// an invalid TRF0 guard, or a referenced byte range outside its chunk.
+ pub fn decode(bytes: Arc<[u8]>) -> Result<Self, ResearchError> {
+ let archive = decode_nres_payload(bytes)?;
+ let mut chunk_ids = HashMap::new();
+ for entry in archive.entries() {
+ let type_id = entry.meta().type_id;
+ if RESEARCH_CHUNKS.contains(&type_id) && chunk_ids.insert(type_id, entry.id()).is_some()
+ {
+ return Err(ResearchError::DuplicateChunk { type_id });
+ }
+ }
+
+ let trf0 = required_chunk(&archive, &chunk_ids, TRF0)?;
+ if trf0.entry.meta().attr2 != 3 {
+ return Err(ResearchError::InvalidTrf0Attribute {
+ got: trf0.entry.meta().attr2,
+ });
+ }
+ let row_count = trf0.entry.meta().attr1;
+ let row_bytes = counted_prefix(trf0.payload, u64::from(row_count), 40)?;
+ let records = read_research_rows(row_bytes, row_count)?;
+
+ let trf1 = required_chunk(&archive, &chunk_ids, TRF1)?;
+ let flag_count = usize::try_from(row_count).map_err(|_| DecodeError::IntegerOverflow)?;
+ if trf1.payload.len() > flag_count {
+ return Err(ResearchError::FlagsTooLong {
+ row_count: flag_count,
+ actual: trf1.payload.len(),
+ });
+ }
+ let mut flags = vec![0; flag_count];
+ flags[..trf1.payload.len()].copy_from_slice(trf1.payload);
+
+ let trfb = required_chunk(&archive, &chunk_ids, TRFB)?;
+ let alias_count = trfb.entry.meta().attr1;
+ let pair_bytes = counted_prefix(trfb.payload, u64::from(alias_count), 4)?;
+ let trf6 = required_chunk(&archive, &chunk_ids, TRF6)?;
+ let name_pool = trf6.payload.to_vec();
+ let aliases = read_aliases(pair_bytes, &name_pool, alias_count)?;
+
+ for (record_index, record) in records.iter().enumerate() {
+ // The native reader casts the packed word to u16 before indexing TRFB.
+ let pair_index = record[8] as u16;
+ if usize::from(pair_index) >= aliases.len() {
+ return Err(ResearchError::InvalidRecordPair {
+ record_index,
+ pair_index,
+ pair_count: aliases.len(),
+ });
+ }
+ }
+
+ for type_id in [TRF7, TRF8, TRF9, TRFA] {
+ required_chunk(&archive, &chunk_ids, type_id)?;
+ }
+
+ let relation_2_3 = read_relations(&archive, &chunk_ids, TRF2, TRF3)?;
+ let relation_4_5 = read_relations(&archive, &chunk_ids, TRF4, TRF5)?;
+
+ Ok(Self {
+ archive,
+ records,
+ flags,
+ aliases,
+ name_pool,
+ relation_2_3,
+ relation_4_5,
+ })
+ }
+
+ /// Returns the retained NRes document, including opaque chunks and unused payload tails.
+ #[must_use]
+ pub fn archive(&self) -> &NresDocument {
+ &self.archive
+ }
+
+ /// Number of TRF0 rows, taken from that chunk's own attr1.
+ #[must_use]
+ pub fn row_count(&self) -> usize {
+ self.records.len()
+ }
+
+ /// Looks up the first ASCII-case-insensitive TRFB alias in source order.
+ ///
+ /// The shipped research keys are ASCII and use ASCII case-insensitive matching.
+ /// Comparisons involving non-ASCII bytes fail explicitly because native
+ /// locale-specific folding for those bytes is unknown.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if matching requires unverified non-ASCII folding or
+ /// the selected alias contains an out-of-range row id.
+ pub fn find_key(&self, key: &[u8]) -> Result<Option<usize>, ResearchError> {
+ let key = key.split(|byte| *byte == 0).next().unwrap_or_default();
+ if !key.is_ascii() {
+ return Err(ResearchError::UnsupportedNonAsciiLookup {
+ query: key.to_vec(),
+ alias: None,
+ });
+ }
+ for alias in &self.aliases {
+ let name = &self.name_pool[alias.name_range.clone()];
+ if !name.is_ascii() {
+ return Err(ResearchError::UnsupportedNonAsciiLookup {
+ query: key.to_vec(),
+ alias: Some(name.to_vec()),
+ });
+ }
+ if name.eq_ignore_ascii_case(key) {
+ let row_index = u32::from(alias.row_index);
+ if usize::from(alias.row_index) >= self.records.len() {
+ return Err(ResearchError::InvalidResearchIndex {
+ type_id: TRFB,
+ index: row_index,
+ row_count: self.records.len(),
+ });
+ }
+ return Ok(Some(usize::from(alias.row_index)));
+ }
+ }
+ Ok(None)
+ }
+
+ /// Returns the preserved ten raw TRF0 words for one row.
+ #[must_use]
+ pub fn row_words(&self, index: usize) -> Option<&[u32; 10]> {
+ self.records.get(index)
+ }
+
+ /// Returns the original TRF1 byte for one row.
+ #[must_use]
+ pub fn flag_byte(&self, index: usize) -> Option<u8> {
+ self.flags.get(index).copied()
+ }
+
+ /// Returns the raw name bytes selected by a row's low-16-bit TRFB link.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error when the row is outside TRF0.
+ pub fn row_name(&self, index: usize) -> Result<&[u8], ResearchError> {
+ let row = self
+ .records
+ .get(index)
+ .ok_or(ResearchError::InvalidResearchIndex {
+ type_id: TRF0,
+ index: u32::try_from(index).unwrap_or(u32::MAX),
+ row_count: self.records.len(),
+ })?;
+ let pair_index = usize::from(row[8] as u16);
+ let alias = self
+ .aliases
+ .get(pair_index)
+ .ok_or(ResearchError::InvalidRecordPair {
+ record_index: index,
+ pair_index: row[8] as u16,
+ pair_count: self.aliases.len(),
+ })?;
+ Ok(&self.name_pool[alias.name_range.clone()])
+ }
+
+ /// Returns the ordered TRF2/TRF3 indices used by CalcFullResearchCost.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if this row has no TRF2 group or its TRF3 stream
+ /// contains a row index outside TRF0.
+ pub fn relation_2_3(&self, index: usize) -> Result<&[u32], ResearchError> {
+ let related = self.relation_2_3.get(index)?;
+ validate_relation_indices(related, TRF3, self.records.len())?;
+ Ok(related)
+ }
+
+ /// Returns the separate ordered TRF4/TRF5 index group.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if this row has no TRF4 group or its TRF5 stream
+ /// contains a row index outside TRF0.
+ pub fn relation_4_5(&self, index: usize) -> Result<&[u32], ResearchError> {
+ let related = self.relation_4_5.get(index)?;
+ validate_relation_indices(related, TRF5, self.records.len())?;
+ Ok(related)
+ }
+
+ fn gates(&self, index: usize) -> Result<(u32, u32, u32), ResearchError> {
+ let flag = self
+ .flags
+ .get(index)
+ .copied()
+ .ok_or(ResearchError::InvalidResearchIndex {
+ type_id: TRF1,
+ index: u32::try_from(index).unwrap_or(u32::MAX),
+ row_count: self.records.len(),
+ })?;
+ Ok((
+ u32::from((flag >> 1) & 1),
+ u32::from(flag & 1),
+ u32::from((flag >> 2) & 1),
+ ))
+ }
+}
+
+pub(super) fn decode_candidate_first_tree(
+ payload: &[u8],
+) -> Result<(UnitDat, Vec<UnitComponentTreeNode>), PrototypeError> {
+ let limits = Limits::default();
+ let payload_len = u64::try_from(payload.len()).map_err(|_| DecodeError::IntegerOverflow)?;
+ if payload_len > limits.max_file_bytes {
+ return Err(DecodeError::LimitExceeded {
+ count: payload_len,
+ limit: limits.max_file_bytes,
+ }
+ .into());
+ }
+ if payload.len() < UNIT_DAT_HEADER_BYTES {
+ return Err(PrototypeError::InvalidSize);
+ }
+ let magic = u32::from_le_bytes(
+ payload[..4]
+ .try_into()
+ .map_err(|_| PrototypeError::InvalidSize)?,
+ );
+ if magic != UNIT_DAT_MAGIC {
+ return Err(PrototypeError::InvalidUnitDatMagic(magic));
+ }
+
+ let available_body = payload.len() - UNIT_DAT_HEADER_BYTES;
+ let complete_records = available_body / UNIT_DAT_RECORD_BYTES;
+ if complete_records == 0 {
+ return Err(PrototypeError::InvalidSize);
+ }
+ let maximum_records =
+ usize::try_from(limits.max_entries).map_err(|_| DecodeError::IntegerOverflow)?;
+ let scan_records = complete_records.min(maximum_records);
+ let mut pending_nodes = 1_u64;
+ let mut first_tree_count = None;
+
+ for record_index in 0..scan_records {
+ let record_offset = record_index
+ .checked_mul(UNIT_DAT_RECORD_BYTES)
+ .and_then(|offset| UNIT_DAT_HEADER_BYTES.checked_add(offset))
+ .ok_or(DecodeError::IntegerOverflow)?;
+ let children_offset = record_offset
+ .checked_add(108)
+ .ok_or(DecodeError::IntegerOverflow)?;
+ let children_end = children_offset
+ .checked_add(4)
+ .ok_or(DecodeError::IntegerOverflow)?;
+ let child_count = u32::from_le_bytes(
+ payload
+ .get(children_offset..children_end)
+ .ok_or(PrototypeError::InvalidSize)?
+ .try_into()
+ .map_err(|_| PrototypeError::InvalidSize)?,
+ );
+ pending_nodes = pending_nodes
+ .checked_sub(1)
+ .and_then(|pending| pending.checked_add(u64::from(child_count)))
+ .ok_or(DecodeError::IntegerOverflow)?;
+ if pending_nodes > u64::from(limits.max_entries) {
+ return Err(DecodeError::LimitExceeded {
+ count: pending_nodes,
+ limit: u64::from(limits.max_entries),
+ }
+ .into());
+ }
+ if pending_nodes == 0 {
+ first_tree_count = Some(
+ record_index
+ .checked_add(1)
+ .ok_or(DecodeError::IntegerOverflow)?,
+ );
+ break;
+ }
+ }
+
+ let Some(record_count) = first_tree_count else {
+ if scan_records == maximum_records {
+ return Err(DecodeError::LimitExceeded {
+ count: u64::from(limits.max_entries) + 1,
+ limit: u64::from(limits.max_entries),
+ }
+ .into());
+ }
+ return Err(PrototypeError::InvalidSize);
+ };
+ let record_count = u64::try_from(record_count).map_err(|_| DecodeError::IntegerOverflow)?;
+ let _record_capacity = checked_allocation_len(record_count, u64::from(limits.max_entries))?;
+ let record_bytes = checked_count_bytes(
+ record_count,
+ UNIT_DAT_RECORD_BYTES as u64,
+ u64::try_from(available_body).map_err(|_| DecodeError::IntegerOverflow)?,
+ )?;
+ let record_size = u64::try_from(std::mem::size_of::<UnitComponentRecord>())
+ .map_err(|_| DecodeError::IntegerOverflow)?;
+ let tree_record_size = std::mem::size_of::<UnitComponentTreeNode>()
+ .checked_add(2 * std::mem::size_of::<usize>())
+ .ok_or(DecodeError::IntegerOverflow)?;
+ let tree_record_size =
+ u64::try_from(tree_record_size).map_err(|_| DecodeError::IntegerOverflow)?;
+ let decoded_bytes = record_size
+ .checked_add(tree_record_size)
+ .and_then(|size| size.checked_mul(record_count))
+ .ok_or(DecodeError::IntegerOverflow)?;
+ let _decoded_capacity = checked_allocation_len(decoded_bytes, limits.max_decoded_bytes)?;
+ let prefix_end = UNIT_DAT_HEADER_BYTES
+ .checked_add(record_bytes)
+ .ok_or(DecodeError::IntegerOverflow)?;
+
+ let candidate = decode_unit_dat(&payload[..prefix_end])?;
+ let tree = unit_component_tree(&candidate.records)?;
+ Ok((candidate, tree))
+}
+
+fn candidate_research_predicate(
+ candidate: &UnitDat,
+ tree: &[UnitComponentTreeNode],
+ research: &ResearchTrfDocument,
+ first_info_word: usize,
+ second_info_word: usize,
+) -> Result<bool, ResearchError> {
+ let mut stack = vec![0_usize];
+ while let Some(node_index) = stack.pop() {
+ let Some(node) = tree.get(node_index) else {
+ return Err(ResearchError::CandidateTree(
+ PrototypeError::InvalidUnitHierarchy(format!(
+ "component index {node_index} is outside the reconstructed tree"
+ )),
+ ));
+ };
+ let Some(component) = candidate.records.get(node.record_index) else {
+ return Err(ResearchError::CandidateTree(
+ PrototypeError::InvalidUnitHierarchy(format!(
+ "tree references missing component {}",
+ node.record_index
+ )),
+ ));
+ };
+ if component.kind == 0 {
+ continue;
+ }
+
+ let Some(key_end) = component.resource_raw.iter().position(|byte| *byte == 0) else {
+ return Err(ResearchError::MissingCandidateKeyTerminator {
+ component_index: node.record_index,
+ });
+ };
+ let Some(research_index) = research.find_key(&component.resource_raw[..key_end])? else {
+ return Ok(false);
+ };
+ let (word0, word1, word2) = research.gates(research_index)?;
+ let info_words = [word0, word1, word2];
+ if info_words[first_info_word] == 0 || info_words[second_info_word] == 0 {
+ return Ok(false);
+ }
+ for child in node.children.iter().rev() {
+ stack.push(*child);
+ }
+ }
+ Ok(true)
+}
+
+fn first_research_predicate(
+ candidate: &UnitDat,
+ tree: &[UnitComponentTreeNode],
+ research: &ResearchTrfDocument,
+) -> Result<bool, ResearchError> {
+ candidate_research_predicate(candidate, tree, research, 1, 2)
+}
+
+fn second_research_predicate(
+ candidate: &UnitDat,
+ tree: &[UnitComponentTreeNode],
+ research: &ResearchTrfDocument,
+) -> Result<bool, ResearchError> {
+ candidate_research_predicate(candidate, tree, research, 0, 2)
+}
+
+/// Calculates the ordered ore and power cost for one candidate Unit DAT tree.
+///
+/// # Errors
+///
+/// Returns an error for an empty research table, malformed candidate tree,
+/// missing or unsupported key comparison, invalid relation group, or invalid
+/// row reference.
+pub fn calc_full_research_cost(
+ candidate: &UnitDat,
+ research: &ResearchTrfDocument,
+) -> Result<ResearchCost, ResearchError> {
+ if research.records.is_empty() {
+ return Err(ResearchError::EmptyResearchTable);
+ }
+ let tree = unit_component_tree(&candidate.records)?;
+ if tree.is_empty() {
+ return Err(ResearchError::EmptyCandidateTree);
+ }
+
+ let mut visited = vec![false; research.records.len()];
+ let direct = collect_direct_indices(candidate, &tree, research, &mut visited)?;
+ let mut closure = Vec::new();
+ for &index in &direct {
+ closure.push(index);
+ let (_, recurse, _) = research.gates(index)?;
+ if recurse == 0 {
+ append_relation_closure(index, research, &mut visited, &mut closure)?;
+ }
+ }
+
+ let mut ore = 0.0_f32;
+ let mut power = 0.0_f32;
+ for index in closure {
+ let (included, _, _) = research.gates(index)?;
+ if included == 0 {
+ let row = research
+ .records
+ .get(index)
+ .ok_or(ResearchError::InvalidResearchIndex {
+ type_id: TRF0,
+ index: u32::try_from(index).unwrap_or(u32::MAX),
+ row_count: research.records.len(),
+ })?;
+ ore = ore + f32::from_bits(row[1]);
+ power = power + f32::from_bits(row[0]);
+ }
+ }
+
+ Ok(ResearchCost { ore, power })
+}
+
+fn collect_direct_indices(
+ candidate: &UnitDat,
+ tree: &[UnitComponentTreeNode],
+ research: &ResearchTrfDocument,
+ visited: &mut [bool],
+) -> Result<Vec<usize>, ResearchError> {
+ let mut direct = Vec::new();
+ let mut stack = vec![0_usize];
+ let mut first_error = None;
+
+ while let Some(node_index) = stack.pop() {
+ let Some(node) = tree.get(node_index) else {
+ return Err(ResearchError::CandidateTree(
+ PrototypeError::InvalidUnitHierarchy(format!(
+ "component index {node_index} is outside the reconstructed tree"
+ )),
+ ));
+ };
+ let Some(component) = candidate.records.get(node.record_index) else {
+ return Err(ResearchError::CandidateTree(
+ PrototypeError::InvalidUnitHierarchy(format!(
+ "tree references missing component {}",
+ node.record_index
+ )),
+ ));
+ };
+
+ if component.kind == 0 {
+ continue;
+ }
+
+ let key_end = component.resource_raw.iter().position(|byte| *byte == 0);
+ let key = match key_end {
+ Some(end) => &component.resource_raw[..end],
+ None => {
+ if first_error.is_none() {
+ first_error = Some(ResearchError::MissingCandidateKeyTerminator {
+ component_index: node.record_index,
+ });
+ }
+ for child in node.children.iter().rev() {
+ stack.push(*child);
+ }
+ continue;
+ }
+ };
+
+ match research.find_key(key) {
+ Ok(Some(index)) => match research.gates(index) {
+ Ok((included, _, _)) => {
+ if included == 0 && !visited[index] {
+ visited[index] = true;
+ direct.push(index);
+ }
+ }
+ Err(error) => {
+ if first_error.is_none() {
+ first_error = Some(error);
+ }
+ }
+ },
+ Ok(None) => {
+ if first_error.is_none() {
+ first_error = Some(ResearchError::UnknownResearchKey {
+ component_index: node.record_index,
+ key: key.to_vec(),
+ });
+ }
+ }
+ Err(error) => {
+ if first_error.is_none() {
+ first_error = Some(error);
+ }
+ }
+ }
+
+ for child in node.children.iter().rev() {
+ stack.push(*child);
+ }
+ }
+
+ if let Some(error) = first_error {
+ return Err(error);
+ }
+ Ok(direct)
+}
+
+#[derive(Clone, Copy)]
+struct RelationFrame {
+ row_index: usize,
+ next_child: usize,
+}
+
+fn append_relation_closure(
+ root: usize,
+ research: &ResearchTrfDocument,
+ visited: &mut [bool],
+ closure: &mut Vec<usize>,
+) -> Result<(), ResearchError> {
+ let mut stack = vec![RelationFrame {
+ row_index: root,
+ next_child: 0,
+ }];
+
+ while let Some(frame) = stack.last().copied() {
+ let related = research.relation_2_3.get(frame.row_index)?;
+ if frame.next_child >= related.len() {
+ stack.pop();
+ continue;
+ }
+ let related_index = related[frame.next_child];
+ stack
+ .last_mut()
+ .expect("non-empty relation stack")
+ .next_child += 1;
+
+ let index = usize::try_from(related_index).map_err(|_| DecodeError::IntegerOverflow)?;
+ if index >= research.records.len() {
+ return Err(ResearchError::InvalidResearchIndex {
+ type_id: TRF3,
+ index: related_index,
+ row_count: research.records.len(),
+ });
+ }
+ let (included, recurse, _) = research.gates(index)?;
+ if included != 0 || visited[index] {
+ continue;
+ }
+
+ visited[index] = true;
+ closure.push(index);
+ if recurse == 0 {
+ stack.push(RelationFrame {
+ row_index: index,
+ next_child: 0,
+ });
+ }
+ }
+
+ Ok(())
+}
+
+impl ResearchRelations {
+ fn get(&self, index: usize) -> Result<&[u32], ResearchError> {
+ let group_count = self.group_starts.len().saturating_sub(1);
+ let missing_group = || ResearchError::MissingRelationGroup {
+ type_id: self.table_id,
+ index,
+ group_count,
+ };
+ let Some(end_index) = index.checked_add(1) else {
+ return Err(missing_group());
+ };
+ let Some(&start) = self.group_starts.get(index) else {
+ return Err(missing_group());
+ };
+ let Some(&end) = self.group_starts.get(end_index) else {
+ return Err(missing_group());
+ };
+ Ok(&self.indices[start..end])
+ }
+}
+
+fn validate_relation_indices(
+ related: &[u32],
+ type_id: u32,
+ row_count: usize,
+) -> Result<(), ResearchError> {
+ for &index in related {
+ if usize::try_from(index).map_or(true, |value| value >= row_count) {
+ return Err(ResearchError::InvalidResearchIndex {
+ type_id,
+ index,
+ row_count,
+ });
+ }
+ }
+ Ok(())
+}
+
+fn required_chunk<'a>(
+ archive: &'a NresDocument,
+ chunk_ids: &HashMap<u32, EntryId>,
+ type_id: u32,
+) -> Result<Chunk<'a>, ResearchError> {
+ let id = chunk_ids
+ .get(&type_id)
+ .copied()
+ .ok_or(ResearchError::MissingChunk { type_id })?;
+ let entry = archive
+ .entry(id)
+ .ok_or(fparkan_nres::NresError::EntryIdOutOfRange {
+ id: id.0,
+ entry_count: archive.header().entry_count,
+ })?;
+ let payload = archive.payload(id)?;
+ Ok(Chunk { entry, payload })
+}
+
+fn optional_chunk<'a>(
+ archive: &'a NresDocument,
+ chunk_ids: &HashMap<u32, EntryId>,
+ type_id: u32,
+) -> Result<Option<Chunk<'a>>, ResearchError> {
+ let Some(id) = chunk_ids.get(&type_id).copied() else {
+ return Ok(None);
+ };
+ let entry = archive
+ .entry(id)
+ .ok_or(fparkan_nres::NresError::EntryIdOutOfRange {
+ id: id.0,
+ entry_count: archive.header().entry_count,
+ })?;
+ let payload = archive.payload(id)?;
+ Ok(Some(Chunk { entry, payload }))
+}
+
+fn counted_prefix(payload: &[u8], count: u64, stride: u64) -> Result<&[u8], ResearchError> {
+ let remaining = u64::try_from(payload.len()).map_err(|_| DecodeError::IntegerOverflow)?;
+ let size = checked_count_bytes(count, stride, remaining)?;
+ Ok(&payload[..size])
+}
+
+fn read_research_rows(bytes: &[u8], row_count: u32) -> Result<Vec<[u32; 10]>, ResearchError> {
+ let capacity = checked_allocation_len(
+ u64::from(row_count),
+ u64::from(Limits::default().max_array_items),
+ )?;
+ let mut cursor = Cursor::new(bytes);
+ let mut records = Vec::with_capacity(capacity);
+ for _ in 0..row_count {
+ let mut row = [0_u32; 10];
+ for word in &mut row {
+ *word = cursor.read_u32_le()?;
+ }
+ records.push(row);
+ }
+ cursor.require_eof()?;
+ Ok(records)
+}
+
+fn read_aliases(
+ bytes: &[u8],
+ name_pool: &[u8],
+ alias_count: u32,
+) -> Result<Vec<ResearchAlias>, ResearchError> {
+ let capacity = checked_allocation_len(
+ u64::from(alias_count),
+ u64::from(Limits::default().max_array_items),
+ )?;
+ let mut cursor = Cursor::new(bytes);
+ let mut aliases = Vec::with_capacity(capacity);
+ for pair_index in 0..capacity {
+ let name_offset = cursor.read_u16_le()?;
+ let row_index = cursor.read_u16_le()?;
+ let offset = usize::from(name_offset);
+ let suffix = name_pool
+ .get(offset..)
+ .ok_or(ResearchError::InvalidNameOffset {
+ pair_index,
+ offset: name_offset,
+ })?;
+ let length =
+ suffix
+ .iter()
+ .position(|byte| *byte == 0)
+ .ok_or(ResearchError::UnterminatedName {
+ pair_index,
+ offset: name_offset,
+ })?;
+ aliases.push(ResearchAlias {
+ name_range: offset..offset + length,
+ row_index,
+ });
+ }
+ cursor.require_eof()?;
+ Ok(aliases)
+}
+
+fn read_relations(
+ archive: &NresDocument,
+ chunk_ids: &HashMap<u32, EntryId>,
+ count_type: u32,
+ index_type: u32,
+) -> Result<ResearchRelations, ResearchError> {
+ let count_chunk = required_chunk(archive, chunk_ids, count_type)?;
+ let count_count = count_chunk.entry.meta().attr1;
+ let count_bytes = counted_prefix(count_chunk.payload, u64::from(count_count), 4)?;
+ let mut cursor = Cursor::new(count_bytes);
+ let max_items = u64::from(Limits::default().max_array_items);
+ let group_count = u64::from(count_count);
+ let offset_count = group_count
+ .checked_add(1)
+ .ok_or(DecodeError::IntegerOverflow)?;
+ let offset_capacity = checked_allocation_len(
+ offset_count,
+ max_items
+ .checked_add(1)
+ .ok_or(DecodeError::IntegerOverflow)?,
+ )?;
+ let mut group_starts = Vec::with_capacity(offset_capacity);
+ group_starts.push(0_usize);
+ let mut total_ids = 0_u64;
+ for _ in 0..count_count {
+ let count = cursor.read_u32_le()?;
+ total_ids = total_ids
+ .checked_add(u64::from(count))
+ .ok_or(DecodeError::IntegerOverflow)?;
+ let next = checked_allocation_len(total_ids, max_items)?;
+ group_starts.push(next);
+ }
+
+ let index_chunk = optional_chunk(archive, chunk_ids, index_type)?;
+ let indices = match index_chunk {
+ None if total_ids == 0 => Vec::new(),
+ None => {
+ return Err(ResearchError::MissingRelationStream {
+ type_id: index_type,
+ required_ids: total_ids,
+ })
+ }
+ Some(chunk) => {
+ let index_bytes = counted_prefix(chunk.payload, total_ids, 4)?;
+ let mut cursor = Cursor::new(index_bytes);
+ let capacity = checked_allocation_len(total_ids, max_items)?;
+ let mut indices = Vec::with_capacity(capacity);
+ for _ in 0..total_ids {
+ indices.push(cursor.read_u32_le()?);
+ }
+ cursor.require_eof()?;
+ indices
+ }
+ };
+
+ Ok(ResearchRelations {
+ table_id: count_type,
+ group_starts,
+ indices,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn decoded_array_limits_fail_before_large_allocations() {
+ let limit = Limits::default().max_array_items;
+ let too_many = limit.checked_add(1).expect("test count");
+
+ assert!(matches!(
+ read_research_rows(&[], too_many),
+ Err(ResearchError::Binary(DecodeError::LimitExceeded { count, limit: actual_limit }))
+ if count == u64::from(too_many) && actual_limit == u64::from(limit)
+ ));
+ assert!(matches!(
+ read_aliases(&[], &[], too_many),
+ Err(ResearchError::Binary(DecodeError::LimitExceeded { count, limit: actual_limit }))
+ if count == u64::from(too_many) && actual_limit == u64::from(limit)
+ ));
+ }
+}
diff --git a/crates/fparkan-font/Cargo.toml b/crates/fparkan-font/Cargo.toml
new file mode 100644
index 0000000..3053a3e
--- /dev/null
+++ b/crates/fparkan-font/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "fparkan-font"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+fparkan-texm = { path = "../fparkan-texm" }
+
+[dev-dependencies]
+fparkan-nres = { path = "../fparkan-nres" }
diff --git a/crates/fparkan-font/src/lib.rs b/crates/fparkan-font/src/lib.rs
new file mode 100644
index 0000000..770ebb0
--- /dev/null
+++ b/crates/fparkan-font/src/lib.rs
@@ -0,0 +1,402 @@
+#![forbid(unsafe_code)]
+//! Safe reader for the fixed Tfnt metrics and its embedded Texm image.
+
+use fparkan_texm::{TexmDocument, TexmError};
+use std::sync::Arc;
+
+const PREFIX_LEN: usize = 0x14;
+const GLYPH_TABLE_END: usize = 0x1014;
+const GLYPH_COUNT: usize = 256;
+
+/// Confirmed Tfnt metrics and the preserved opaque word at offset `0x0c`.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct TfntHeader {
+ /// Lower bound used by the native fixed-width drawing path.
+ pub fixed_width_floor: i32,
+ /// Vertical extent used by native clipping and drawing.
+ pub vertical_extent: i32,
+ /// Raw bits copied from offset `0x0c`; its semantic name is unknown.
+ pub word_0c_bits: u32,
+ /// Per-input-byte spacing used by native text measurement.
+ pub character_spacing: i32,
+}
+
+/// One 16-byte glyph record; the first three words remain opaque.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct TfntGlyph {
+ /// First opaque DWORD.
+ pub word0: u32,
+ /// Second opaque DWORD.
+ pub word1: u32,
+ /// Third opaque DWORD.
+ pub word2: u32,
+ /// Signed advance at record offset `0x0c`.
+ pub advance: i32,
+}
+
+/// Decoded Tfnt prefix, fixed glyph table, and embedded Texm document.
+#[derive(Clone, Debug)]
+pub struct TfntDocument {
+ /// Header metrics.
+ pub header: TfntHeader,
+ /// Records indexed by the native unsigned input byte, `0..=255`.
+ pub glyphs: [TfntGlyph; GLYPH_COUNT],
+ /// Texture document parsed by the shared Texm reader.
+ pub texm: TexmDocument,
+}
+
+/// Tfnt decode error.
+#[derive(Debug)]
+pub enum TfntError {
+ /// The safe file reader requires the corpus signature `Tfnt`.
+ InvalidMagic { got: [u8; 4] },
+ /// The fixed 20-byte prefix is incomplete.
+ TruncatedHeader { actual: usize, required: usize },
+ /// The fixed 256-record table is incomplete.
+ TruncatedGlyphTable { actual: usize, required: usize },
+ /// The embedded texture could not be decoded.
+ Texm(TexmError),
+}
+
+impl std::fmt::Display for TfntError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::InvalidMagic { got } => write!(f, "invalid Tfnt magic: {got:02X?}"),
+ Self::TruncatedHeader { actual, required } => {
+ write!(f, "Tfnt header truncated: {actual} bytes, need {required}")
+ }
+ Self::TruncatedGlyphTable { actual, required } => {
+ write!(
+ f,
+ "Tfnt glyph table truncated: {actual} bytes, need {required}"
+ )
+ }
+ Self::Texm(source) => write!(f, "embedded Texm: {source}"),
+ }
+ }
+}
+
+impl std::error::Error for TfntError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Texm(source) => Some(source),
+ Self::InvalidMagic { .. }
+ | Self::TruncatedHeader { .. }
+ | Self::TruncatedGlyphTable { .. } => None,
+ }
+ }
+}
+
+/// Decodes a Tfnt file and its embedded Texm texture.
+///
+/// # Errors
+///
+/// Returns [`TfntError`] for a bad signature, an incomplete fixed header or
+/// glyph table, or an invalid embedded Texm document.
+pub fn decode_tfnt(bytes: Arc<[u8]>) -> Result<TfntDocument, TfntError> {
+ if bytes.len() < PREFIX_LEN {
+ return Err(TfntError::TruncatedHeader {
+ actual: bytes.len(),
+ required: PREFIX_LEN,
+ });
+ }
+
+ let mut magic = [0; 4];
+ magic.copy_from_slice(&bytes[..4]);
+ if magic != *b"Tfnt" {
+ return Err(TfntError::InvalidMagic { got: magic });
+ }
+
+ if bytes.len() < GLYPH_TABLE_END {
+ return Err(TfntError::TruncatedGlyphTable {
+ actual: bytes.len(),
+ required: GLYPH_TABLE_END,
+ });
+ }
+
+ let header = TfntHeader {
+ fixed_width_floor: read_i32_le(&bytes, 0x04),
+ vertical_extent: read_i32_le(&bytes, 0x08),
+ word_0c_bits: read_u32_le(&bytes, 0x0c),
+ character_spacing: read_i32_le(&bytes, 0x10),
+ };
+ let mut glyphs = [TfntGlyph::default(); GLYPH_COUNT];
+ for (glyph, record) in glyphs
+ .iter_mut()
+ .zip(bytes[PREFIX_LEN..GLYPH_TABLE_END].chunks_exact(16))
+ {
+ *glyph = TfntGlyph {
+ word0: read_u32_le(record, 0),
+ word1: read_u32_le(record, 4),
+ word2: read_u32_le(record, 8),
+ advance: read_i32_le(record, 12),
+ };
+ }
+
+ let texm =
+ fparkan_texm::decode_texm(Arc::from(&bytes[GLYPH_TABLE_END..])).map_err(TfntError::Texm)?;
+
+ Ok(TfntDocument {
+ header,
+ glyphs,
+ texm,
+ })
+}
+
+fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
+ u32::from_le_bytes(
+ bytes[offset..offset + 4]
+ .try_into()
+ .expect("fixed range was bounds-checked"),
+ )
+}
+
+fn read_i32_le(bytes: &[u8], offset: usize) -> i32 {
+ i32::from_le_bytes(
+ bytes[offset..offset + 4]
+ .try_into()
+ .expect("fixed range was bounds-checked"),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fparkan_nres::{decode as decode_nres, ReadProfile};
+ use fparkan_texm::PixelFormat;
+ use std::path::PathBuf;
+
+ #[test]
+ fn reads_distinct_header_values_and_preserves_nan_bits() {
+ let mut bytes = tfnt_prefix();
+ put_u32(&mut bytes, 0x04, (-17_i32) as u32);
+ put_u32(&mut bytes, 0x08, 513);
+ put_u32(&mut bytes, 0x0c, 0x7fc1_2345);
+ put_u32(&mut bytes, 0x10, (-3_i32) as u32);
+ bytes.extend_from_slice(&indexed_texm(2, 1, &[0xA5, 0x5A]));
+
+ let document = decode_tfnt(Arc::from(bytes.into_boxed_slice())).expect("Tfnt");
+
+ assert_eq!(document.header.fixed_width_floor, -17);
+ assert_eq!(document.header.vertical_extent, 513);
+ assert_eq!(document.header.word_0c_bits, 0x7fc1_2345);
+ assert_eq!(document.header.character_spacing, -3);
+ }
+
+ #[test]
+ fn preserves_distinct_glyph_records_including_byte_255_and_signed_advances() {
+ let mut bytes = tfnt_prefix();
+ write_glyph(
+ &mut bytes,
+ 7,
+ [0x7fc1_2345, 0x89ab_cdef, 0x0102_0304, (-23_i32) as u32],
+ );
+ write_glyph(
+ &mut bytes,
+ 255,
+ [0x8000_0000, 0x7654_3210, 0xffff_ffff, i32::MIN as u32],
+ );
+ bytes.extend_from_slice(&indexed_texm(2, 1, &[1, 2]));
+
+ let document = decode_tfnt(Arc::from(bytes.into_boxed_slice())).expect("Tfnt");
+
+ assert_eq!(
+ document.glyphs[7],
+ TfntGlyph {
+ word0: 0x7fc1_2345,
+ word1: 0x89ab_cdef,
+ word2: 0x0102_0304,
+ advance: -23,
+ }
+ );
+ assert_eq!(document.glyphs[255].word0, 0x8000_0000);
+ assert_eq!(document.glyphs[255].word1, 0x7654_3210);
+ assert_eq!(document.glyphs[255].word2, u32::MAX);
+ assert_eq!(document.glyphs[255].advance, i32::MIN);
+ }
+
+ #[test]
+ fn delegates_embedded_texture_to_texm_reader() {
+ let mut bytes = tfnt_prefix();
+ bytes.extend_from_slice(&indexed_texm(2, 1, &[0xA5, 0x5A]));
+
+ let document = decode_tfnt(Arc::from(bytes.into_boxed_slice())).expect("Tfnt");
+
+ assert_eq!(document.texm.width(), 2);
+ assert_eq!(document.texm.height(), 1);
+ assert_eq!(document.texm.mip_count(), 1);
+ assert_eq!(document.texm.format(), PixelFormat::Indexed8);
+ assert_eq!(document.texm.mip_level(0).expect("mip").bytes, [0xA5, 0x5A]);
+ }
+
+ #[test]
+ fn rejects_wrong_magic_and_short_prefix() {
+ let mut wrong_magic = tfnt_prefix();
+ wrong_magic[..4].copy_from_slice(b"BAD!");
+ assert!(matches!(
+ decode_tfnt(Arc::from(wrong_magic.into_boxed_slice())),
+ Err(TfntError::InvalidMagic { .. })
+ ));
+
+ let mut short_prefix = tfnt_prefix();
+ short_prefix.truncate(PREFIX_LEN - 1);
+ assert!(matches!(
+ decode_tfnt(Arc::from(short_prefix.into_boxed_slice())),
+ Err(TfntError::TruncatedHeader { .. })
+ ));
+ }
+
+ #[test]
+ fn rejects_short_fixed_glyph_table() {
+ let mut bytes = tfnt_prefix();
+ bytes.truncate(GLYPH_TABLE_END - 1);
+
+ assert!(matches!(
+ decode_tfnt(Arc::from(bytes.into_boxed_slice())),
+ Err(TfntError::TruncatedGlyphTable { .. })
+ ));
+ }
+
+ #[test]
+ fn reports_invalid_and_truncated_embedded_texm_without_panicking() {
+ let mut invalid = tfnt_prefix();
+ invalid.extend_from_slice(b"Not a Texm payload, but long enough to reach its reader.");
+ assert!(matches!(
+ decode_tfnt(Arc::from(invalid.into_boxed_slice())),
+ Err(TfntError::Texm(_))
+ ));
+
+ let mut truncated = tfnt_prefix();
+ truncated.extend_from_slice(b"Texm");
+ assert!(matches!(
+ decode_tfnt(Arc::from(truncated.into_boxed_slice())),
+ Err(TfntError::Texm(_))
+ ));
+ }
+
+ #[test]
+ #[ignore = "requires licensed GOG corpus"]
+ fn decodes_original_gog_tfnt_resources() {
+ let root = std::env::var_os("FPARKAN_CORPUS_PART1_ROOT")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| panic!("FPARKAN_CORPUS_PART1_ROOT is required for this test"));
+ assert!(
+ root.is_dir(),
+ "licensed corpus root is missing: {}",
+ root.display()
+ );
+
+ let font_bytes = std::fs::read(root.join("ui/font.lib")).expect("read ui/font.lib");
+ let fonts = decode_nres(
+ Arc::from(font_bytes.into_boxed_slice()),
+ ReadProfile::Strict,
+ )
+ .expect("decode ui/font.lib");
+ let expected = [
+ ("gf_1024.tft", 256, 256),
+ ("tf_1024.tft", 256, 256),
+ ("mf_1024.tft", 256, 256),
+ ("gf_800.tft", 128, 128),
+ ("tf_800.tft", 128, 128),
+ ("mf_800.tft", 256, 256),
+ ("gf_640.tft", 128, 128),
+ ("tf_640.tft", 128, 128),
+ ("mf_640.tft", 256, 256),
+ ];
+ assert_eq!(fonts.entry_count(), expected.len());
+ for (name, width, height) in expected {
+ let id = fonts
+ .find(name)
+ .unwrap_or_else(|| panic!("missing NRes font {name}"));
+ let entry = fonts.entry(id).expect("font entry");
+ assert_eq!(entry.meta().type_id, u32::from_le_bytes(*b"FONT"), "{name}");
+ let document = decode_tfnt(Arc::from(fonts.payload(id).expect("font payload")))
+ .unwrap_or_else(|err| panic!("{name}: {err}"));
+ assert_eq!(document.texm.width(), width, "{name}");
+ assert_eq!(document.texm.height(), height, "{name}");
+ assert_eq!(document.texm.format(), PixelFormat::Indexed8, "{name}");
+ assert_eq!(document.texm.mip_count(), 1, "{name}");
+ assert_eq!(
+ document.texm.mip_level(0).expect("mip").bytes.len(),
+ width as usize * height as usize,
+ "{name}"
+ );
+ if name == "gf_640.tft" {
+ assert_eq!(
+ document.header,
+ TfntHeader {
+ fixed_width_floor: 8,
+ vertical_extent: 7,
+ word_0c_bits: 0x3d80_0000,
+ character_spacing: 1,
+ }
+ );
+ assert_eq!(
+ document.glyphs[65],
+ TfntGlyph {
+ word0: 0x3c00_0000,
+ word1: 0x3d80_0000,
+ word2: 0x3e80_0000,
+ advance: 6,
+ }
+ );
+ assert_eq!(document.glyphs[255].advance, 5);
+ }
+ }
+
+ let sys_bytes = std::fs::read(root.join("sys.lib")).expect("read sys.lib");
+ let sys = decode_nres(Arc::from(sys_bytes.into_boxed_slice()), ReadProfile::Strict)
+ .expect("decode sys.lib");
+ let id = sys.find("ARIALTEX.TFT").expect("ARIALTEX.TFT entry");
+ let entry = sys.entry(id).expect("ARIALTEX.TFT entry");
+ assert_eq!(entry.meta().type_id, u32::from_le_bytes(*b"FONT"));
+ let document = decode_tfnt(Arc::from(sys.payload(id).expect("ARIALTEX.TFT payload")))
+ .expect("decode ARIALTEX.TFT");
+ assert_eq!(document.texm.width(), 128);
+ assert_eq!(document.texm.height(), 128);
+ assert_eq!(document.texm.format(), PixelFormat::Indexed8);
+ assert_eq!(document.texm.mip_count(), 1);
+ assert_eq!(
+ document.texm.mip_level(0).expect("mip").bytes.len(),
+ 128 * 128
+ );
+ assert_eq!(
+ document.header,
+ TfntHeader {
+ fixed_width_floor: 8,
+ vertical_extent: 7,
+ word_0c_bits: 0x3d80_0000,
+ character_spacing: 1,
+ }
+ );
+ assert_eq!(document.glyphs[65].advance, 6);
+ assert_eq!(document.glyphs[255].advance, 4);
+ }
+
+ fn tfnt_prefix() -> Vec<u8> {
+ let mut bytes = vec![0; GLYPH_TABLE_END];
+ bytes[..4].copy_from_slice(b"Tfnt");
+ bytes
+ }
+
+ fn indexed_texm(width: u32, height: u32, pixels: &[u8]) -> Vec<u8> {
+ let mut bytes = Vec::with_capacity(32 + 1024 + pixels.len());
+ for word in [0x6d78_6554, width, height, 1, 0x20, 0x0100_0000, 0, 0] {
+ bytes.extend_from_slice(&word.to_le_bytes());
+ }
+ bytes.extend_from_slice(&[0; 1024]);
+ bytes.extend_from_slice(pixels);
+ bytes
+ }
+
+ fn put_u32(bytes: &mut [u8], offset: usize, value: u32) {
+ bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
+ }
+
+ fn write_glyph(bytes: &mut [u8], index: usize, words: [u32; 4]) {
+ let offset = PREFIX_LEN + index * 16;
+ for (word_index, value) in words.into_iter().enumerate() {
+ put_u32(bytes, offset + word_index * 4, value);
+ }
+ }
+}
diff --git a/crates/fparkan-fx/src/ambient_op3.rs b/crates/fparkan-fx/src/ambient_op3.rs
index 5bd2881..1ec2973 100644
--- a/crates/fparkan-fx/src/ambient_op3.rs
+++ b/crates/fparkan-fx/src/ambient_op3.rs
@@ -471,6 +471,7 @@ fn native_op3_type1_matrix(
/// `view · axis` value to exactly ±1 before building the cross-product frame;
/// neither input vector is normalized. The native path normalizes only
/// `view × axis`, then computes `axis × U` without normalizing the result.
+/// The authored axis is the quad's X column (Effect.dll RVA 0x97F6..0x983C).
#[must_use]
fn native_op3_type1_basis(
view: [f32; 3],
@@ -501,9 +502,9 @@ fn native_op3_type1_basis(
}
let w = cross3(axis, u);
let result = [
- [u[0], w[0], axis[0], position[0]],
- [u[1], w[1], axis[1], position[1]],
- [u[2], w[2], axis[2], position[2]],
+ [axis[0], u[0], w[0], position[0]],
+ [axis[1], u[1], w[1], position[1]],
+ [axis[2], u[2], w[2], position[2]],
[0.0, 0.0, 0.0, 1.0],
];
result
@@ -837,19 +838,19 @@ mod tests {
// cross-product frame instead of being normalized before the gate.
let frame =
native_op3_type1_basis([2.0, 1.0, 0.0], [1.0, 0.0, 0.0], [3.0, 4.0, 5.0]).unwrap();
- assert_eq!(frame[0], [0.0, 0.0, 1.0, 3.0]);
- assert_eq!(frame[1], [0.0, 1.0, 0.0, 4.0]);
- assert_eq!(frame[2], [-1.0, 0.0, 0.0, 5.0]);
+ assert_eq!(frame[0], [1.0, 0.0, 0.0, 3.0]);
+ assert_eq!(frame[1], [0.0, 0.0, 1.0, 4.0]);
+ assert_eq!(frame[2], [0.0, -1.0, 0.0, 5.0]);
// The axis is not normalized: W and the axis column keep its scale.
let axis_scaled =
native_op3_type1_basis([2.0, 1.0, 0.0], [0.0, 0.0, 2.0], [0.0; 3]).unwrap();
let inv_sqrt_5 = 1.0 / 5.0_f32.sqrt();
- assert_eq!(axis_scaled[0][0], inv_sqrt_5);
- assert_eq!(axis_scaled[1][0], -2.0 * inv_sqrt_5);
- assert_eq!(axis_scaled[0][1], 4.0 * inv_sqrt_5);
- assert_eq!(axis_scaled[1][1], 2.0 * inv_sqrt_5);
- assert_eq!(axis_scaled[2][2], 2.0);
+ assert_eq!(axis_scaled[0][1], inv_sqrt_5);
+ assert_eq!(axis_scaled[1][1], -2.0 * inv_sqrt_5);
+ assert_eq!(axis_scaled[0][2], 4.0 * inv_sqrt_5);
+ assert_eq!(axis_scaled[1][2], 2.0 * inv_sqrt_5);
+ assert_eq!(axis_scaled[2][0], 2.0);
assert_eq!(
native_op3_type1_basis([f32::NAN, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0; 3]),
@@ -907,6 +908,10 @@ mod tests {
assert_eq!(frame.normalized_time, 0.5);
assert_eq!(frame.color, [1.0, 1.0, 1.0, 0.5]);
assert_eq!(frame.material_phase, 0.5);
+ let authored_axis_edge = std::array::from_fn::<_, 3, _>(|axis| {
+ frame.vertices[2][axis] - frame.vertices[1][axis]
+ });
+ assert_eq!(authored_axis_edge, [0.0, 0.0, 1.0]);
assert!(evaluate_op3_type1_command(
&command,
4,
@@ -919,6 +924,197 @@ mod tests {
}
#[test]
+ #[ignore = "requires the original GOG installation"]
+ fn original_deflector_quad_keeps_its_authored_axis_over_time_and_camera_motion() {
+ let root = std::env::var_os("FPARKAN_CORPUS_PART1_ROOT")
+ .expect("set FPARKAN_CORPUS_PART1_ROOT to the original GOG installation");
+ let bytes = std::fs::read(std::path::Path::new(&root).join("effects.rlb"))
+ .expect("read effects.rlb");
+ let archive = fparkan_nres::decode(bytes.into(), fparkan_nres::ReadProfile::Compatible)
+ .expect("decode effects.rlb");
+ let entry = archive.find("deflector").expect("deflector FXID");
+ let document = crate::decode_fxid(
+ archive
+ .payload(entry)
+ .expect("FXID payload")
+ .to_vec()
+ .into(),
+ )
+ .expect("decode deflector");
+ let command = &document.commands()[0];
+ let parent = [
+ [12.0, 0.0, 0.0, 100.0],
+ [0.0, 3.0, 0.0, 200.0],
+ [0.0, 0.0, 3.0, 20.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ];
+ for elapsed in [0.0_f32, 0.125, 0.5, 1.0, 2.5] {
+ for camera in [
+ [160.0, 230.0, 20.0],
+ [160.0, 200.0, 50.0],
+ [160.0, 170.0, 5.0],
+ ] {
+ let frame = evaluate_op3_type1_command(
+ command,
+ 0,
+ &parent,
+ elapsed,
+ elapsed.min(1.0),
+ camera,
+ )
+ .expect("active deflector quad");
+ assert!(frame.active);
+ assert_eq!(frame.material.name.to_ascii_lowercase(), "defl_b");
+ let axis_edge = std::array::from_fn::<_, 3, _>(|axis| {
+ frame.vertices[2][axis] - frame.vertices[1][axis]
+ });
+ assert_eq!(axis_edge, [12.0, 0.0, 0.0]);
+ let center = std::array::from_fn::<_, 3, _>(|axis| {
+ (frame.vertices[0][axis] + frame.vertices[2][axis]) * 0.5
+ });
+ assert_eq!(center, [106.0, 200.0, 20.0]);
+ assert_eq!(frame.material_phase, (elapsed * 7.0).fract());
+ }
+ }
+ }
+
+ #[test]
+ #[ignore = "requires the original GOG installation"]
+ fn original_tree_ray_58_matches_native_type1_geometry_samples() {
+ let root = std::env::var_os("FPARKAN_CORPUS_PART1_ROOT")
+ .expect("set FPARKAN_CORPUS_PART1_ROOT to the original GOG installation");
+ let bytes = std::fs::read(std::path::Path::new(&root).join("effects.rlb"))
+ .expect("read effects.rlb");
+ let archive = fparkan_nres::decode(bytes.into(), fparkan_nres::ReadProfile::Compatible)
+ .expect("decode effects.rlb");
+ let entry = archive.find("tree_ray_58").expect("tree_ray_58 FXID");
+ let document = crate::decode_fxid(
+ archive
+ .payload(entry)
+ .expect("FXID payload")
+ .to_vec()
+ .into(),
+ )
+ .expect("decode tree_ray_58");
+ assert_eq!(document.header().time_mode, 2);
+ assert_eq!(document.header().duration_seconds, 3.0);
+ assert_eq!(document.commands().len(), 3);
+
+ let parent = [
+ [0.999_924_123, 0.012_319_660, 0.0, 386.673_400_879],
+ [-0.012_319_660, 0.999_924_123, 0.0, 603.692_321_777],
+ [0.0, 0.0, 1.0, 180.414_703_369],
+ [0.0, 0.0, 0.0, 1.0],
+ ];
+ let center = [390.273_132, 603.647_949, 180.414_703];
+ // Original GOG Effect.dll RVA 0x93D0, x87 CW 0x007F; Rust-sampled keys are supplied separately.
+ let native_samples = [
+ (
+ 0,
+ "defl_y_add",
+ [0.0, 35.0, 22.0],
+ [
+ [386.280, 604.229_431, 179.568_024],
+ [386.266_876, 603.165_039, 181.261_383],
+ [394.266_266, 603.066_467, 181.261_383],
+ [394.279_4, 604.130_859, 179.568_024],
+ ],
+ ),
+ (
+ 0,
+ "defl_y_add",
+ [20.0, -30.0, 15.0],
+ [
+ [386.279, 604.147_4, 181.307_663],
+ [386.267_883, 603.247_1, 179.521_744],
+ [394.267_273, 603.148_5, 179.521_744],
+ [394.278_381, 604.048_8, 181.307_663],
+ ],
+ ),
+ (
+ 1,
+ "defl_y1_add",
+ [0.0, 35.0, 22.0],
+ [
+ [386.276_7, 603.963_3, 179.991_364],
+ [386.270_172, 603.431_152, 180.838_043],
+ [394.269_562, 603.332_6, 180.838_043],
+ [394.276_1, 603.864_746, 179.991_364],
+ ],
+ ),
+ ];
+ for (command_index, material_name, camera_offset, expected_vertices) in native_samples {
+ let camera = std::array::from_fn(|axis| center[axis] + camera_offset[axis]);
+ let frame = evaluate_op3_type1_command(
+ &document.commands()[command_index],
+ command_index,
+ &parent,
+ 0.0,
+ 0.0,
+ camera,
+ )
+ .expect("active tree ray quad");
+ assert_eq!(frame.material.name, material_name);
+ for (actual, expected) in frame
+ .vertices
+ .iter()
+ .flatten()
+ .zip(expected_vertices.iter().flatten())
+ {
+ assert!(
+ (actual - expected).abs() < 0.001,
+ "native sample vertex mismatch: {actual} vs {expected}"
+ );
+ }
+ }
+
+ for (command_index, material_name) in [(0, "defl_y_add"), (1, "defl_y1_add")] {
+ let command = &document.commands()[command_index];
+ assert_eq!(command.opcode, FxOpcode::Op3);
+ assert_eq!(
+ read_u32(&command.raw_body, 0),
+ Some(TYPE1_BODY_DISCRIMINATOR)
+ );
+ let camera_offsets = [[0.0, 35.0, 22.0], [20.0, -30.0, 15.0], [-25.0, 18.0, 10.0]];
+ for elapsed in [0.0_f32, 0.375, 0.75, 1.5, 2.25, 2.999, 3.125] {
+ let phase = elapsed.rem_euclid(3.0) / 3.0;
+ for camera_offset in camera_offsets {
+ let camera = std::array::from_fn(|axis| center[axis] + camera_offset[axis]);
+ let frame = evaluate_op3_type1_command(
+ command,
+ command_index,
+ &parent,
+ elapsed,
+ phase,
+ camera,
+ )
+ .expect("active tree ray quad");
+ assert_eq!(frame.material.name, material_name);
+ assert!(frame.active);
+ let actual_center: [f32; 3] = std::array::from_fn(|axis| {
+ frame
+ .vertices
+ .iter()
+ .map(|vertex| vertex[axis])
+ .sum::<f32>()
+ * 0.25
+ });
+ for axis in 0..3 {
+ assert!((actual_center[axis] - center[axis]).abs() < 0.001);
+ }
+ let authored_axis_edge: [f32; 3] = std::array::from_fn(|axis| {
+ frame.vertices[2][axis] - frame.vertices[1][axis]
+ });
+ let native_axis_edge = [7.999_393, -0.098_557_28, 0.0];
+ for axis in 0..3 {
+ assert!((authored_axis_edge[axis] - native_axis_edge[axis]).abs() < 0.001);
+ }
+ }
+ }
+ }
+ }
+
+ #[test]
fn rejects_truncated_bodies_and_non_finite_tracks_or_inputs() {
let identity = [
[1.0, 0.0, 0.0, 0.0],
diff --git a/crates/fparkan-msh/src/lib.rs b/crates/fparkan-msh/src/lib.rs
index 4b561e7..f9d7555 100644
--- a/crates/fparkan-msh/src/lib.rs
+++ b/crates/fparkan-msh/src/lib.rs
@@ -21,8 +21,13 @@ pub const STREAM_UV0: u32 = 5;
/// Optional secondary texture coordinate stream used by native lightmap/detail
/// stages. MSH stores it as the type-18 four-byte packed UV stream.
pub const STREAM_UV1: u32 = 18;
+/// Optional type-16 stream. Its presence marks MSH vertex-colour data.
+pub const STREAM_TYPE_16: u32 = 16;
/// Triangle index stream.
pub const STREAM_INDICES: u32 = 6;
+/// Optional triangle-descriptor stream.
+pub const STREAM_TRIANGLE_DESCRIPTORS: u32 = 7;
+const TRIANGLE_NORMAL_SCALE: f32 = 1.0 / 32_767.0;
/// Animation key stream.
pub const STREAM_ANIMATION_KEYS: u32 = 8;
/// Node names stream.
@@ -98,10 +103,16 @@ pub struct ModelAsset {
pub uv0: Option<Vec<[u16; 2]>>,
/// Optional secondary packed texture coordinates from type 18.
pub uv1: Option<Vec<[u16; 2]>>,
+ /// Whether the source MSH contains a type-16 vertex-colour stream.
+ ///
+ /// The stream payload is preserved separately and is not decoded here.
+ pub type16_present: bool,
/// Triangle indices.
pub indices: Vec<u16>,
/// Draw batches.
pub batches: Vec<Batch>,
+ /// Optional type-7 triangle descriptors.
+ pub triangle_descriptors: Option<Vec<TriangleDescriptor>>,
/// Optional decoded node names.
pub node_names: Option<Vec<Option<String>>>,
/// Optional decoded node-animation streams.
@@ -189,7 +200,13 @@ pub struct Batch {
pub material_index: u8,
/// Lightmap selector at byte offset `+0x05`.
pub lightmap_index: u8,
- /// Local batch index at byte offset `+0x06`.
+ /// Raw word at byte offset `+0x06`.
+ ///
+ /// In the native building-collision helper retry, this word together with
+ /// the model's base index selects the next linked Node38 root. For a
+ /// standalone `ModelAsset`, that base is zero, so the link is this raw
+ /// value. Keep the decoded field lossless; its meaning depends on the
+ /// query path and batch flags.
pub local_batch_index: u16,
/// Index count.
pub index_count: u16,
@@ -201,9 +218,48 @@ pub struct Batch {
pub base_vertex: u32,
}
-/// Preserved triangle descriptor stream marker.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct TriangleDescriptor;
+/// One 16-byte type-7 triangle descriptor.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct TriangleDescriptor {
+ /// Triangle mask flags at byte offset `+0`.
+ pub tri_flags: u16,
+ /// Opaque 16-bit words from offsets `+2`, `+4`, `+6` and `+14`.
+ pub opaque: [u16; 4],
+ /// Signed packed plane normal at byte offsets `+8`, `+10` and `+12`.
+ pub normal: [i16; 3],
+}
+
+/// Include-all and exclude-any masks used by the native MSH query.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct SegmentQueryMasks {
+ /// Node38 header bits that must all be set.
+ pub node_include: u16,
+ /// Node38 header bits that must all be clear.
+ pub node_exclude: u16,
+ /// Batch20 leading-DWORD bits that must all be set.
+ pub batch_include: u32,
+ /// Batch20 leading-DWORD bits that must all be clear.
+ pub batch_exclude: u32,
+ /// Type-7 triangle bits that must all be set.
+ pub triangle_include: u16,
+ /// Type-7 triangle bits that must all be clear.
+ pub triangle_exclude: u16,
+}
+
+/// One accepted segment hit from the native MSH triangle-query path.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SegmentHit {
+ /// Hit position in the same coordinate frame as the input segment.
+ pub position: [f32; 3],
+ /// Squared distance from the input segment's first endpoint.
+ pub distance_squared: f32,
+ /// Global Batch20 index.
+ pub batch_index: usize,
+ /// Global type-7 descriptor index.
+ pub triangle_descriptor_index: usize,
+ /// Global Res3 position indices of the hit triangle.
+ pub vertex_indices: [usize; 3],
+}
/// Vertex stream view.
#[derive(Clone, Debug, PartialEq)]
@@ -685,6 +741,287 @@ pub fn draw_batches(model: &ModelAsset, slot: SlotId) -> Result<&[Batch], MshErr
})
}
+/// Queries a segment against one slot using the native Node38, Batch20 and
+/// type-7 include-all/exclude-any masks.
+///
+/// The first endpoint is the native forward direction. Batch bit `0x0002`
+/// retries the plane test in reverse only when the forward plane test misses.
+/// The returned distance is always measured from the original first endpoint.
+///
+/// # Errors
+///
+/// Returns [`MshError`] when type 7 is absent, the model does not use Node38,
+/// or the selected slot's descriptor, batch, index or vertex ranges are invalid.
+pub fn query_slot_segment(
+ model: &ModelAsset,
+ node: NodeId,
+ slot: SlotId,
+ endpoints: [[f32; 3]; 2],
+ masks: SegmentQueryMasks,
+) -> Result<Option<SegmentHit>, MshError> {
+ let descriptors = model
+ .triangle_descriptors
+ .as_deref()
+ .ok_or(MshError::MissingStream {
+ type_id: STREAM_TRIANGLE_DESCRIPTORS,
+ label: "Res7",
+ })?;
+ if model.node_stride != 38 {
+ return Err(MshError::InvalidGeometry(format!(
+ "segment query requires Node38 metadata, got node stride {}",
+ model.node_stride
+ )));
+ }
+ if !endpoints.iter().flatten().all(|value| value.is_finite()) {
+ return Err(MshError::InvalidGeometry(
+ "segment endpoints must be finite".to_string(),
+ ));
+ }
+ let node_flags = node38_metadata(model, node)
+ .ok_or_else(|| MshError::InvalidGeometry("invalid Node38 metadata".to_string()))?
+ .header_flags;
+ let slot_index = usize::try_from(slot.0).map_err(|_| MshError::SlotOutOfBounds {
+ slot: slot.0,
+ slot_count: model.slots.len(),
+ })?;
+ let slot_ref = model
+ .slots
+ .get(slot_index)
+ .ok_or(MshError::SlotOutOfBounds {
+ slot: slot.0,
+ slot_count: model.slots.len(),
+ })?;
+ let batches = draw_batches(model, slot)?;
+ let descriptor_start = usize::from(slot_ref.tri_start);
+ let descriptor_end = descriptor_start
+ .checked_add(usize::from(slot_ref.tri_count))
+ .ok_or_else(|| MshError::InvalidGeometry("type-7 range overflow".to_string()))?;
+ if descriptor_end > descriptors.len() {
+ return Err(MshError::InvalidGeometry(format!(
+ "slot {slot_index} type-7 range {descriptor_start}..{descriptor_end} exceeds {} descriptors",
+ descriptors.len()
+ )));
+ }
+
+ let mut face_count = 0usize;
+ for (local_batch, batch) in batches.iter().enumerate() {
+ if batch.index_count % 3 != 0 {
+ return Err(MshError::InvalidGeometry(format!(
+ "batch {} index count {} is not triangle-aligned",
+ usize::from(slot_ref.batch_start) + local_batch,
+ batch.index_count
+ )));
+ }
+ let start = usize::try_from(batch.index_start)
+ .map_err(|_| MshError::InvalidGeometry("batch index range overflow".to_string()))?;
+ let end = start
+ .checked_add(usize::from(batch.index_count))
+ .ok_or_else(|| MshError::InvalidGeometry("batch index range overflow".to_string()))?;
+ if end > model.indices.len() {
+ return Err(MshError::InvalidGeometry(format!(
+ "batch {} index range {start}..{end} exceeds {} indices",
+ usize::from(slot_ref.batch_start) + local_batch,
+ model.indices.len()
+ )));
+ }
+ face_count = face_count
+ .checked_add(usize::from(batch.index_count) / 3)
+ .ok_or_else(|| MshError::InvalidGeometry("slot triangle count overflow".to_string()))?;
+ }
+ if face_count != usize::from(slot_ref.tri_count) {
+ return Err(MshError::InvalidGeometry(format!(
+ "slot {slot_index} declares {} type-7 descriptors but its batches contain {face_count} triangles",
+ slot_ref.tri_count
+ )));
+ }
+ if !mask_matches(
+ u32::from(node_flags),
+ u32::from(masks.node_include),
+ u32::from(masks.node_exclude),
+ ) {
+ return Ok(None);
+ }
+
+ let mut best = None;
+ let mut descriptor_cursor = descriptor_start;
+ for (local_batch, batch) in batches.iter().enumerate() {
+ let batch_index = usize::from(slot_ref.batch_start) + local_batch;
+ let batch_flags = u32::from(batch.batch_flags) | (u32::from(batch.material_index_hi) << 16);
+ let batch_face_count = usize::from(batch.index_count) / 3;
+ if mask_matches(batch_flags, masks.batch_include, masks.batch_exclude) {
+ for local_face in 0..batch_face_count {
+ let triangle_descriptor_index = descriptor_cursor + local_face;
+ let descriptor = descriptors[triangle_descriptor_index];
+ if !mask_matches(
+ u32::from(descriptor.tri_flags),
+ u32::from(masks.triangle_include),
+ u32::from(masks.triangle_exclude),
+ ) {
+ continue;
+ }
+ let index_start = usize::try_from(batch.index_start)
+ .expect("validated batch index range")
+ + local_face * 3;
+ let base_vertex = usize::try_from(batch.base_vertex)
+ .map_err(|_| MshError::InvalidGeometry("base vertex overflow".to_string()))?;
+ let mut vertex_indices = [0usize; 3];
+ for (triangle_corner, index) in vertex_indices.iter_mut().enumerate() {
+ let raw_index = usize::from(model.indices[index_start + triangle_corner]);
+ *index = base_vertex.checked_add(raw_index).ok_or_else(|| {
+ MshError::InvalidGeometry("resolved vertex index overflow".to_string())
+ })?;
+ if *index >= model.positions.len() {
+ return Err(MshError::VertexIndexOutOfBounds {
+ batch: batch_index,
+ vertex: u64::try_from(*index).unwrap_or(u64::MAX),
+ position_count: model.positions.len(),
+ });
+ }
+ }
+ let triangle = vertex_indices.map(|index| model.positions[index]);
+ if !triangle.iter().flatten().all(|value| value.is_finite()) {
+ return Err(MshError::InvalidGeometry(format!(
+ "triangle descriptor {triangle_descriptor_index} has a non-finite position"
+ )));
+ }
+ let normal = descriptor
+ .normal
+ .map(|value| f32::from(value) * TRIANGLE_NORMAL_SCALE);
+ if normal == [0.0; 3] {
+ continue;
+ }
+ let forward =
+ native_segment_plane_hit(normal, triangle[0], endpoints[0], endpoints[1])?;
+ let position = match forward {
+ Some(position) => Some(position),
+ None if batch.batch_flags & 0x0002 != 0 => {
+ native_segment_plane_hit(normal, triangle[0], endpoints[1], endpoints[0])?
+ }
+ None => None,
+ };
+ let Some(position) = position else {
+ continue;
+ };
+ if !native_point_in_triangle(normal, triangle, position)? {
+ continue;
+ }
+ let offset = [
+ position[0] - endpoints[0][0],
+ position[1] - endpoints[0][1],
+ position[2] - endpoints[0][2],
+ ];
+ let distance_squared = dot3(offset, offset);
+ if !distance_squared.is_finite() {
+ return Err(MshError::InvalidGeometry(
+ "segment hit distance is non-finite".to_string(),
+ ));
+ }
+ if best.is_none_or(|hit: SegmentHit| distance_squared <= hit.distance_squared) {
+ best = Some(SegmentHit {
+ position,
+ distance_squared,
+ batch_index,
+ triangle_descriptor_index,
+ vertex_indices,
+ });
+ }
+ }
+ }
+ descriptor_cursor += batch_face_count;
+ }
+ Ok(best)
+}
+
+fn mask_matches(flags: u32, include: u32, exclude: u32) -> bool {
+ flags & include == include && flags & exclude == 0
+}
+
+fn native_segment_plane_hit(
+ normal: [f32; 3],
+ anchor: [f32; 3],
+ start: [f32; 3],
+ end: [f32; 3],
+) -> Result<Option<[f32; 3]>, MshError> {
+ let direction = [end[0] - start[0], end[1] - start[1], end[2] - start[2]];
+ let denominator = dot3(normal, direction);
+ let start_distance = dot3(normal, start) - dot3(normal, anchor);
+ let end_distance = start_distance + denominator;
+ if !denominator.is_finite() || !start_distance.is_finite() || !end_distance.is_finite() {
+ return Err(MshError::InvalidGeometry(
+ "segment plane calculation is non-finite".to_string(),
+ ));
+ }
+ if denominator >= 0.0 || start_distance <= 0.0 || end_distance > 0.0 {
+ return Ok(None);
+ }
+ let ratio = start_distance / denominator;
+ let position = [
+ start[0] - direction[0] * ratio,
+ start[1] - direction[1] * ratio,
+ start[2] - direction[2] * ratio,
+ ];
+ if !position.iter().all(|value| value.is_finite()) {
+ return Err(MshError::InvalidGeometry(
+ "segment intersection is non-finite".to_string(),
+ ));
+ }
+ Ok(Some(position))
+}
+
+fn native_point_in_triangle(
+ normal: [f32; 3],
+ triangle: [[f32; 3]; 3],
+ point: [f32; 3],
+) -> Result<bool, MshError> {
+ let absolute = normal.map(f32::abs);
+ let xy_max = if absolute[0] < absolute[1] {
+ absolute[1]
+ } else {
+ absolute[0]
+ };
+ let dominant_axis = if absolute[2] <= xy_max {
+ if absolute[0] < absolute[1] {
+ 1
+ } else {
+ 0
+ }
+ } else {
+ 2
+ };
+ for index in 0..3 {
+ let previous = triangle[(index + 2) % 3];
+ let current = triangle[index];
+ let relative = [
+ point[0] - previous[0],
+ point[1] - previous[1],
+ point[2] - previous[2],
+ ];
+ let edge = [
+ current[0] - previous[0],
+ current[1] - previous[1],
+ current[2] - previous[2],
+ ];
+ let side = match dominant_axis {
+ 0 => (relative[2] * edge[1] + relative[1] * -edge[2]) * normal[0],
+ 1 => (-edge[0] * relative[2] + relative[0] * edge[2]) * normal[1],
+ _ => (relative[1] * edge[0] - relative[0] * edge[1]) * normal[2],
+ };
+ if !side.is_finite() {
+ return Err(MshError::InvalidGeometry(
+ "triangle edge calculation is non-finite".to_string(),
+ ));
+ }
+ if side < 0.0 {
+ return Ok(false);
+ }
+ }
+ Ok(true)
+}
+
+fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 {
+ left[0] * right[0] + left[1] * right[1] + left[2] * right[2]
+}
+
impl MshDocument {
/// Returns original stream descriptors.
#[must_use]
@@ -791,6 +1128,10 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
let uv1 = read_optional_stream(document, STREAM_UV1)?
.map(|raw| parse_secondary_uv_stream(raw, positions.len()))
.transpose()?;
+ let type16_present = read_optional_stream(document, STREAM_TYPE_16)?.is_some();
+ let triangle_descriptors = read_optional_triangle_descriptors(document)?
+ .map(|raw| parse_triangle_descriptors(&raw.bytes))
+ .transpose()?;
let node_names = read_optional_stream(document, STREAM_NAMES)?
.map(|raw| parse_res10_names(&raw.bytes, node_count))
.transpose()?;
@@ -807,8 +1148,10 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
normals,
uv0,
uv1,
+ type16_present,
indices,
batches,
+ triangle_descriptors,
node_names,
animation,
})
@@ -879,6 +1222,28 @@ fn read_optional_stream(
}))
}
+fn read_optional_triangle_descriptors(
+ document: &NresDocument,
+) -> Result<Option<RawStream>, MshError> {
+ let mut entries = document
+ .entries()
+ .iter()
+ .filter(|entry| entry.meta().type_id == STREAM_TRIANGLE_DESCRIPTORS);
+ let Some(entry) = entries.next() else {
+ return Ok(None);
+ };
+ if entries.next().is_some() {
+ return Err(MshError::DuplicateStream {
+ type_id: STREAM_TRIANGLE_DESCRIPTORS,
+ label: "Res7",
+ });
+ }
+ Ok(Some(RawStream {
+ attributes: attributes(entry.meta()),
+ bytes: document.payload(entry.id())?.to_vec(),
+ }))
+}
+
fn parse_slots(data: &[u8]) -> Result<Vec<Slot>, MshError> {
if data.len() < 0x8C {
return Err(MshError::InvalidGeometry(format!(
@@ -997,6 +1362,30 @@ fn parse_batches(data: &[u8]) -> Result<Vec<Batch>, MshError> {
Ok(out)
}
+fn parse_triangle_descriptors(data: &[u8]) -> Result<Vec<TriangleDescriptor>, MshError> {
+ if !data.len().is_multiple_of(16) {
+ return Err(invalid_resource_size("Res7", data.len(), 16));
+ }
+ let mut out = Vec::with_capacity(data.len() / 16);
+ for offset in (0..data.len()).step_by(16) {
+ out.push(TriangleDescriptor {
+ tri_flags: read_u16_required(data, offset)?,
+ opaque: [
+ read_u16_required(data, offset + 2)?,
+ read_u16_required(data, offset + 4)?,
+ read_u16_required(data, offset + 6)?,
+ read_u16_required(data, offset + 14)?,
+ ],
+ normal: [
+ read_i16_required(data, offset + 8)?,
+ read_i16_required(data, offset + 10)?,
+ read_i16_required(data, offset + 12)?,
+ ],
+ });
+ }
+ Ok(out)
+}
+
fn parse_u16_array(data: &[u8], label: &'static str) -> Result<Vec<u16>, MshError> {
if !data.len().is_multiple_of(2) {
return Err(invalid_resource_size(label, data.len(), 2));
@@ -1212,6 +1601,12 @@ fn read_u16_required(bytes: &[u8], offset: usize) -> Result<u16, MshError> {
Ok(u16::from_le_bytes(arr))
}
+fn read_i16_required(bytes: &[u8], offset: usize) -> Result<i16, MshError> {
+ Ok(i16::from_le_bytes(
+ read_u16_required(bytes, offset)?.to_le_bytes(),
+ ))
+}
+
fn read_i8(bytes: &[u8], offset: usize) -> Result<i8, MshError> {
let byte = bytes
.get(offset)
@@ -1259,6 +1654,371 @@ mod tests {
assert_eq!(model.node_stride, 38);
assert_eq!(model.node_count, 0);
assert!(model.slots.is_empty());
+ assert_eq!(model.triangle_descriptors, None);
+ assert!(!model.type16_present);
+ }
+
+ #[test]
+ fn type16_presence_is_preserved_without_decoding_its_payload() {
+ let payload = [0xDE, 0xAD, 0xBE, 0xEF];
+ let document = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ stream(STREAM_TYPE_16, 0, b"Res16", &payload),
+ ]))
+ .expect("nested NRes");
+ let model =
+ validate_msh(&decode_msh(&document).expect("MSH document")).expect("model asset");
+
+ assert!(model.type16_present);
+ }
+
+ #[test]
+ fn type7_decodes_optional_records_and_keeps_opaque_words() {
+ let mut raw = Vec::new();
+ push_u16(&mut raw, 0x3456);
+ push_u16(&mut raw, 0x1111);
+ push_u16(&mut raw, 0x2222);
+ push_u16(&mut raw, 0x3333);
+ raw.extend_from_slice(&(-1_i16).to_le_bytes());
+ raw.extend_from_slice(&i16::MIN.to_le_bytes());
+ raw.extend_from_slice(&i16::MAX.to_le_bytes());
+ push_u16(&mut raw, 0x4444);
+ let document = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ stream(STREAM_TRIANGLE_DESCRIPTORS, 0, b"Res7", &raw),
+ ]))
+ .expect("nested NRes");
+ let msh = decode_msh(&document).expect("optional type7 stream");
+ let model = validate_msh(&msh).expect("model");
+
+ assert_eq!(
+ model.triangle_descriptors,
+ Some(vec![TriangleDescriptor {
+ tri_flags: 0x3456,
+ opaque: [0x1111, 0x2222, 0x3333, 0x4444],
+ normal: [-1, i16::MIN, i16::MAX],
+ }])
+ );
+ let preserved = msh.preserved_streams().expect("preserved streams");
+ assert_eq!(
+ preserved
+ .iter()
+ .find(|stream| stream.type_id == STREAM_TRIANGLE_DESCRIPTORS)
+ .unwrap()
+ .bytes
+ .as_ref(),
+ raw
+ );
+ }
+
+ #[test]
+ fn malformed_or_duplicate_optional_type7_stream_is_an_error() {
+ for entries in [
+ vec![
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ stream(STREAM_TRIANGLE_DESCRIPTORS, 0, b"Res7", &[0; 15]),
+ ],
+ vec![
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ stream(STREAM_TRIANGLE_DESCRIPTORS, 0, b"Res7a", &[0; 16]),
+ stream(STREAM_TRIANGLE_DESCRIPTORS, 0, b"Res7b", &[0; 16]),
+ ],
+ ] {
+ let document = decode_nested(&build_nres(&entries)).expect("nested NRes");
+ let msh = decode_msh(&document).expect("required streams");
+ assert!(validate_msh(&msh).is_err());
+ }
+ }
+
+ #[test]
+ fn segment_query_uses_native_one_sided_edge_and_endpoint_rules() {
+ let model = segment_model(&[(0, 0)], &[0]);
+ let hit = query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ )
+ .expect("forward segment")
+ .expect("front-to-back hit");
+ assert_eq!(hit.position, [0.0, 0.0, 0.0]);
+ assert_eq!(hit.distance_squared, 1.0);
+ assert_eq!(hit.vertex_indices, [0, 1, 2]);
+
+ assert!(query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, -1.0, 0.0], [0.0, 1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ )
+ .expect("reverse segment")
+ .is_none());
+ assert!(query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, 0.0, 0.0]],
+ SegmentQueryMasks::default(),
+ )
+ .expect("endpoint on plane")
+ .is_some());
+ assert!(query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 0.0, 0.0], [0.0, -1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ )
+ .expect("start on plane")
+ .is_none());
+ assert!(query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, -1.0], [0.0, -1.0, -1.0]],
+ SegmentQueryMasks::default(),
+ )
+ .expect("segment crossing triangle edge")
+ .is_some());
+
+ let two_sided = segment_model(&[(2, 0)], &[0]);
+ assert!(query_slot_segment(
+ &two_sided,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, -1.0, 0.0], [0.0, 1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ )
+ .expect("two-sided reverse retry")
+ .is_some());
+ }
+
+ #[test]
+ fn segment_query_skips_triangles_with_zero_packed_normals() {
+ let mut model = segment_model(&[(0, 0)], &[0]);
+ model
+ .triangle_descriptors
+ .as_mut()
+ .expect("type-7 descriptors")[0]
+ .normal = [0; 3];
+
+ assert!(query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ )
+ .expect("zero normal is a missed triangle")
+ .is_none());
+ }
+
+ #[test]
+ fn segment_query_applies_native_masks_and_advances_filtered_descriptors() {
+ let mut model = segment_model(&[(0x0200, 1), (0, 1), (0, 1)], &[0, 0x0020, 0x0004]);
+ model
+ .triangle_descriptors
+ .as_mut()
+ .expect("type-7 descriptors")
+ .insert(
+ 0,
+ TriangleDescriptor {
+ tri_flags: 0,
+ opaque: [0; 4],
+ normal: [0, i16::MAX, 0],
+ },
+ );
+ model.slots[0].tri_start = 1;
+ let masks = SegmentQueryMasks {
+ node_include: 0x0004,
+ node_exclude: 0,
+ batch_include: 0x0001_0000,
+ batch_exclude: 0x0000_0200,
+ triangle_include: 0x0004,
+ triangle_exclude: 0x0020,
+ };
+ let hit = query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ masks,
+ )
+ .expect("native masks")
+ .expect("third descriptor survives");
+ assert_eq!(hit.batch_index, 2);
+ assert_eq!(hit.triangle_descriptor_index, 3);
+ assert_eq!(hit.vertex_indices, [6, 7, 8]);
+
+ for masks in [
+ SegmentQueryMasks {
+ node_include: 0x0008,
+ ..masks
+ },
+ SegmentQueryMasks {
+ node_exclude: 0x0004,
+ ..masks
+ },
+ SegmentQueryMasks {
+ batch_include: 0x0002_0000,
+ ..masks
+ },
+ SegmentQueryMasks {
+ triangle_exclude: 0x0004,
+ ..masks
+ },
+ ] {
+ assert!(query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ masks,
+ )
+ .expect("filtered query")
+ .is_none());
+ }
+ }
+
+ #[test]
+ fn segment_query_replaces_equal_distance_hits_with_the_later_candidate() {
+ let model = segment_model(&[(0, 0), (0, 0)], &[0, 0]);
+ let hit = query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ )
+ .expect("equal-distance candidates")
+ .expect("hit");
+ assert_eq!(hit.distance_squared, 1.0);
+ assert_eq!(hit.batch_index, 1);
+ assert_eq!(hit.triangle_descriptor_index, 1);
+ }
+
+ #[test]
+ fn native_point_in_triangle_projects_on_each_dominant_normal_axis() {
+ for (normal, triangle, edge, outside) in [
+ (
+ [1.0, 0.0, 0.0],
+ [[0.0, -1.0, -1.0], [0.0, 1.0, -1.0], [0.0, 0.0, 1.0]],
+ [0.0, 0.0, -1.0],
+ [0.0, 2.0, 0.0],
+ ),
+ (
+ [0.0, 1.0, 0.0],
+ [[-1.0, 0.0, -1.0], [0.0, 0.0, 1.0], [1.0, 0.0, -1.0]],
+ [0.0, 0.0, -1.0],
+ [0.0, 0.0, 2.0],
+ ),
+ (
+ [0.0, 0.0, 1.0],
+ [[-1.0, -1.0, 0.0], [1.0, -1.0, 0.0], [0.0, 1.0, 0.0]],
+ [0.0, -1.0, 0.0],
+ [2.0, 0.0, 0.0],
+ ),
+ ] {
+ assert!(native_point_in_triangle(normal, triangle, [0.0; 3]).expect("inside"));
+ assert!(native_point_in_triangle(normal, triangle, edge).expect("edge"));
+ assert!(!native_point_in_triangle(normal, triangle, outside).expect("outside"));
+ }
+ }
+
+ #[test]
+ fn segment_query_reports_missing_type7_and_invalid_metadata_or_ranges() {
+ let mut model = segment_model(&[(0, 0)], &[0]);
+ model.triangle_descriptors = None;
+ assert!(matches!(
+ query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ ),
+ Err(MshError::MissingStream {
+ type_id: STREAM_TRIANGLE_DESCRIPTORS,
+ ..
+ })
+ ));
+
+ let mut model = segment_model(&[(0, 0)], &[0]);
+ model.node_stride = 24;
+ assert!(matches!(
+ query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ ),
+ Err(MshError::InvalidGeometry(_))
+ ));
+
+ let mut model = segment_model(&[(0, 0)], &[0]);
+ model.slots[0].tri_start = 1;
+ assert!(query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ )
+ .is_err());
+
+ let mut model = segment_model(&[(0, 0)], &[0]);
+ model.slots[0].batch_count = 2;
+ assert!(matches!(
+ query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ ),
+ Err(MshError::BatchRangeOutOfBounds { .. })
+ ));
+
+ let mut model = segment_model(&[(0, 0)], &[0]);
+ model.batches[0].index_count = 4;
+ assert!(query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ )
+ .is_err());
+
+ let mut model = segment_model(&[(0, 0)], &[0]);
+ model.batches[0].index_start = 3;
+ assert!(query_slot_segment(
+ &model,
+ NodeId(0),
+ SlotId(0),
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ SegmentQueryMasks::default(),
+ )
+ .is_err());
}
#[test]
@@ -1535,8 +2295,10 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: Vec::new(),
batches: Vec::new(),
+ triangle_descriptors: None,
node_names: None,
animation: Some(ModelAnimation {
keys: vec![
@@ -1596,8 +2358,10 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: Vec::new(),
batches: Vec::new(),
+ triangle_descriptors: None,
node_names: None,
animation: Some(ModelAnimation {
keys: vec![
@@ -1660,8 +2424,10 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: Vec::new(),
batches: Vec::new(),
+ triangle_descriptors: None,
node_names: None,
animation: Some(ModelAnimation {
keys: vec![
@@ -2420,6 +3186,156 @@ mod tests {
track.sample(AnimationTime(frame as f32))
}
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn factory_wall_door_and_reversed_segments_match_native_query() {
+ let root = corpus_root("IS");
+ let archive_path = files_under(&root)
+ .into_iter()
+ .find(|path| {
+ path.file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.eq_ignore_ascii_case("fortif.rlb"))
+ })
+ .expect("fortif.rlb in licensed corpus");
+ let archive_bytes = std::fs::read(&archive_path).expect("fortif.rlb bytes");
+ let archive = fparkan_nres::decode(
+ Arc::from(archive_bytes.into_boxed_slice()),
+ ReadProfile::Compatible,
+ )
+ .expect("fortif.rlb archive");
+ let entry = archive
+ .entries()
+ .iter()
+ .find(|entry| entry.name_bytes().eq_ignore_ascii_case(b"fr_l_plant.msh"))
+ .expect("plant factory MSH");
+ let nested = fparkan_nres::decode(
+ Arc::from(
+ archive
+ .payload(entry.id())
+ .expect("MSH payload")
+ .to_vec()
+ .into_boxed_slice(),
+ ),
+ ReadProfile::Compatible,
+ )
+ .expect("nested plant MSH");
+ let msh = decode_msh(&nested).expect("plant MSH document");
+ let model = validate_msh(&msh).expect("plant MSH model");
+ let slot = selected_slot(&model, NodeId(1), Lod(0), Group(0)).expect("Node 1 slot");
+ assert_eq!(slot, SlotId(0));
+ let hierarchy = node38_fallback_hierarchy(&model).expect("Node38 poses");
+ let pose = hierarchy.poses[1];
+ assert_eq!(pose.rotation, [0.0, 0.0, 0.0, 1.0]);
+
+ let masks = SegmentQueryMasks {
+ node_include: 0,
+ node_exclude: 0,
+ batch_include: 0,
+ batch_exclude: 0x0208,
+ triangle_include: 0,
+ triangle_exclude: 0x0024,
+ };
+ let local = |point: [f32; 3]| {
+ [
+ point[0] - pose.translation[0],
+ point[1] - pose.translation[1],
+ point[2] - pose.translation[2],
+ ]
+ };
+ let wall = query_slot_segment(
+ &model,
+ NodeId(1),
+ slot,
+ [local([-10.5, 22.16, 2.5]), local([-10.5, 19.16, 2.5])],
+ masks,
+ )
+ .expect("wall query")
+ .expect("solid wall hit");
+ assert_eq!(wall.batch_index, 5);
+ assert_eq!(wall.triangle_descriptor_index, 62);
+ assert_eq!(wall.distance_squared, 4.0);
+ assert!(query_slot_segment(
+ &model,
+ NodeId(1),
+ slot,
+ [local([0.5, 22.16, 2.5]), local([0.5, 19.16, 2.5])],
+ masks,
+ )
+ .expect("door query")
+ .is_none());
+ assert!(query_slot_segment(
+ &model,
+ NodeId(1),
+ slot,
+ [local([-10.5, 19.16, 2.5]), local([-10.5, 22.16, 2.5])],
+ masks,
+ )
+ .expect("reversed wall query")
+ .is_none());
+ }
+
+ fn segment_model(batch_fields: &[(u16, u16)], tri_flags: &[u16]) -> ModelAsset {
+ assert_eq!(batch_fields.len(), tri_flags.len());
+ let mut nodes_raw = node38([u16::MAX; 15]);
+ nodes_raw[0..2].copy_from_slice(&0x0004_u16.to_le_bytes());
+ let mut positions = Vec::with_capacity(batch_fields.len() * 3);
+ let mut indices = Vec::with_capacity(batch_fields.len() * 3);
+ let mut batches = Vec::with_capacity(batch_fields.len());
+ for index in 0..batch_fields.len() {
+ positions.extend_from_slice(&[[-1.0, 0.0, -1.0], [0.0, 0.0, 1.0], [1.0, 0.0, -1.0]]);
+ indices.extend_from_slice(&[0, 1, 2]);
+ batches.push(Batch {
+ batch_flags: batch_fields[index].0,
+ material_index_hi: batch_fields[index].1,
+ material_index: 0,
+ lightmap_index: 0,
+ local_batch_index: u16::try_from(index).expect("local batch index"),
+ index_count: 3,
+ index_start: u32::try_from(index * 3).expect("index start"),
+ vertex_count: 3,
+ base_vertex: u32::try_from(index * 3).expect("base vertex"),
+ });
+ }
+ ModelAsset {
+ node_stride: 38,
+ node_count: 1,
+ nodes_raw,
+ bounding_sphere_center: [0.0; 3],
+ bounding_sphere_radius: 1.0,
+ slots: vec![Slot {
+ tri_start: 0,
+ tri_count: u16::try_from(tri_flags.len()).expect("triangle count"),
+ batch_start: 0,
+ batch_count: u16::try_from(batch_fields.len()).expect("batch count"),
+ aabb_min: [-1.0, 0.0, -1.0],
+ aabb_max: [1.0, 0.0, 1.0],
+ sphere_center: [0.0; 3],
+ sphere_radius: 1.0,
+ opaque: [0; 5],
+ }],
+ positions,
+ normals: None,
+ uv0: None,
+ uv1: None,
+ type16_present: false,
+ indices,
+ batches,
+ node_names: None,
+ animation: None,
+ triangle_descriptors: Some(
+ tri_flags
+ .iter()
+ .map(|tri_flags| TriangleDescriptor {
+ tri_flags: *tri_flags,
+ opaque: [0; 4],
+ normal: [0, i16::MAX, 0],
+ })
+ .collect(),
+ ),
+ }
+ }
+
fn decode_nested(bytes: &[u8]) -> Result<NresDocument, NresError> {
fparkan_nres::decode(
Arc::from(bytes.to_vec().into_boxed_slice()),
diff --git a/crates/fparkan-runtime/Cargo.toml b/crates/fparkan-runtime/Cargo.toml
index f66551b..8eb3e84 100644
--- a/crates/fparkan-runtime/Cargo.toml
+++ b/crates/fparkan-runtime/Cargo.toml
@@ -17,3 +17,6 @@ fparkan-resource = { path = "../fparkan-resource" }
fparkan-script = { path = "../fparkan-script" }
fparkan-vfs = { path = "../fparkan-vfs" }
fparkan-world = { path = "../fparkan-world" }
+
+[dev-dependencies]
+fparkan-animation = { path = "../fparkan-animation" }
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs
index 5e352fc..c962649 100644
--- a/crates/fparkan-runtime/src/lib.rs
+++ b/crates/fparkan-runtime/src/lib.rs
@@ -1,6 +1,7 @@
#![forbid(unsafe_code)]
//! Runtime orchestration for mission loading and world stepping.
+use fparkan_assets::ClanBody;
#[cfg(test)]
use fparkan_assets::PreparedControlResourcePair;
use fparkan_assets::{
@@ -10,8 +11,8 @@ use fparkan_assets::{
AssetId, AssetManager, BehaviorProfileRates, BehaviorProfileVarError, BuildCategory,
MissionAssetPlan, MissionDocument, MissionError, MissionTerrainPaths, NresError,
PreparedComponentControl, PreparedControlEffectSection, PreparedControlNodeDataRow,
- PreparedControlNodeDataTable, PreparedControlTypedRecord, PreparedVisual, TerrainFormatError,
- TerrainPreparationError, TerrainWorld, TmaProfile,
+ PreparedControlNodeDataTable, PreparedControlTypedRecord, PreparedVisual, ResearchError,
+ ResearchTrfDocument, TerrainFormatError, TerrainPreparationError, TerrainWorld, TmaProfile,
};
use fparkan_msh::{node38_metadata, selected_slot, Group, Lod, NodeId};
use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy};
@@ -22,8 +23,9 @@ use fparkan_prototype::{
};
use fparkan_resource::{resource_name, CachedResourceRepository, ResourceKey, ResourceRepository};
use fparkan_script::{
- Handler19DwordWrite, Handler19InitInput, Handler19InitResolution, Handler19ResolveError,
- ScriptInstruction, ScriptPackage, VarSet, VarSetDefault, VmHostCallbackCommand,
+ FormulaTable, Handler19InitInput, ScriptInitResolution, ScriptInitResolveError,
+ ScriptInitWrite, ScriptInstruction, ScriptPackage, VarSet, VarSetDefault,
+ VmHostCallbackCommand,
};
use fparkan_terrain::SurfaceQuery;
use fparkan_vfs::{Vfs, VfsError};
@@ -36,9 +38,13 @@ use std::collections::HashMap;
use std::num::NonZeroUsize;
use std::sync::Arc;
+mod mesh_collision;
+
pub use fparkan_assets::MissionAssets;
+pub use mesh_collision::{query_building_segment_world, BuildingMeshHit, BuildingMeshNode};
const MISSION_DECODED_PAYLOAD_CACHE_ENTRIES: usize = 256;
+const MAX_MISSION_AI_RECORD_SLOTS: usize = 1_000_000;
const FALLBACK_SCRIPT_VARSET_PATH: &str = "MISSIONS/SCRIPTS/varset.var";
const INITIAL_BEHAVIOR_RNG_STATE: u32 = 1;
const UNIT_DAT_CONTROL_CLASS_HIGH_BIT: u32 = 0x8000_0000;
@@ -82,6 +88,15 @@ pub struct MissionRequest {
pub mode: MissionMode,
}
+/// Explicit inputs supplied by the startup caller to the full mission loader.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct MissionStartupInput {
+ /// Caller-supplied queue clock used for the initial Behavior deadline.
+ pub queue_time_ms: u32,
+ /// Whether this startup creates the per-clan research tables.
+ pub research_creation_gate: bool,
+}
+
const INITIAL_SCRIPT_HOST_STATUS: u32 = 5;
/// Ordered mission property preserved for later runtime stages.
@@ -481,6 +496,38 @@ pub struct MissionControlDeviceTableState {
pub current_output_rate_5e0_bits: u32,
}
+/// One initial outer row prepared from the current Control descriptor table.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MissionBehaviorControlGroup {
+ /// Table ordinal of the descriptor whose current tag starts this group.
+ pub header_device_ordinal: usize,
+ /// Table ordinals of current-tag-2 descriptors following this header.
+ pub candidate_device_ordinals: Vec<usize>,
+ /// Table ordinals of current-tag-30 descriptors following this header.
+ pub tag30_device_ordinals: Vec<usize>,
+}
+
+/// Initial grouping of a prepared Control table, before live Behavior refreshes.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct MissionBehaviorControlGrouping {
+ /// Groups in the order their current-tag-1 headers appeared.
+ pub groups: Vec<MissionBehaviorControlGroup>,
+ /// Current-tag-2 entries before the first header.
+ pub ungrouped_candidate_device_ordinals: Vec<usize>,
+}
+
+/// Initial Behavior grouping projection for one full-load mission object.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MissionBehaviorControlGroupingState {
+ /// Stable identity from the source TMA object order.
+ pub original_id: Option<OriginalObjectId>,
+ /// Corresponding live Rust world object.
+ pub handle: ObjectHandle,
+ /// Initial grouping, or the reason its prepared Control table is unavailable.
+ pub grouping:
+ Result<MissionBehaviorControlGrouping, MissionControlDeviceTableUnavailableReason>,
+}
+
/// Provenance for the class passed to Control's constructor.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MissionControlClassSource {
@@ -654,9 +701,11 @@ pub struct MissionScriptBundle {
pub base_path: String,
/// Resolved compiled `.scr` path.
pub script_path: String,
- /// Losslessly decoded compiled package; only proven `Handler(19)` Init
- /// writes are resolved and retained by runtime.
+ /// Losslessly decoded compiled package; only the recovered first-`Init`
+ /// subset is resolved and retained by runtime.
pub package: ScriptPackage,
+ /// Optional formula rows loaded from this bundle's sibling `.fml` file.
+ pub formula_table: Option<FormulaTable>,
/// Outcome of the restricted first-`Init` resolution for this bundle.
pub init_execution: MissionScriptInitExecution,
/// Recovered per-AI Behavior scheduler and total state.
@@ -664,6 +713,26 @@ pub struct MissionScriptBundle {
pub behavior_warm_start: Option<BehaviorWarmStartState>,
}
+/// Initial native record-slot words owned by one mission clan's AI instance.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MissionAiStartupState {
+ /// TMA clan index that owns this AI instance.
+ pub clan_index: usize,
+ /// Sentinel words appended during AI startup, in native insertion order.
+ pub record_slots: Vec<u32>,
+}
+
+/// One decoded research table associated with its TMA clan owner and source path.
+#[derive(Debug)]
+pub struct MissionResearchTrfTable {
+ /// TMA clan index that owns this table.
+ pub clan_index: usize,
+ /// Resolved TMA second-resource path used to load this table.
+ pub path: String,
+ /// Independently decoded TRF document.
+ pub document: ResearchTrfDocument,
+}
+
/// Behavior distributor clock and totals tracked from warm start through resource ticks.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BehaviorWarmStartState {
@@ -722,11 +791,12 @@ fn schedule_behavior_distributor_tick(
Some(behavior_elapsed_seconds(elapsed_ms))
}
-/// Numeric script defaults selected for the mission's first script bundle.
+/// Numeric script declarations selected for the mission's first script bundle.
///
/// GOG `ai.dll` first tries `<bundle-base>.var`, then falls back to the shared
/// `MISSIONS/SCRIPTS/varset.var`. This stores the successfully selected source
-/// and supplies the typed targets for the proven `Handler(19)` Init writes.
+/// and supplies typed targets for recovered `Handler(19)` and numeric formula
+/// assignments in the first `Init` event.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MissionScriptVarSet {
/// Resolved `.var` path selected by the original fallback order.
@@ -737,11 +807,7 @@ pub struct MissionScriptVarSet {
pub declarations: VarSet,
}
-/// One resolved, corpus-proven `Init` result for a mission clan.
-///
-/// This is intentionally limited to `Handler(19)`: an `Init` event's other
-/// selectors are retained in [`MissionScriptBundle::package`] but are not
-/// guessed as executable behavior.
+/// One resolved subset of the first `Init` event for a mission clan.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MissionScriptInitState {
/// TMA clan index that owns the `SuperAI` instance and varset.
@@ -750,8 +816,8 @@ pub struct MissionScriptInitState {
pub event_index: usize,
/// Raw event word preserved from the compiled `Init` event.
pub event_word: u32,
- /// Exact three DWORD writes made by each proven `Handler(19)` instruction.
- pub writes: Vec<Handler19DwordWrite>,
+ /// Ordered numeric writes made by the supported `Init` instructions.
+ pub writes: Vec<ScriptInitWrite>,
}
/// Execution status of the first event whose case-sensitive C-string name is `Init`.
@@ -765,14 +831,14 @@ pub enum MissionScriptInitExecution {
event_index: usize,
},
/// A preview retained the decoded package but stopped before the first
- /// instruction outside the recovered `Handler(19)` action subset.
+ /// instruction outside the recovered numeric `Init` subset.
Unsupported {
/// Zero-based event table index selected by native lookup.
event_index: usize,
/// Zero-based instruction index where restricted resolution stopped.
instruction_index: usize,
- /// Typed selector, control, or result-save shape that is not modeled.
- error: Handler19ResolveError,
+ /// Typed selector, operand, or expression shape that is not modeled.
+ error: ScriptInitResolveError,
},
}
@@ -823,6 +889,7 @@ struct MissionLoadOptions {
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct LoadedMissionScripts {
bundles: Vec<MissionScriptBundle>,
+ ai_startup_states: Vec<MissionAiStartupState>,
varset: Option<MissionScriptVarSet>,
init_states: Vec<MissionScriptInitState>,
varset_states: Vec<MissionScriptVarSetState>,
@@ -915,7 +982,7 @@ pub struct LoadedMission {
pub script_event_count: usize,
/// Numeric script defaults loaded through the original bundle/fallback rule.
pub script_varset_declaration_count: usize,
- /// `Handler(19)` Init results resolved for mission clans.
+ /// Writes resolved for the first mission-clan `Init` events.
pub script_init_state_count: usize,
/// Instantiated per-clan varsets whose Init writes have been applied.
pub script_varset_state_count: usize,
@@ -950,6 +1017,9 @@ struct LoadedMissionState {
behavior_ore_states: Vec<BehaviorOreState>,
behavior_profiles: Vec<BehaviorProfileState>,
control_device_tables: Vec<MissionControlDeviceTableState>,
+ behavior_control_groupings: Vec<MissionBehaviorControlGroupingState>,
+ research_trf_tables: Vec<MissionResearchTrfTable>,
+ ai_startup_states: Vec<MissionAiStartupState>,
script_bundles: Vec<MissionScriptBundle>,
script_varset: Option<MissionScriptVarSet>,
script_init_states: Vec<MissionScriptInitState>,
@@ -994,7 +1064,7 @@ pub enum EngineError {
/// Parse diagnostic.
message: String,
},
- /// A recovered `Handler(19)` Init input or target was not representable.
+ /// A recovered `Init` input or write target was not representable.
ScriptInit {
/// Owning TMA clan index.
clan_index: usize,
@@ -1012,7 +1082,7 @@ pub enum EngineError {
/// Zero-based instruction index within that event.
instruction_index: usize,
/// Typed unsupported instruction shape.
- source: Handler19ResolveError,
+ source: ScriptInitResolveError,
},
/// A recovered script handler could not resolve against runtime state.
ScriptRuntime {
@@ -1021,6 +1091,17 @@ pub enum EngineError {
/// Handler-specific diagnostic.
message: String,
},
+ /// A clan's initial AI record slots exceeded the Rust allocation budget or could not be reserved.
+ MissionAiRecordSlots {
+ /// Owning TMA clan index.
+ clan_index: usize,
+ /// Raw second-resource tag interpreted as unsigned bits.
+ raw_count: u32,
+ /// Slots already reserved for earlier clans in this mission.
+ allocated_slots: usize,
+ /// Budget or allocation diagnostic.
+ message: String,
+ },
/// `NRes` decode error.
Nres {
/// Resource path.
@@ -1028,6 +1109,22 @@ pub enum EngineError {
/// Source error.
source: NresError,
},
+ /// A loaded clan's second TMA resource could not be decoded as a TRF table.
+ ResearchTrf {
+ /// Owning TMA clan index.
+ clan_index: usize,
+ /// Resolved resource path.
+ path: String,
+ /// TRF decode diagnostic.
+ source: ResearchError,
+ },
+ /// A clan's research path is not representable by the supported ASCII path layer.
+ ResearchTrfPath {
+ /// Owning TMA clan index.
+ clan_index: usize,
+ /// Raw C-string bytes before path normalization.
+ path: Vec<u8>,
+ },
/// Mission decode error.
Mission {
/// Resource path.
@@ -1184,7 +1281,25 @@ impl std::fmt::Display for EngineError {
clan_index,
message,
} => write!(f, "clan {clan_index}: script runtime failed: {message}"),
+ Self::MissionAiRecordSlots {
+ clan_index,
+ raw_count,
+ allocated_slots,
+ message,
+ } => write!(
+ f,
+ "clan {clan_index} AI initial slots (unsigned raw count {raw_count}, after {allocated_slots} mission slots): {message}"
+ ),
Self::Nres { path, source } => write!(f, "{path}: {source}"),
+ Self::ResearchTrf {
+ clan_index,
+ path,
+ source,
+ } => write!(f, "clan {clan_index} research TRF {path}: {source}"),
+ Self::ResearchTrfPath { clan_index, path } => write!(
+ f,
+ "clan {clan_index} research TRF path contains non-ASCII bytes: {path:?}"
+ ),
Self::Mission { path, source } => write!(f, "{path}: {source}"),
Self::TerrainFormat { path, source } => write!(f, "{path}: {source}"),
Self::Terrain(source) => write!(f, "{source}"),
@@ -1223,6 +1338,7 @@ impl std::error::Error for EngineError {
Self::Path { source, .. } => Some(source),
Self::Vfs { source, .. } => Some(source),
Self::Nres { source, .. } => Some(source),
+ Self::ResearchTrf { source, .. } => Some(source),
Self::Mission { source, .. } => Some(source),
Self::TerrainFormat { source, .. } => Some(source),
Self::ScriptInitUnsupported { source, .. } => Some(source),
@@ -1234,7 +1350,9 @@ impl std::error::Error for EngineError {
| Self::VarSet { .. }
| Self::ScriptInit { .. }
| Self::ScriptRuntime { .. }
+ | Self::MissionAiRecordSlots { .. }
| Self::Movement(_)
+ | Self::ResearchTrfPath { .. }
| Self::PrototypeGraph { .. }
| Self::BehaviorProfile { .. }
| Self::BehaviorDistributorTick { .. }
@@ -1255,10 +1373,12 @@ pub fn create(services: EngineServices) -> Result<Engine, EngineError> {
/// Loads a mission transactionally and runs the recovered initial script subset.
///
-/// `queue_time_ms` is the caller-supplied 32-bit queue-clock sample used to
-/// initialize the first Behavior deadline for each script bundle. This runtime
-/// entry point does not acquire the native queue clock or reproduce the full
-/// SuperAI host lifecycle.
+/// `startup_input.queue_time_ms` is the caller-supplied 32-bit queue-clock
+/// sample used to initialize the first Behavior deadline for each script bundle.
+/// `startup_input.research_creation_gate` explicitly controls whether full
+/// startup loads per-clan research tables; it is not inferred from mission mode
+/// or path. This runtime entry point does not acquire the native queue clock or
+/// reproduce the full SuperAI host lifecycle.
///
/// # Errors
///
@@ -1270,13 +1390,13 @@ pub fn create(services: EngineServices) -> Result<Engine, EngineError> {
pub fn load_mission(
engine: &mut Engine,
request: MissionRequest,
- queue_time_ms: u32,
+ startup_input: MissionStartupInput,
) -> Result<LoadedMission, EngineError> {
load_mission_with_options(
engine,
request,
MissionLoadOptions::default(),
- Some(queue_time_ms),
+ Some(startup_input),
)
}
@@ -1335,7 +1455,7 @@ fn load_mission_with_options(
engine: &mut Engine,
request: MissionRequest,
options: MissionLoadOptions,
- queue_time_ms: Option<u32>,
+ startup_input: Option<MissionStartupInput>,
) -> Result<LoadedMission, EngineError> {
let vfs = engine.services.vfs.clone().ok_or(EngineError::MissingVfs)?;
let mission_path = normalize_engine_path("mission", &request.key)?;
@@ -1387,6 +1507,13 @@ fn load_mission_with_options(
}
})?;
let mut loaded_scripts = load_mission_scripts(&vfs, &mission, options.asset_scope)?;
+ let research_trf_tables = if options.asset_scope == MissionAssetScope::Full
+ && startup_input.is_some_and(|input| input.research_creation_gate)
+ {
+ load_mission_research_trf_tables(&vfs, &mission, &loaded_scripts.ai_startup_states)?
+ } else {
+ Vec::new()
+ };
let repository = CachedResourceRepository::with_payload_cache_budget(
vfs.clone(),
MISSION_DECODED_PAYLOAD_CACHE_ENTRIES,
@@ -1659,10 +1786,20 @@ fn load_mission_with_options(
} else {
Vec::new()
};
+ let behavior_control_groupings = if options.asset_scope == MissionAssetScope::Full {
+ control_device_tables
+ .iter()
+ .map(project_mission_behavior_control_grouping)
+ .collect()
+ } else {
+ Vec::new()
+ };
let mut next_behavior_rng_state = engine.behavior_rng_state;
if options.asset_scope == MissionAssetScope::Full {
- let queue_time_ms = queue_time_ms.expect("full mission loading requires a queue time");
+ let queue_time_ms = startup_input
+ .expect("full mission loading requires startup inputs")
+ .queue_time_ms;
if let Err(error) = start_mission_scripts(
&mission,
&mut loaded_scripts,
@@ -1738,6 +1875,9 @@ fn load_mission_with_options(
behavior_ore_states,
behavior_profiles,
control_device_tables,
+ behavior_control_groupings,
+ research_trf_tables,
+ ai_startup_states: loaded_scripts.ai_startup_states,
script_bundles: loaded_scripts.bundles,
script_varset: loaded_scripts.varset,
script_init_states: loaded_scripts.init_states,
@@ -1819,6 +1959,24 @@ pub fn loaded_mission_script_bundles(engine: &Engine) -> Option<&[MissionScriptB
.map(|state| state.script_bundles.as_slice())
}
+/// Returns the per-clan AI record slots initialized during full mission startup.
+#[must_use]
+pub fn loaded_mission_ai_startup_states(engine: &Engine) -> Option<&[MissionAiStartupState]> {
+ engine
+ .loaded
+ .as_ref()
+ .map(|state| state.ai_startup_states.as_slice())
+}
+
+/// Returns the per-clan TRF tables loaded during full mission startup.
+#[must_use]
+pub fn loaded_mission_research_trf_tables(engine: &Engine) -> Option<&[MissionResearchTrfTable]> {
+ engine
+ .loaded
+ .as_ref()
+ .map(|state| state.research_trf_tables.as_slice())
+}
+
/// Returns the numeric script defaults selected by the original `.var` fallback rule.
#[must_use]
pub fn loaded_mission_script_varset(engine: &Engine) -> Option<&MissionScriptVarSet> {
@@ -1828,10 +1986,11 @@ pub fn loaded_mission_script_varset(engine: &Engine) -> Option<&MissionScriptVar
.and_then(|state| state.script_varset.as_ref())
}
-/// Returns the proven script `Init` writes resolved for the loaded mission.
+/// Returns the recovered script `Init` writes resolved for the loaded mission.
///
-/// The list has one entry for each clan whose `Init` event contains recovered
-/// `Handler(19)` instructions. It is not a general script event executor.
+/// The list has one entry for each clan whose first `Init` event contains a
+/// supported `Handler(19)` instruction or numeric FML literal assignment. It
+/// is not a general script event executor.
#[must_use]
pub fn loaded_mission_script_init_states(engine: &Engine) -> Option<&[MissionScriptInitState]> {
engine
@@ -2058,9 +2217,65 @@ pub fn loaded_mission_control_device_tables(
.map(|state| state.control_device_tables.as_slice())
}
-/// Queries native Control global-table selector 1 (weighted ratio) or 2 (sum).
+/// Returns the initial prepared Control grouping for full-load mission objects.
+#[must_use]
+pub fn loaded_mission_behavior_control_groupings(
+ engine: &Engine,
+) -> Option<&[MissionBehaviorControlGroupingState]> {
+ engine
+ .loaded
+ .as_ref()
+ .map(|state| state.behavior_control_groupings.as_slice())
+}
+
+fn project_mission_behavior_control_grouping(
+ table: &MissionControlDeviceTableState,
+) -> MissionBehaviorControlGroupingState {
+ let grouping = match &table.status {
+ MissionControlDeviceTableStatus::Available(devices) => {
+ Ok(build_mission_behavior_control_grouping(devices))
+ }
+ MissionControlDeviceTableStatus::Unavailable(reason) => Err(reason.clone()),
+ };
+ MissionBehaviorControlGroupingState {
+ original_id: table.original_id,
+ handle: table.handle,
+ grouping,
+ }
+}
+
+fn build_mission_behavior_control_grouping(
+ devices: &[EffectiveControlDevice],
+) -> MissionBehaviorControlGrouping {
+ let mut grouping = MissionBehaviorControlGrouping::default();
+ for (ordinal, device) in devices.iter().enumerate() {
+ match device.descriptor.tag {
+ 1 => {
+ grouping.groups.push(MissionBehaviorControlGroup {
+ header_device_ordinal: ordinal,
+ candidate_device_ordinals: Vec::new(),
+ tag30_device_ordinals: Vec::new(),
+ });
+ }
+ 2 => match grouping.groups.last_mut() {
+ Some(group) => group.candidate_device_ordinals.push(ordinal),
+ None => grouping.ungrouped_candidate_device_ordinals.push(ordinal),
+ },
+ 30 => {
+ if let Some(group) = grouping.groups.last_mut() {
+ group.tag30_device_ordinals.push(ordinal);
+ }
+ }
+ _ => {}
+ }
+ }
+ grouping
+}
+
+/// Queries native Control global-table selector 1 (weighted ratio), 2 (sum), or 15.
/// `Ok(None)` preserves the original false/no-result behavior for an empty or
-/// zero-denominator table. Other unsupported native paths are reported as errors.
+/// zero-denominator table or selector 15 without both cached factory rows. Other
+/// unsupported native paths are reported as errors.
/// Inputs and intermediates must be finite and normal, or exact zero.
///
/// # Errors
@@ -2084,20 +2299,22 @@ pub fn query_loaded_mission_control_resource(
query_control_device_table_resource(table, selector)
}
-/// Queries one loaded Control device through its original factory getter.
+/// Queries one loaded Control device through its indexed selector interface.
///
-/// Selector `0xe00` is modeled for current descriptor tags 5 and 26 with
-/// factory tags 5 and 26. Both common factory readers use the device key to
-/// read the current provider value and multiply it by coefficient 0. Only
-/// current tag 26 also multiplies by `M`; current tag 5 ignores `M` and
-/// descriptor word 8 for this selector. Explicit Control update calls can
-/// change current provider or multiplier values; the mission clock does not
-/// advance them automatically.
+/// Selector `0x400` returns the current type-1 value from the provider row
+/// selected by the device key, including signed zero, regardless of current
+/// descriptor or factory tag. Selector `0xe00` is modeled for current
+/// descriptor tags 5 and 26 with factory tags 5 and 26. Both common factory
+/// readers use the device key to read the current provider value and multiply
+/// it by coefficient 0. Only current tag 26 also multiplies by `M`; current
+/// tag 5 ignores `M` and descriptor word 8 for this selector. Explicit Control
+/// update calls can change current provider or multiplier values; the mission
+/// clock does not advance them automatically.
///
/// # Errors
/// Returns an error for an invalid handle, unavailable mission/table/provider,
-/// an out-of-range device/provider key, or a factory/tag/float path that is not
-/// supported by the current reconstruction.
+/// an out-of-range device/provider key, an unsupported float range, or an
+/// unsupported current/factory tag on selector `0xe00`.
pub fn query_loaded_mission_control_device_selector(
engine: &Engine,
handle: ObjectHandle,
@@ -4127,7 +4344,7 @@ fn query_control_device_table_resource(
table: &MissionControlDeviceTableState,
selector: u32,
) -> Result<Option<f32>, ControlResourceAccessError> {
- if !matches!(selector, 1 | 2) {
+ if !matches!(selector, 1 | 2 | 15) {
return Err(ControlResourceAccessError::UnsupportedSelector(selector));
}
let entries = match &table.status {
@@ -4137,6 +4354,32 @@ fn query_control_device_table_resource(
}
};
+ if selector == 15 {
+ let Some(class9) = entries.iter().rev().find(|entry| entry.factory_tag == 9) else {
+ return Ok(None);
+ };
+ let Some(class21) = entries.iter().rev().find(|entry| entry.factory_tag == 21) else {
+ return Ok(None);
+ };
+
+ let mut sum = control_device_budget_coefficient(class21, 5)?;
+ for index in (0..5).rev() {
+ sum = checked_control_sum(
+ 21,
+ "sum",
+ sum,
+ control_device_budget_coefficient(class21, index)?,
+ )?;
+ }
+ return checked_control_product(
+ 9,
+ "selector-15 value",
+ control_device_selector_zero(class9)?,
+ sum,
+ )
+ .map(Some);
+ }
+
let mut numerator = 0.0_f32;
let mut denominator = 0.0_f32;
for entry in entries.iter().filter(|entry| entry.descriptor.tag == 19) {
@@ -4173,7 +4416,7 @@ fn query_control_device_table_selector(
device_index: usize,
selector: u32,
) -> Result<Option<f32>, ControlResourceAccessError> {
- if selector != 0xe00 {
+ if !matches!(selector, 0x400 | 0xe00) {
return Err(ControlResourceAccessError::UnsupportedSelector(selector));
}
let devices = match &table.status {
@@ -4189,6 +4432,10 @@ fn query_control_device_table_selector(
device_index,
device_count: devices.len(),
})?;
+ if selector == 0x400 {
+ return control_device_provider_value_from_status(&table.node_provider_status, device)
+ .map(Some);
+ }
query_control_device_selector(device, &table.node_provider_status)
}
@@ -6332,6 +6579,91 @@ fn normalize_engine_path(role: &'static str, value: &str) -> Result<NormalizedPa
})
}
+fn script_bundle_stem(base_path: &str) -> &str {
+ let suffix_start = base_path.len().saturating_sub(4);
+ if base_path
+ .get(suffix_start..)
+ .is_some_and(|suffix| suffix.eq_ignore_ascii_case(".scr"))
+ {
+ &base_path[..suffix_start]
+ } else {
+ base_path
+ }
+}
+
+fn script_sibling_path(base_path: &str, extension: &str) -> String {
+ format!("{}.{extension}", script_bundle_stem(base_path))
+}
+
+fn load_script_formula_table(
+ vfs: &Arc<dyn Vfs>,
+ base_path: &str,
+) -> Result<Option<FormulaTable>, EngineError> {
+ let path = normalize_engine_path("mission formula", &script_sibling_path(base_path, "fml"))?;
+ match vfs.read(&path) {
+ Ok(bytes) => Ok(Some(FormulaTable::parse(&bytes))),
+ Err(VfsError::NotFound(_)) => Ok(None),
+ Err(source) => Err(EngineError::Vfs {
+ path: path.as_str().to_string(),
+ source,
+ }),
+ }
+}
+
+fn load_mission_research_trf_tables(
+ vfs: &Arc<dyn Vfs>,
+ mission: &MissionDocument,
+ ai_startup_states: &[MissionAiStartupState],
+) -> Result<Vec<MissionResearchTrfTable>, EngineError> {
+ let mut tables = Vec::with_capacity(ai_startup_states.len());
+ for ai_state in ai_startup_states {
+ let Some(clan) = mission.clans.get(ai_state.clan_index) else {
+ return Err(EngineError::ScriptRuntime {
+ clan_index: ai_state.clan_index,
+ message: "AI startup state clan index is outside the decoded mission".to_string(),
+ });
+ };
+ let second_resource = match &clan.body {
+ ClanBody::Standard {
+ second_resource, ..
+ }
+ | ClanBody::Spatial {
+ second_resource, ..
+ } => second_resource,
+ };
+ let raw_path = second_resource
+ .path
+ .raw
+ .split(|byte| *byte == 0)
+ .next()
+ .unwrap_or_default();
+ if raw_path.is_empty() {
+ continue;
+ }
+ if !raw_path.is_ascii() {
+ return Err(EngineError::ResearchTrfPath {
+ clan_index: ai_state.clan_index,
+ path: raw_path.to_vec(),
+ });
+ }
+ let value = std::str::from_utf8(raw_path).expect("ASCII paths are valid UTF-8");
+ let path = normalize_engine_path("mission research TRF", value)?;
+ let bytes = read_vfs(vfs, &path)?;
+ let document =
+ ResearchTrfDocument::decode(bytes).map_err(|source| EngineError::ResearchTrf {
+ clan_index: ai_state.clan_index,
+ path: path.as_str().to_string(),
+ source,
+ })?;
+ tables.push(MissionResearchTrfTable {
+ clan_index: ai_state.clan_index,
+ path: path.as_str().to_string(),
+ document,
+ });
+ }
+ Ok(tables)
+}
+
fn load_mission_scripts(
vfs: &Arc<dyn Vfs>,
mission: &MissionDocument,
@@ -6340,6 +6672,54 @@ fn load_mission_scripts(
let script_bases = mission_script_bundle_bases(mission);
let mut bundles = Vec::with_capacity(script_bases.len());
let mut first_base_path = None;
+ let mut ai_startup_states = Vec::new();
+ if asset_scope == MissionAssetScope::Full {
+ let mut allocated_slots = 0usize;
+ for (clan_index, clan) in mission.clans.iter().enumerate() {
+ let second_resource = match &clan.body {
+ ClanBody::Standard {
+ second_resource, ..
+ }
+ | ClanBody::Spatial {
+ second_resource, ..
+ } => second_resource,
+ };
+ let raw_count = second_resource.tag as u32;
+ let count =
+ usize::try_from(raw_count).map_err(|_| EngineError::MissionAiRecordSlots {
+ clan_index,
+ raw_count,
+ allocated_slots,
+ message: "count does not fit this platform's address space".to_string(),
+ })?;
+ let requested_total = allocated_slots.checked_add(count);
+ if requested_total.is_none_or(|total| total > MAX_MISSION_AI_RECORD_SLOTS) {
+ return Err(EngineError::MissionAiRecordSlots {
+ clan_index,
+ raw_count,
+ allocated_slots,
+ message: format!(
+ "cumulative mission count exceeds the Rust allocation budget of {MAX_MISSION_AI_RECORD_SLOTS} u32 slots"
+ ),
+ });
+ }
+ let mut record_slots = Vec::new();
+ record_slots.try_reserve_exact(count).map_err(|source| {
+ EngineError::MissionAiRecordSlots {
+ clan_index,
+ raw_count,
+ allocated_slots,
+ message: format!("could not reserve {count} u32 slots: {source}"),
+ }
+ })?;
+ record_slots.resize(count, u32::MAX);
+ allocated_slots = requested_total.expect("checked cumulative AI slot count");
+ ai_startup_states.push(MissionAiStartupState {
+ clan_index,
+ record_slots,
+ });
+ }
+ }
for script_base in script_bases {
let base_path = String::from_utf8_lossy(&script_base.path_raw).into_owned();
let script_path = if base_path.to_ascii_lowercase().ends_with(".scr") {
@@ -6353,6 +6733,7 @@ fn load_mission_scripts(
path: normalized.as_str().to_string(),
message: source.to_string(),
})?;
+ let formula_table = load_script_formula_table(vfs, &base_path)?;
if first_base_path.is_none() {
first_base_path = Some(base_path.clone());
}
@@ -6361,6 +6742,7 @@ fn load_mission_scripts(
base_path,
script_path: normalized.as_str().to_string(),
package,
+ formula_table,
init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None,
});
@@ -6372,7 +6754,7 @@ fn load_mission_scripts(
let (init_states, varset_states) = if asset_scope == MissionAssetScope::Full {
(Vec::new(), Vec::new())
} else if let Some(varset) = &varset {
- let init_states = resolve_handler19_init_states(
+ let init_states = resolve_init_states(
&mission
.clans
.iter()
@@ -6389,6 +6771,7 @@ fn load_mission_scripts(
};
Ok(LoadedMissionScripts {
bundles,
+ ai_startup_states,
varset,
init_states,
varset_states,
@@ -6417,7 +6800,7 @@ fn start_mission_scripts(
));
if let Some(varset) = scripts.varset.as_ref() {
- init_states.extend(resolve_handler19_init_states(
+ init_states.extend(resolve_init_states(
&clan_anchors,
std::slice::from_mut(bundle),
varset,
@@ -6459,12 +6842,12 @@ fn materialize_script_varset_states(
return Err(EngineError::ScriptInit {
clan_index: bundle.clan_index,
message: format!(
- "Handler(19) write index {} is outside the instantiated varset",
+ "Init write index {} is outside the instantiated varset",
write.index
),
});
};
- *value = VarSetDefault::Dword(write.value);
+ *value = write.value;
}
}
Ok(MissionScriptVarSetState {
@@ -6475,7 +6858,7 @@ fn materialize_script_varset_states(
.collect()
}
-fn resolve_handler19_init_states(
+fn resolve_init_states(
clan_anchors: &[[f32; 2]],
bundles: &mut [MissionScriptBundle],
varset: &MissionScriptVarSet,
@@ -6508,15 +6891,15 @@ fn resolve_handler19_init_states(
};
match varset
.declarations
- .resolve_handler19_init(&bundle.package, input)
+ .resolve_init(&bundle.package, input, bundle.formula_table.as_ref())
.map_err(|source| EngineError::ScriptInit {
clan_index: bundle.clan_index,
message: source.to_string(),
})? {
- Handler19InitResolution::Missing => {
+ ScriptInitResolution::Missing => {
bundle.init_execution = MissionScriptInitExecution::Missing;
}
- Handler19InitResolution::Executed {
+ ScriptInitResolution::Executed {
event_index,
event_word,
writes,
@@ -6531,7 +6914,7 @@ fn resolve_handler19_init_states(
});
}
}
- Handler19InitResolution::Unsupported {
+ ScriptInitResolution::Unsupported {
event_index,
instruction_index,
error,
@@ -6542,7 +6925,7 @@ fn resolve_handler19_init_states(
error,
};
}
- Handler19InitResolution::Unsupported {
+ ScriptInitResolution::Unsupported {
event_index,
instruction_index,
error,
@@ -6631,7 +7014,6 @@ fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, Engi
#[cfg(test)]
mod tests {
use super::*;
- use fparkan_script::ScriptDispatchSelector;
use fparkan_vfs::{DirectoryVfs, MemoryVfs, OverlayVfs, VfsEntry, VfsMetadata};
use std::path::{Path, PathBuf};
@@ -6808,8 +7190,10 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: Vec::new(),
batches: Vec::new(),
+ triangle_descriptors: None,
node_names: None,
animation: None,
}
@@ -7086,6 +7470,95 @@ mod tests {
}
}
+ #[test]
+ fn behavior_control_grouping_uses_current_tags_and_table_ordinals() {
+ let tags = [2, 30, 1, 30, 2, 99, 1, 2, 30, 2, 1];
+ let devices = tags
+ .into_iter()
+ .enumerate()
+ .map(|(ordinal, current_tag)| {
+ let mut device = synthetic_effective_control_device(
+ 5,
+ current_tag,
+ 1.0_f32.to_bits(),
+ None,
+ None,
+ );
+ device.key = 100 + ordinal as u32;
+ device.descriptor.fixed_words[1] = 200 + ordinal as u32;
+ device
+ })
+ .collect();
+ let table =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(devices));
+ let state = project_mission_behavior_control_grouping(&table);
+ assert_eq!(state.original_id, Some(OriginalObjectId(7)));
+ assert_eq!(
+ state.handle,
+ ObjectHandle {
+ generation: 5,
+ slot: 7
+ }
+ );
+ assert_eq!(
+ state.grouping,
+ Ok(MissionBehaviorControlGrouping {
+ ungrouped_candidate_device_ordinals: vec![0],
+ groups: vec![
+ MissionBehaviorControlGroup {
+ header_device_ordinal: 2,
+ candidate_device_ordinals: vec![4],
+ tag30_device_ordinals: vec![3],
+ },
+ MissionBehaviorControlGroup {
+ header_device_ordinal: 6,
+ candidate_device_ordinals: vec![7, 9],
+ tag30_device_ordinals: vec![8],
+ },
+ MissionBehaviorControlGroup {
+ header_device_ordinal: 10,
+ candidate_device_ordinals: Vec::new(),
+ tag30_device_ordinals: Vec::new(),
+ },
+ ],
+ }),
+ "group membership uses current descriptor tags and table ordinals, not factory tags or keys"
+ );
+
+ let empty =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(Vec::new()));
+ assert_eq!(
+ project_mission_behavior_control_grouping(&empty).grouping,
+ Ok(MissionBehaviorControlGrouping::default())
+ );
+
+ let headerless_devices = [2, 30, 2]
+ .into_iter()
+ .map(|current_tag| {
+ synthetic_effective_control_device(5, current_tag, 1.0_f32.to_bits(), None, None)
+ })
+ .collect();
+ let headerless = synthetic_control_device_table(
+ MissionControlDeviceTableStatus::Available(headerless_devices),
+ );
+ assert_eq!(
+ project_mission_behavior_control_grouping(&headerless).grouping,
+ Ok(MissionBehaviorControlGrouping {
+ groups: Vec::new(),
+ ungrouped_candidate_device_ordinals: vec![0, 2],
+ })
+ );
+
+ let reason = MissionControlDeviceTableUnavailableReason::NoControlResource;
+ let unavailable = synthetic_control_device_table(
+ MissionControlDeviceTableStatus::Unavailable(reason.clone()),
+ );
+ assert_eq!(
+ project_mission_behavior_control_grouping(&unavailable).grouping,
+ Err(reason)
+ );
+ }
+
fn control_budget_test_table(generator_coefficient: f32) -> MissionControlDeviceTableState {
let mut generator = synthetic_effective_control_device(
19,
@@ -7986,6 +8459,251 @@ mod tests {
}
#[test]
+ fn control_resource_selector_15_uses_last_cached_factories_and_current_fields() {
+ let mut first_class9 = synthetic_effective_control_device(
+ 9,
+ 19,
+ 90.0_f32.to_bits(),
+ Some(f32::INFINITY.to_bits()),
+ Some(9.0_f32.to_bits()),
+ );
+ let mut first_class21 = synthetic_effective_control_device(
+ 21,
+ 19,
+ 90.0_f32.to_bits(),
+ Some(f32::INFINITY.to_bits()),
+ None,
+ );
+ let mut class9 = synthetic_effective_control_device(
+ 9,
+ 5,
+ 99.0_f32.to_bits(),
+ Some(f32::NAN.to_bits()),
+ Some(99.0_f32.to_bits()),
+ );
+ let mut class21 = synthetic_effective_control_device(
+ 21,
+ 26,
+ 90.0_f32.to_bits(),
+ Some(f32::INFINITY.to_bits()),
+ None,
+ );
+ first_class9.native_word_50_bits = Some(0);
+ first_class21.native_word_50_bits = Some(0);
+ class9.native_word_50_bits = Some(0);
+ class21.native_word_50_bits = Some(0);
+ class9.key = u32::MAX;
+ class21.key = u32::MAX;
+ class9.coefficient_bits.as_mut().unwrap()[0] = 3.0_f32.to_bits();
+ class9.class9_selector0_scale_bits = Some(4.0_f32.to_bits());
+ class21.coefficient_bits.as_mut().unwrap()[..6].copy_from_slice(&[
+ 1.0_f32.to_bits(),
+ 2.0_f32.to_bits(),
+ 3.0_f32.to_bits(),
+ 4.0_f32.to_bits(),
+ 5.0_f32.to_bits(),
+ 6.0_f32.to_bits(),
+ ]);
+
+ let table = synthetic_control_device_table_with_providers(
+ MissionControlDeviceTableStatus::Available(vec![
+ first_class9,
+ first_class21,
+ class9,
+ class21,
+ ]),
+ MissionControlNodeProviderTableStatus::Unavailable(
+ MissionControlNodeProviderTableUnavailableReason::MissingNodeData {
+ unit_component_index: 0,
+ source: None,
+ },
+ ),
+ );
+
+ assert_eq!(
+ query_control_device_table_resource(&table, 15)
+ .expect("selector 15 uses current cached getter values")
+ .map(f32::to_bits),
+ Some(252.0_f32.to_bits())
+ );
+ }
+
+ #[test]
+ fn control_resource_selector_15_preserves_descending_binary32_accumulation() {
+ let mut class9 = synthetic_effective_control_device(
+ 9,
+ 19,
+ 1.0_f32.to_bits(),
+ None,
+ Some(1.0_f32.to_bits()),
+ );
+ let mut class21 = synthetic_effective_control_device(21, 19, 0, None, None);
+ class9.coefficient_bits.as_mut().unwrap()[0] = 1.0_f32.to_bits();
+ class21.coefficient_bits.as_mut().unwrap()[..6].copy_from_slice(&[
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ (-16_777_216.0_f32).to_bits(),
+ 16_777_216.0_f32.to_bits(),
+ 1.0_f32.to_bits(),
+ ]);
+ let forward = class21.coefficient_bits.unwrap()[..6]
+ .iter()
+ .map(|bits| f32::from_bits(*bits))
+ .fold(0.0_f32, |sum, value| sum + value);
+ assert_eq!(forward.to_bits(), 1.0_f32.to_bits());
+
+ let table =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ class9.clone(),
+ class21.clone(),
+ ]));
+ assert_eq!(
+ query_control_device_table_resource(&table, 15)
+ .expect("descending native order")
+ .map(f32::to_bits),
+ Some(0.0_f32.to_bits())
+ );
+
+ class21.coefficient_bits.as_mut().unwrap()[..6].fill((-0.0_f32).to_bits());
+ let negative_zero =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ class9, class21,
+ ]));
+ assert_eq!(
+ query_control_device_table_resource(&negative_zero, 15)
+ .expect("negative zero is retained")
+ .map(f32::to_bits),
+ Some((-0.0_f32).to_bits())
+ );
+ }
+
+ #[test]
+ fn control_resource_selector_15_returns_no_result_without_both_cached_factories() {
+ let empty =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(Vec::new()));
+ assert_eq!(query_control_device_table_resource(&empty, 15), Ok(None));
+
+ let class9_only =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ synthetic_effective_control_device(9, 35, 1, None, None),
+ ]));
+ assert_eq!(
+ query_control_device_table_resource(&class9_only, 15),
+ Ok(None)
+ );
+
+ let mut class21 = synthetic_effective_control_device(21, 35, 1, None, None);
+ class21.coefficient_bits = None;
+ let class21_only =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ class21,
+ ]));
+ assert_eq!(
+ query_control_device_table_resource(&class21_only, 15),
+ Ok(None)
+ );
+ }
+
+ #[test]
+ fn control_resource_selector_15_reports_missing_and_unsupported_inputs() {
+ let mut class9 = synthetic_effective_control_device(
+ 9,
+ 19,
+ 1.0_f32.to_bits(),
+ None,
+ Some(1.0_f32.to_bits()),
+ );
+ let mut class21 = synthetic_effective_control_device(21, 19, 1.0_f32.to_bits(), None, None);
+ class21.coefficient_bits = None;
+ let missing_coefficients =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ class9.clone(),
+ class21.clone(),
+ ]));
+ assert_eq!(
+ query_control_device_table_resource(&missing_coefficients, 15),
+ Err(ControlResourceAccessError::UnsupportedField {
+ factory_tag: 21,
+ field: "coefficient table",
+ })
+ );
+
+ class21.coefficient_bits = Some([1.0_f32.to_bits(); 16]);
+ class9.coefficient_bits = None;
+ let missing_class9_coefficient =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ class9.clone(),
+ class21.clone(),
+ ]));
+ assert_eq!(
+ query_control_device_table_resource(&missing_class9_coefficient, 15),
+ Err(ControlResourceAccessError::UnsupportedField {
+ factory_tag: 9,
+ field: "coefficient",
+ })
+ );
+
+ class9.coefficient_bits = Some([1.0_f32.to_bits(); 16]);
+ class9.class9_selector0_scale_bits = None;
+ let missing_scale =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ class9.clone(),
+ class21.clone(),
+ ]));
+ assert_eq!(
+ query_control_device_table_resource(&missing_scale, 15),
+ Err(ControlResourceAccessError::UnsupportedField {
+ factory_tag: 9,
+ field: "class-9 selector-0 scale",
+ })
+ );
+
+ class9.class9_selector0_scale_bits = Some(1.0_f32.to_bits());
+ class21.coefficient_bits.as_mut().unwrap()[5] = f32::INFINITY.to_bits();
+ let nonfinite =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ class9.clone(),
+ class21.clone(),
+ ]));
+ assert_eq!(
+ query_control_device_table_resource(&nonfinite, 15),
+ Err(ControlResourceAccessError::NonFiniteValue {
+ factory_tag: 21,
+ field: "coefficient",
+ })
+ );
+
+ class21.coefficient_bits.as_mut().unwrap()[5] = 1;
+ let subnormal =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ class9.clone(),
+ class21.clone(),
+ ]));
+ assert_eq!(
+ query_control_device_table_resource(&subnormal, 15),
+ Err(ControlResourceAccessError::SubnormalValue {
+ factory_tag: 21,
+ field: "coefficient",
+ })
+ );
+
+ class21.coefficient_bits.as_mut().unwrap()[5] = f32::MAX.to_bits();
+ class21.coefficient_bits.as_mut().unwrap()[4] = f32::MAX.to_bits();
+ let overflowing_sum =
+ synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
+ class9, class21,
+ ]));
+ assert_eq!(
+ query_control_device_table_resource(&overflowing_sum, 15),
+ Err(ControlResourceAccessError::UnsupportedFloatRange {
+ factory_tag: Some(21),
+ field: "sum",
+ })
+ );
+ }
+
+ #[test]
fn control_resource_setter_updates_matching_rows_atomically_and_round_trips() {
let mut table =
synthetic_control_device_table(MissionControlDeviceTableStatus::Available(vec![
@@ -9621,11 +10339,13 @@ mod tests {
trailing_bytes: Vec::new(),
raw: Arc::from(&b""[..]),
},
+ formula_table: None,
init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None,
};
let mut scripts = LoadedMissionScripts {
bundles: vec![bundle(0, 18, Vec::new()), bundle(1, 19, vec![0, 1, 2])],
+ ai_startup_states: Vec::new(),
varset: Some(varset),
..LoadedMissionScripts::default()
};
@@ -9637,7 +10357,7 @@ mod tests {
assert!(matches!(
error,
EngineError::ScriptInitUnsupported {
- source: Handler19ResolveError::UnsupportedSelector {
+ source: ScriptInitResolveError::UnsupportedSelector {
found: fparkan_script::ScriptDispatchSelector::Handler(18),
},
..
@@ -9655,6 +10375,193 @@ mod tests {
assert_eq!(rng_state, 0x0029_e2c0);
}
+ fn script_slots_test_mission(clans: &[(bool, bool, i32)]) -> MissionDocument {
+ fn push_u32(bytes: &mut Vec<u8>, value: u32) {
+ bytes.extend_from_slice(&value.to_le_bytes());
+ }
+ fn push_lp(bytes: &mut Vec<u8>, value: &[u8]) {
+ push_u32(bytes, u32::try_from(value.len()).expect("length fits u32"));
+ bytes.extend_from_slice(value);
+ }
+
+ let mut tma = Vec::new();
+ push_u32(&mut tma, 1);
+ push_u32(&mut tma, 0);
+ push_u32(&mut tma, 6);
+ push_u32(
+ &mut tma,
+ u32::try_from(clans.len()).expect("clan count fits u32"),
+ );
+ for (clan_index, (spatial, scripted, tag)) in clans.iter().enumerate() {
+ push_lp(&mut tma, b"synthetic");
+ push_u32(&mut tma, u32::MAX);
+ tma.extend_from_slice(&(clan_index as f32).to_le_bytes());
+ tma.extend_from_slice(&0.0_f32.to_le_bytes());
+ let script_path: &[u8] = if *scripted {
+ b"MISSIONS/SCRIPTS/shared"
+ } else {
+ b""
+ };
+ if *spatial {
+ push_u32(&mut tma, 0);
+ push_lp(&mut tma, script_path);
+ push_u32(&mut tma, 0);
+ } else {
+ push_u32(&mut tma, 1);
+ push_lp(&mut tma, script_path);
+ push_u32(&mut tma, 0);
+ }
+ push_lp(&mut tma, b"unused.trf");
+ push_u32(&mut tma, *tag as u32);
+ push_u32(&mut tma, 0);
+ }
+ push_u32(&mut tma, 10);
+ push_u32(&mut tma, 0);
+ push_lp(&mut tma, b"");
+ push_u32(&mut tma, 0);
+ push_lp(&mut tma, b"");
+ push_u32(&mut tma, 1);
+ push_u32(&mut tma, 0);
+ decode_mission_payload(Arc::from(tma), TmaProfile::Strict).expect("synthetic mission TMA")
+ }
+
+ fn script_slots_test_tags(mission: &MissionDocument) -> Vec<i32> {
+ mission
+ .clans
+ .iter()
+ .map(|clan| match &clan.body {
+ ClanBody::Standard {
+ second_resource, ..
+ }
+ | ClanBody::Spatial {
+ second_resource, ..
+ } => second_resource.tag,
+ })
+ .collect()
+ }
+
+ fn script_slots_test_vfs() -> Arc<dyn Vfs> {
+ let package = encode_script_package(&ScriptPackage {
+ opcode_handler_count: 73,
+ events: Vec::new(),
+ trailing_bytes: Vec::new(),
+ raw: Arc::from(&b""[..]),
+ });
+ let mut files = MemoryVfs::default();
+ files.insert(
+ normalize_engine_path("script slots test", "MISSIONS/SCRIPTS/shared.scr")
+ .expect("script path"),
+ Arc::from(package.into_boxed_slice()),
+ );
+ files.insert(
+ normalize_engine_path("script slots test", "MISSIONS/SCRIPTS/shared.var")
+ .expect("varset path"),
+ Arc::from(&b"VAR(DWORD, value, 0)\n"[..]),
+ );
+ Arc::new(files)
+ }
+
+ #[test]
+ fn full_mission_script_loader_builds_independent_unsigned_ai_slots_per_clan() {
+ let mission = script_slots_test_mission(&[
+ (false, true, 2),
+ (true, true, 3),
+ (false, false, 4),
+ (false, true, 0),
+ ]);
+ let raw_tags = script_slots_test_tags(&mission);
+ let vfs = script_slots_test_vfs();
+
+ let full = load_mission_scripts(&vfs, &mission, MissionAssetScope::Full)
+ .expect("Full script bundle load");
+ assert_eq!(
+ full.bundles
+ .iter()
+ .map(|bundle| bundle.clan_index)
+ .collect::<Vec<_>>(),
+ [0, 1, 3],
+ "empty-script clans do not produce fake script bundles"
+ );
+ assert_eq!(
+ full.ai_startup_states
+ .iter()
+ .map(|state| state.clan_index)
+ .collect::<Vec<_>>(),
+ [0, 1, 2, 3],
+ "Full startup retains a state for every TMA clan"
+ );
+ assert_eq!(
+ full.ai_startup_states
+ .iter()
+ .map(|state| state.record_slots.clone())
+ .collect::<Vec<_>>(),
+ [
+ vec![u32::MAX; 2],
+ vec![u32::MAX; 3],
+ vec![u32::MAX; 4],
+ Vec::new(),
+ ]
+ );
+ assert_ne!(
+ full.ai_startup_states[0].record_slots.as_ptr(),
+ full.ai_startup_states[1].record_slots.as_ptr(),
+ "each clan AI owns a separate vector"
+ );
+ assert_ne!(
+ full.ai_startup_states[1].record_slots.as_ptr(),
+ full.ai_startup_states[2].record_slots.as_ptr(),
+ "empty-script AI also owns a separate vector"
+ );
+ assert!(full
+ .bundles
+ .iter()
+ .all(|bundle| bundle.init_execution == MissionScriptInitExecution::Missing));
+
+ let preview = load_mission_scripts(&vfs, &mission, MissionAssetScope::FirstMeshPreview)
+ .expect("preview script bundle load");
+ assert!(preview.ai_startup_states.is_empty());
+ assert_eq!(
+ preview
+ .bundles
+ .iter()
+ .map(|bundle| bundle.clan_index)
+ .collect::<Vec<_>>(),
+ [0, 1, 3]
+ );
+ assert_eq!(
+ script_slots_test_tags(&mission),
+ raw_tags,
+ "loading leaves the signed TMA tag bits unchanged"
+ );
+ }
+
+ #[test]
+ fn full_mission_script_loader_rejects_unsigned_and_cumulative_ai_slot_overflow() {
+ let vfs = script_slots_test_vfs();
+
+ let negative_tag = script_slots_test_mission(&[(false, false, -1)]);
+ assert!(matches!(
+ load_mission_scripts(&vfs, &negative_tag, MissionAssetScope::Full),
+ Err(EngineError::MissionAiRecordSlots {
+ clan_index: 0,
+ raw_count: u32::MAX,
+ ..
+ })
+ ));
+
+ let over_budget =
+ script_slots_test_mission(&[(false, true, 600_000), (true, false, 400_001)]);
+ assert!(matches!(
+ load_mission_scripts(&vfs, &over_budget, MissionAssetScope::Full),
+ Err(EngineError::MissionAiRecordSlots {
+ clan_index: 1,
+ raw_count: 400_001,
+ allocated_slots: 600_000,
+ ..
+ })
+ ));
+ }
+
#[test]
fn load_mission_requires_vfs_and_keeps_world_unchanged_on_error() {
let mut engine = create(EngineServices::default()).expect("engine");
@@ -9665,7 +10572,10 @@ mod tests {
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
mode: MissionMode::SingleMission,
},
- 0,
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: false,
+ },
)
.expect_err("missing VFS");
assert!(matches!(err, EngineError::MissingVfs));
@@ -11266,6 +12176,71 @@ mod tests {
}
#[test]
+ fn indexed_provider_value_query_400_reads_current_value_by_device_key() {
+ let mut device = synthetic_effective_control_device(2, 99, 9.0_f32.to_bits(), None, None);
+ device.key = 1;
+ device.native_word_50_bits = Some(0);
+ device.coefficient_bits = None;
+ device.descriptor.fixed_words[8] = 13.0_f32.to_bits();
+ let table = synthetic_control_device_table_with_providers(
+ MissionControlDeviceTableStatus::Available(vec![device]),
+ MissionControlNodeProviderTableStatus::Available(vec![
+ synthetic_node_provider_row(0, 0, 0.125_f32.to_bits()),
+ synthetic_node_provider_row(0, 1, 0.625_f32.to_bits()),
+ synthetic_node_provider_row(0, 2, 0.875_f32.to_bits()),
+ ]),
+ );
+
+ assert_eq!(
+ query_control_device_table_selector(&table, 0, 0x400),
+ Ok(Some(0.625_f32)),
+ "selector 0x400 returns the current type-1 value at the device's provider key"
+ );
+
+ let mut negative_zero = table.clone();
+ let MissionControlNodeProviderTableStatus::Available(rows) =
+ &mut negative_zero.node_provider_status
+ else {
+ unreachable!();
+ };
+ rows[1].current_type1_value_bits = (-0.0_f32).to_bits();
+ assert_eq!(
+ query_control_device_table_selector(&negative_zero, 0, 0x400)
+ .expect("negative-zero provider value")
+ .map(f32::to_bits),
+ Some((-0.0_f32).to_bits())
+ );
+
+ let reason = MissionControlNodeProviderTableUnavailableReason::MissingNodeData {
+ unit_component_index: 0,
+ source: None,
+ };
+ let unavailable = synthetic_control_device_table_with_providers(
+ table.status.clone(),
+ MissionControlNodeProviderTableStatus::Unavailable(reason.clone()),
+ );
+ assert_eq!(
+ query_control_device_table_selector(&unavailable, 0, 0x400),
+ Err(ControlResourceAccessError::NodeProviderTableUnavailable(
+ reason
+ ))
+ );
+
+ let mut out_of_range = table.clone();
+ let MissionControlDeviceTableStatus::Available(devices) = &mut out_of_range.status else {
+ unreachable!();
+ };
+ devices[0].key = 3;
+ assert_eq!(
+ query_control_device_table_selector(&out_of_range, 0, 0x400),
+ Err(ControlResourceAccessError::NodeProviderKeyOutOfRange {
+ key: 3,
+ row_count: 3,
+ })
+ );
+ }
+
+ #[test]
fn synthetic_into_builder_appends_all_rows_and_overrides_only_first_record() {
let append = synthetic_control_table(
&[None, Some(PROTOTYPE_TYPE_INTO)],
@@ -11511,7 +12486,10 @@ mod tests {
key: "MISSIONS/TEST/MINE/data.tma".to_string(),
mode: MissionMode::SingleMission,
},
- 0,
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: false,
+ },
)
.expect("full normal mission load");
assert_eq!(loaded.object_count, 1);
@@ -12207,6 +13185,392 @@ mod tests {
assert_eq!(rows[stage_key as usize].initial_amount_bits, stage_initial);
}
+ fn test_push_u32(bytes: &mut Vec<u8>, value: u32) {
+ bytes.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn test_push_i32(bytes: &mut Vec<u8>, value: i32) {
+ bytes.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn build_nres(entries: &[(u32, u32, u32, u32, Vec<u8>)]) -> Vec<u8> {
+ let mut bytes = vec![0; 16];
+ let mut offsets = Vec::with_capacity(entries.len());
+ for (_, _, _, _, payload) in entries {
+ offsets.push(u32::try_from(bytes.len()).expect("offset fits u32"));
+ bytes.extend_from_slice(payload);
+ let padding = (8 - bytes.len() % 8) % 8;
+ bytes.resize(bytes.len() + padding, 0);
+ }
+ for (index, (type_id, attr1, attr2, attr3, payload)) in entries.iter().enumerate() {
+ test_push_u32(&mut bytes, *type_id);
+ test_push_u32(&mut bytes, *attr1);
+ test_push_u32(&mut bytes, *attr2);
+ test_push_u32(
+ &mut bytes,
+ u32::try_from(payload.len()).expect("payload fits u32"),
+ );
+ test_push_u32(&mut bytes, *attr3);
+ let mut name = [0; 36];
+ name[..4].copy_from_slice(b"Test");
+ bytes.extend_from_slice(&name);
+ test_push_u32(&mut bytes, offsets[index]);
+ test_push_u32(&mut bytes, u32::try_from(index).expect("index fits u32"));
+ }
+ bytes[..4].copy_from_slice(b"NRes");
+ bytes[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
+ let count = u32::try_from(entries.len()).expect("count fits u32");
+ bytes[8..12].copy_from_slice(&count.to_le_bytes());
+ let size = u32::try_from(bytes.len()).expect("size fits u32");
+ bytes[12..16].copy_from_slice(&size.to_le_bytes());
+ bytes
+ }
+
+ fn empty_land_mesh() -> Vec<u8> {
+ build_nres(&[
+ (1, 0, 0, 38, Vec::new()),
+ (2, 0, 0, 0, vec![0; 0x8c]),
+ (3, 0, 0, 12, Vec::new()),
+ (4, 0, 0, 4, Vec::new()),
+ (5, 0, 0, 4, Vec::new()),
+ (18, 0, 0, 4, Vec::new()),
+ (14, 0, 0, 4, Vec::new()),
+ (11, 0, 0, 4, Vec::new()),
+ (21, 0, 0, 28, Vec::new()),
+ ])
+ }
+
+ fn minimal_land_map() -> Vec<u8> {
+ let mut payload = Vec::new();
+ for value in [0.0_f32, 0.0, 0.0, 0.0, 2.0, 0.0, 1.0, 0.0] {
+ payload.extend_from_slice(&value.to_le_bytes());
+ }
+ for value in [0, 0, 7, 0, 2, 0] {
+ test_push_u32(&mut payload, value);
+ }
+ for value in [0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.0] {
+ payload.extend_from_slice(&value.to_le_bytes());
+ }
+ for _ in 0..2 {
+ test_push_i32(&mut payload, -1);
+ test_push_i32(&mut payload, -1);
+ }
+ test_push_u32(&mut payload, 1);
+ test_push_u32(&mut payload, 1);
+ payload.extend_from_slice(&1_u16.to_le_bytes());
+ payload.extend_from_slice(&0_u16.to_le_bytes());
+ build_nres(&[(12, 1, 0, 0, payload)])
+ }
+
+ #[test]
+ fn full_load_applies_a_synthetic_numeric_formula_in_init() {
+ fn push_u32(bytes: &mut Vec<u8>, value: u32) {
+ bytes.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn push_i32(bytes: &mut Vec<u8>, value: i32) {
+ bytes.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn push_f32(bytes: &mut Vec<u8>, value: f32) {
+ bytes.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn push_lp(bytes: &mut Vec<u8>, value: &[u8]) {
+ push_u32(bytes, u32::try_from(value.len()).expect("length fits u32"));
+ bytes.extend_from_slice(value);
+ }
+
+ fn synthetic_mission() -> Vec<u8> {
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, 1);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, 6);
+ push_u32(&mut bytes, 1);
+ push_lp(&mut bytes, b"Synthetic");
+ push_u32(&mut bytes, u32::MAX);
+ push_f32(&mut bytes, 12.25);
+ push_f32(&mut bytes, 34.75);
+ push_u32(&mut bytes, 1);
+ push_lp(&mut bytes, b"MISSIONS/SCRIPTS/formula");
+ push_i32(&mut bytes, 0);
+ push_lp(&mut bytes, b"");
+ push_i32(&mut bytes, 0);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, 10);
+ push_u32(&mut bytes, 0);
+ push_lp(&mut bytes, b"DATA\\MAPS\\SYNTHETIC\\land");
+ push_u32(&mut bytes, 0);
+ push_lp(&mut bytes, b"");
+ push_u32(&mut bytes, 1);
+ push_u32(&mut bytes, 0);
+ bytes
+ }
+
+ let package = ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![fparkan_script::ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0,
+ instructions: vec![
+ ScriptInstruction {
+ header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX],
+ references: vec![0, 1, 2],
+ },
+ ScriptInstruction {
+ header_words: [u32::MAX, 3, u32::MAX, u32::MAX, 6, 0, 0],
+ references: Vec::new(),
+ },
+ ],
+ }],
+ trailing_bytes: Vec::new(),
+ raw: Arc::from(&b""[..]),
+ };
+ let mut files = MemoryVfs::default();
+ for (path, bytes) in [
+ (
+ "MISSIONS/TEST/data.tma",
+ Arc::<[u8]>::from(synthetic_mission().into_boxed_slice()),
+ ),
+ (
+ "DATA/MAPS/SYNTHETIC/Land.msh",
+ Arc::<[u8]>::from(empty_land_mesh().into_boxed_slice()),
+ ),
+ (
+ "DATA/MAPS/SYNTHETIC/Land.map",
+ Arc::<[u8]>::from(minimal_land_map().into_boxed_slice()),
+ ),
+ ("BuildDat.lst", Arc::from(&b""[..])),
+ (
+ "MISSIONS/SCRIPTS/formula.scr",
+ Arc::<[u8]>::from(encode_script_package(&package).into_boxed_slice()),
+ ),
+ (
+ "MISSIONS/SCRIPTS/formula.var",
+ Arc::from(
+ &b"VAR(DWORD, ClanBaseX, 0)\nVAR(DWORD, ClanBaseY, 0)\nVAR(DWORD, ClanID, 99)\nVAR(float, target, 0.5)\n"[..],
+ ),
+ ),
+ (
+ "MISSIONS/SCRIPTS/formula.fml",
+ Arc::from(
+ &b"// authored Init expression\n\nFUNCTION( , ClanID + 0.8, )\nFUNCTION( later, target * 2, )\n"[..],
+ ),
+ ),
+ ] {
+ files.insert(
+ normalize_engine_path("synthetic mission", path).expect("valid path"),
+ bytes,
+ );
+ }
+ let vfs: Arc<dyn Vfs> = Arc::new(files);
+ let mut engine = create(EngineServices::new(vfs)).expect("engine");
+ let loaded = load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/TEST/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: false,
+ },
+ )
+ .expect("full load applies the authored numeric literal");
+
+ assert_eq!(loaded.script_bundle_count, 1);
+ let states = loaded_mission_script_varset_states(&engine).expect("runtime varset");
+ assert_eq!(
+ states[0].values,
+ [
+ VarSetDefault::Dword(12),
+ VarSetDefault::Dword(35),
+ VarSetDefault::Dword(0),
+ VarSetDefault::FloatBits(0x3f4c_cccd),
+ ]
+ );
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn licensed_fml_variable_rows_resolve_from_shared_varset_defaults() {
+ fn formula_action(target: u32, row: u32) -> ScriptInstruction {
+ ScriptInstruction {
+ header_words: [u32::MAX, target, u32::MAX, u32::MAX, 6, 0, row],
+ references: Vec::new(),
+ }
+ }
+
+ fn init_event_index(package: &ScriptPackage) -> usize {
+ package
+ .events
+ .iter()
+ .position(|event| {
+ event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..])
+ })
+ .expect("Init event")
+ }
+
+ fn row_is_assigned(instructions: &[ScriptInstruction], row: u32, target: u32) -> bool {
+ instructions.iter().any(|instruction| {
+ instruction.header_words[0] == u32::MAX
+ && instruction.header_words[1] == target
+ && instruction.header_words[2] == u32::MAX
+ && instruction.header_words[3] == u32::MAX
+ && instruction.header_words[6] == row
+ })
+ }
+
+ let root = licensed_root("IS");
+ let script_dir = root.join("MISSIONS/SCRIPTS");
+ let declarations = fparkan_script::parse_varset(
+ &std::fs::read(script_dir.join("varset.var")).expect("read shared varset"),
+ )
+ .expect("parse shared varset");
+ let formulas3 = FormulaTable::parse(
+ &std::fs::read(script_dir.join("c1m3e.fml")).expect("read c1m3e.fml"),
+ );
+ let package3 = fparkan_script::decode(
+ &std::fs::read(script_dir.join("c1m3e.scr")).expect("read c1m3e.scr"),
+ )
+ .expect("decode c1m3e.scr");
+ let init3_index = init_event_index(&package3);
+ let init3_event_word = package3.events[init3_index].event_word;
+ let init3 = &package3.events[init3_index].instructions;
+ assert!(row_is_assigned(init3, 0, 40));
+ assert!(row_is_assigned(init3, 1, 41));
+ assert!(row_is_assigned(init3, 2, 164));
+ assert!(!row_is_assigned(init3, 19, 158));
+ assert!(!row_is_assigned(init3, 22, 174));
+ assert!(row_is_assigned(
+ &package3
+ .events
+ .iter()
+ .find(|event| event.name_raw.split(|byte| *byte == 0).next()
+ == Some(&b"Fort_Captured"[..]))
+ .expect("Fort_Captured event")
+ .instructions,
+ 19,
+ 158,
+ ));
+ assert!(row_is_assigned(
+ &package3
+ .events
+ .iter()
+ .find(|event| event.name_raw.split(|byte| *byte == 0).next()
+ == Some(&b"PBM_BUILDING_PROTECT_Start"[..]))
+ .expect("building-protection event")
+ .instructions,
+ 22,
+ 174,
+ ));
+ assert_eq!(formulas3.rows[19].expression_raw, b" 0.8 + fT");
+ assert_eq!(
+ formulas3.rows[22].expression_raw,
+ b" dBuildingProtectHits - dT2"
+ );
+
+ let f_temp = u32::try_from(
+ declarations
+ .declarations
+ .iter()
+ .position(|declaration| declaration.name == "fTemp")
+ .expect("fTemp declaration"),
+ )
+ .expect("fTemp index fits u32");
+ let d_temp3 = u32::try_from(
+ declarations
+ .declarations
+ .iter()
+ .position(|declaration| declaration.name == "dTemp3")
+ .expect("dTemp3 declaration"),
+ )
+ .expect("dTemp3 index fits u32");
+ assert_eq!((f_temp, d_temp3), (158, 174));
+
+ let mut synthetic3 = package3.clone();
+ synthetic3.events[init3_index].instructions =
+ vec![formula_action(f_temp, 19), formula_action(d_temp3, 22)];
+ assert_eq!(
+ declarations.resolve_init(
+ &synthetic3,
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas3),
+ ),
+ Ok(ScriptInitResolution::Executed {
+ event_index: init3_index,
+ event_word: init3_event_word,
+ writes: vec![
+ ScriptInitWrite {
+ index: f_temp,
+ value: VarSetDefault::FloatBits(0.8_f32.to_bits()),
+ },
+ ScriptInitWrite {
+ index: d_temp3,
+ value: VarSetDefault::Dword(2_000_000),
+ },
+ ],
+ })
+ );
+
+ let formulas4 = FormulaTable::parse(
+ &std::fs::read(script_dir.join("c1m4e.fml")).expect("read c1m4e.fml"),
+ );
+ let package4 = fparkan_script::decode(
+ &std::fs::read(script_dir.join("c1m4e.scr")).expect("read c1m4e.scr"),
+ )
+ .expect("decode c1m4e.scr");
+ let init4_index = init_event_index(&package4);
+ let init4_event_word = package4.events[init4_index].event_word;
+ let init4 = &package4.events[init4_index].instructions;
+ assert!(!row_is_assigned(init4, 0, f_temp));
+ assert!(row_is_assigned(
+ &package4
+ .events
+ .iter()
+ .find(|event| event.name_raw.split(|byte| *byte == 0).next()
+ == Some(&b"PBM_BUILDING_INF_CAPTURE_Start"[..]))
+ .expect("building-infantry-capture event")
+ .instructions,
+ 0,
+ f_temp,
+ ));
+ assert_eq!(formulas4.rows[0].expression_raw, b" fTemp + 0.00001");
+ let mut synthetic4 = package4.clone();
+ synthetic4.events[init4_index].instructions =
+ vec![formula_action(f_temp, 0), formula_action(f_temp, 0)];
+ assert_eq!(
+ declarations.resolve_init(
+ &synthetic4,
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas4),
+ ),
+ Ok(ScriptInitResolution::Executed {
+ event_index: init4_index,
+ event_word: init4_event_word,
+ writes: vec![
+ ScriptInitWrite {
+ index: f_temp,
+ value: VarSetDefault::FloatBits(0.00001_f32.to_bits()),
+ },
+ ScriptInitWrite {
+ index: f_temp,
+ value: VarSetDefault::FloatBits((0.00001_f32 + 0.00001_f32).to_bits()),
+ },
+ ],
+ })
+ );
+ }
+
#[test]
fn script_varset_prefers_bundle_adjacent_file_then_uses_shared_fallback() {
let mut personal_files = MemoryVfs::default();
@@ -12257,6 +13621,27 @@ mod tests {
}
#[test]
+ fn script_formula_table_uses_an_optional_case_insensitive_scr_sibling() {
+ let mut files = MemoryVfs::default();
+ let vfs: Arc<dyn Vfs> = Arc::new(files.clone());
+ assert!(matches!(
+ load_script_formula_table(&vfs, "MISSIONS/SCRIPTS/custom.SCR"),
+ Ok(None)
+ ));
+
+ files.insert(
+ normalize_engine_path("test", "MISSIONS/SCRIPTS/custom.fml").expect("path"),
+ Arc::from(&b"FUNCTION(value, 1.25, )\n"[..]),
+ );
+ let vfs: Arc<dyn Vfs> = Arc::new(files);
+ let formulas = load_script_formula_table(&vfs, "MISSIONS/SCRIPTS/custom.SCR")
+ .expect("load optional formula sibling")
+ .expect("formula file exists");
+ assert_eq!(formulas.rows.len(), 1);
+ assert_eq!(formulas.evaluate_numeric_literal(0), Ok(1.25));
+ }
+
+ #[test]
fn handler_nineteen_init_executes_from_clan_anchor_for_each_bundle() {
let declarations = fparkan_script::parse_varset(
b"VAR( DWORD, ClanBaseX, 950)\nVAR( DWORD, ClanBaseY, 1000)\nVAR( DWORD, ClanID, 0)\n",
@@ -12278,18 +13663,19 @@ mod tests {
name_raw: b"Init\0".to_vec(),
event_word: 0x1234_5678,
instructions: vec![fparkan_script::ScriptInstruction {
- header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
+ header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX],
references: vec![0, 1, 2],
}],
}],
trailing_bytes: Vec::new(),
raw: Arc::from(&b""[..]),
},
+ formula_table: None,
init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None,
}];
- let states = resolve_handler19_init_states(
+ let states = resolve_init_states(
&[[500.0, 752.0], [728.0, 449.0]],
&mut bundles,
&varset,
@@ -12303,15 +13689,18 @@ mod tests {
event_index: 0,
event_word: 0x1234_5678,
writes: vec![
- Handler19DwordWrite {
+ ScriptInitWrite {
index: 0,
- value: 728
+ value: VarSetDefault::Dword(728),
},
- Handler19DwordWrite {
+ ScriptInitWrite {
index: 1,
- value: 449
+ value: VarSetDefault::Dword(449),
+ },
+ ScriptInitWrite {
+ index: 2,
+ value: VarSetDefault::Dword(1),
},
- Handler19DwordWrite { index: 2, value: 1 },
],
}]
);
@@ -12349,6 +13738,8 @@ mod tests {
#[test]
fn handler_nineteen_preview_keeps_unsupported_package_without_partial_writes() {
+ let formula_table =
+ FormulaTable::parse(b"FUNCTION(add, x + 2, )\nFUNCTION(unsupported, x * 2, )\n");
let varset = MissionScriptVarSet {
path: FALLBACK_SCRIPT_VARSET_PATH.to_string(),
used_fallback: true,
@@ -12365,11 +13756,15 @@ mod tests {
event_word: 0x1234,
instructions: vec![
fparkan_script::ScriptInstruction {
- header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
+ header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX],
references: vec![0, 1, 2],
},
fparkan_script::ScriptInstruction {
- header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0],
+ header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 6, 0, 0],
+ references: Vec::new(),
+ },
+ fparkan_script::ScriptInstruction {
+ header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 6, 0, 1],
references: Vec::new(),
},
],
@@ -12382,12 +13777,13 @@ mod tests {
base_path: "MISSIONS/SCRIPTS/default".to_string(),
script_path: "MISSIONS/SCRIPTS/default.scr".to_string(),
package: package.clone(),
+ formula_table: Some(formula_table.clone()),
init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None,
};
let anchors = [[500.0, 752.0]];
let mut preview_bundles = vec![bundle()];
- let preview_states = resolve_handler19_init_states(
+ let preview_states = resolve_init_states(
&anchors,
&mut preview_bundles,
&varset,
@@ -12399,9 +13795,10 @@ mod tests {
preview_bundles[0].init_execution,
MissionScriptInitExecution::Unsupported {
event_index: 0,
- instruction_index: 1,
- error: Handler19ResolveError::UnsupportedSelector {
- found: ScriptDispatchSelector::Handler(18),
+ instruction_index: 2,
+ error: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 1,
+ expression: b" x * 2".to_vec(),
},
}
);
@@ -12421,7 +13818,7 @@ mod tests {
let mut full_bundles = vec![bundle()];
assert!(matches!(
- resolve_handler19_init_states(
+ resolve_init_states(
&anchors,
&mut full_bundles,
&varset,
@@ -12430,12 +13827,13 @@ mod tests {
Err(EngineError::ScriptInitUnsupported {
clan_index: 0,
event_index: 0,
- instruction_index: 1,
- source: Handler19ResolveError::UnsupportedSelector {
- found: ScriptDispatchSelector::Handler(18),
+ instruction_index: 2,
+ source: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 1,
+ expression,
},
..
- })
+ }) if expression == b" x * 2"
));
let mut malformed_package = package.clone();
@@ -12447,7 +13845,7 @@ mod tests {
..bundle()
}];
assert!(matches!(
- resolve_handler19_init_states(
+ resolve_init_states(
&anchors,
&mut malformed_bundle,
&varset,
@@ -12639,7 +14037,10 @@ mod tests {
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
mode: MissionMode::AutoDemo,
},
- 100_000,
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
)
.expect("load licensed AutoDemo");
@@ -12779,11 +14180,24 @@ mod tests {
assert_eq!(loaded.script_bundle_count, 2);
assert_eq!(loaded.script_init_state_count, 2);
assert_eq!(loaded.script_varset_state_count, 2);
- assert!(loaded_mission_script_bundles(&engine)
- .expect("script bundles")
- .iter()
- .all(|bundle| bundle.init_execution
- == MissionScriptInitExecution::Executed { event_index: 0 }));
+ let script_bundles = loaded_mission_script_bundles(&engine).expect("script bundles");
+ assert_eq!(script_bundles.len(), 2);
+ assert!(script_bundles.iter().all(|bundle| bundle.init_execution
+ == MissionScriptInitExecution::Executed { event_index: 0 }));
+ for bundle in script_bundles {
+ let init = bundle
+ .package
+ .events
+ .iter()
+ .find(|event| event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..]))
+ .expect("first Init event");
+ let instruction = init.instructions.first().expect("Handler(19) action");
+ assert_eq!(
+ instruction.header_words,
+ [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX]
+ );
+ assert_eq!(instruction.references.as_slice(), &[224, 225, 226]);
+ }
assert_eq!(engine.behavior_rng_state, 0xc823_f683);
assert_eq!(
loaded_mission_script_bundles(&engine)
@@ -12837,7 +14251,10 @@ mod tests {
key: "MISSIONS/Autodemo.00/data.tma".into(),
mode: MissionMode::AutoDemo,
},
- 100_000,
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
)
.unwrap();
let tables = loaded_mission_control_device_tables(&engine).unwrap();
@@ -12858,6 +14275,196 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
+ fn full_load_builds_initial_behavior_control_groupings_for_autodemo() {
+ let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(
+ licensed_root("IS"),
+ ))))
+ .expect("engine");
+ load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Autodemo.00/data.tma".into(),
+ mode: MissionMode::AutoDemo,
+ },
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
+ )
+ .expect("load licensed AutoDemo with explicit startup inputs");
+
+ let groupings =
+ loaded_mission_behavior_control_groupings(&engine).expect("initial Behavior groupings");
+ let group = |header_device_ordinal, candidate_device_ordinals: &[usize]| {
+ MissionBehaviorControlGroup {
+ header_device_ordinal,
+ candidate_device_ordinals: candidate_device_ordinals.to_vec(),
+ tag30_device_ordinals: Vec::new(),
+ }
+ };
+ let expected = [
+ (0, vec![group(6, &[10, 11, 12, 13])]),
+ (1, vec![group(6, &[10, 11])]),
+ (2, vec![group(8, &[12, 13, 14])]),
+ (3, vec![group(6, &[10, 11, 12])]),
+ (4, Vec::new()),
+ (5, Vec::new()),
+ (6, Vec::new()),
+ (7, Vec::new()),
+ ];
+ assert_eq!(groupings.len(), expected.len());
+ for (original_id, expected_groups) in expected {
+ let state = groupings
+ .iter()
+ .find(|state| state.original_id == Some(OriginalObjectId(original_id)))
+ .expect("grouping for AutoDemo Control object");
+ assert_eq!(
+ state.grouping,
+ Ok(MissionBehaviorControlGrouping {
+ groups: expected_groups,
+ ungrouped_candidate_device_ordinals: Vec::new(),
+ }),
+ "original object {original_id}"
+ );
+ }
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn full_load_queries_control_selector_15_against_frozen_native_inputs() {
+ let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(
+ licensed_root("IS"),
+ ))))
+ .expect("engine");
+ load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Autodemo.00/data.tma".into(),
+ mode: MissionMode::AutoDemo,
+ },
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
+ )
+ .expect("load licensed AutoDemo with explicit startup inputs");
+
+ let tables = loaded_mission_control_device_tables(&engine).expect("Control tables");
+ let expected = [
+ (0, Some(0x4657_2800)),
+ (1, Some(0x4636_d000)),
+ (2, Some(0x467b_4001)),
+ (3, Some(0x468c_a000)),
+ (4, Some(0x46ec_4001)),
+ (5, Some(0x46ec_4001)),
+ (6, None),
+ (7, None),
+ ];
+ assert_eq!(tables.len(), expected.len());
+ for (original_id, expected_bits) in expected {
+ let table = tables
+ .iter()
+ .find(|table| table.original_id == Some(OriginalObjectId(original_id)))
+ .expect("AutoDemo Control table for original object");
+ let actual = query_loaded_mission_control_resource(&engine, table.handle, 15)
+ .expect("selector 15 query")
+ .map(f32::to_bits);
+ assert_eq!(actual, expected_bits, "original object {original_id}");
+ }
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn full_load_queries_indexed_control_selector_400_for_all_autodemo_devices() {
+ let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(
+ licensed_root("IS"),
+ ))))
+ .expect("engine");
+ load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Autodemo.00/data.tma".into(),
+ mode: MissionMode::AutoDemo,
+ },
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
+ )
+ .expect("load licensed AutoDemo with explicit startup inputs");
+
+ let tables = loaded_mission_control_device_tables(&engine).expect("Control tables");
+ let expected: [(u32, usize, &[u32]); 8] = [
+ (0, 33, &[0, 0, 0, 0, 0, 0, 16, 19, 16, 18, 27, 30, 31, 32]),
+ (1, 23, &[0, 0, 0, 0, 0, 0, 10, 13, 10, 11, 19, 22]),
+ (2, 26, &[0, 0, 0, 0, 0, 0, 0, 0, 8, 9, 8, 10, 19, 22, 25]),
+ (3, 29, &[0, 0, 0, 0, 0, 0, 16, 21, 16, 20, 26, 27, 28]),
+ (4, 32, &[5, 5, 1, 23, 3, 22, 7, 19, 16, 23, 5, 29]),
+ (5, 32, &[5, 5, 1, 23, 3, 22, 7, 19, 16, 23, 5, 29]),
+ (6, 22, &[17, 19, 19, 15, 13, 14, 10, 11, 4, 5, 7]),
+ (7, 22, &[17, 19, 19, 15, 13, 14, 10, 11, 4, 5, 7]),
+ ];
+ assert_eq!(tables.len(), expected.len());
+
+ let mut queried_devices = 0;
+ for (original_id, expected_provider_rows, expected_keys) in expected {
+ let table = tables
+ .iter()
+ .find(|table| table.original_id == Some(OriginalObjectId(original_id)))
+ .expect("AutoDemo Control table for original object");
+ let devices = match &table.status {
+ MissionControlDeviceTableStatus::Available(devices) => devices,
+ MissionControlDeviceTableStatus::Unavailable(reason) => {
+ panic!("Control table for object {original_id} is unavailable: {reason:?}");
+ }
+ };
+ let provider_rows = match &table.node_provider_status {
+ MissionControlNodeProviderTableStatus::Available(rows) => rows,
+ MissionControlNodeProviderTableStatus::Unavailable(reason) => {
+ panic!("provider rows for object {original_id} are unavailable: {reason:?}");
+ }
+ };
+ assert_eq!(
+ devices.len(),
+ expected_keys.len(),
+ "device count for {original_id}"
+ );
+ assert_eq!(
+ provider_rows.len(),
+ expected_provider_rows,
+ "provider row count for {original_id}"
+ );
+
+ for (device_index, expected_key) in expected_keys.iter().copied().enumerate() {
+ let device = &devices[device_index];
+ assert_eq!(
+ device.key, expected_key,
+ "provider key for object {original_id}, device {device_index}"
+ );
+ assert!(
+ (expected_key as usize) < provider_rows.len(),
+ "provider key for object {original_id}, device {device_index}"
+ );
+ let actual = query_loaded_mission_control_device_selector(
+ &engine,
+ table.handle,
+ device_index,
+ 0x400,
+ )
+ .expect("selector 0x400 query");
+ assert_eq!(
+ actual.map(f32::to_bits),
+ Some(0x3f80_0000),
+ "current provider value for object {original_id}, device {device_index}, key {expected_key}"
+ );
+ queried_devices += 1;
+ }
+ }
+ assert_eq!(queried_devices, 100);
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
fn headless_step_runs_resource_schedule_and_rolls_back_both_error_paths() {
let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(
licensed_root("IS"),
@@ -12869,7 +14476,10 @@ mod tests {
key: "MISSIONS/Autodemo.00/data.tma".into(),
mode: MissionMode::AutoDemo,
},
- 100_000,
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
)
.expect("licensed mission");
let schedules = |engine: &Engine| {
@@ -12978,7 +14588,10 @@ mod tests {
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
mode: MissionMode::AutoDemo,
},
- 100_000,
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
)
.expect("load licensed AutoDemo");
@@ -13713,16 +15326,17 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
- fn licensed_campaign_preview_keeps_unsupported_init_without_partial_writes() {
+ fn licensed_campaign_full_load_applies_literals_then_single_handler43_rolls_back() {
let root = licensed_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
- let mut engine = create(EngineServices::new(vfs)).expect("engine");
+ let mut engine = create(EngineServices::new(vfs.clone())).expect("engine");
+ let campaign_request = || MissionRequest {
+ key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ };
let loaded = load_mission_static_preview_roots(
&mut engine,
- MissionRequest {
- key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
- mode: MissionMode::SingleMission,
- },
+ campaign_request(),
NonZeroUsize::new(1).expect("non-zero root count"),
)
.expect("load licensed campaign preview");
@@ -13741,32 +15355,310 @@ mod tests {
.iter()
.all(|bundle| bundle.behavior_warm_start.is_none()));
assert_eq!(engine.behavior_rng_state, INITIAL_BEHAVIOR_RNG_STATE);
- let unsupported = bundles
+ let bundle = bundles
.iter()
- .find(|bundle| {
- matches!(
- &bundle.init_execution,
- MissionScriptInitExecution::Unsupported { .. }
- )
- })
- .expect("campaign package with an unsupported Init instruction");
- let clan_index = unsupported.clan_index;
+ .find(|bundle| bundle.clan_index == 0)
+ .expect("first campaign script bundle");
+ assert_eq!(
+ bundle.base_path.replace('\\', "/"),
+ "MISSIONS/SCRIPTS/tut1_pl2"
+ );
+ assert_eq!(bundle.formula_table.as_ref().unwrap().rows.len(), 26);
+ assert_eq!(
+ bundle
+ .formula_table
+ .as_ref()
+ .unwrap()
+ .evaluate_numeric_literal(0),
+ Ok(0.0)
+ );
+ assert_eq!(
+ bundle.init_execution,
+ MissionScriptInitExecution::Executed { event_index: 0 }
+ );
let states = loaded_mission_script_init_states(&engine).expect("Init writes");
- assert!(states.iter().all(|state| state.clan_index != clan_index));
+ let init = states
+ .iter()
+ .find(|state| state.clan_index == 0)
+ .expect("first clan Init writes");
+ assert_eq!(
+ init.writes
+ .iter()
+ .map(|write| write.index)
+ .collect::<Vec<_>>(),
+ [224, 225, 226, 188, 189, 190, 191]
+ );
+ assert!(init.writes[3..]
+ .iter()
+ .all(|write| write.value == VarSetDefault::Dword(0)));
let varset = loaded_mission_script_varset_states(&engine)
.expect("per-clan varsets")
.iter()
- .find(|state| state.clan_index == clan_index)
- .expect("unsupported clan varset");
+ .find(|state| state.clan_index == 0)
+ .expect("first clan varset");
+ assert_eq!(
+ &varset.values[188..192],
+ &[
+ VarSetDefault::Dword(0),
+ VarSetDefault::Dword(0),
+ VarSetDefault::Dword(0),
+ VarSetDefault::Dword(0),
+ ]
+ );
assert_eq!(
&varset.values[224..227],
&[
- VarSetDefault::Dword(950),
- VarSetDefault::Dword(1000),
+ VarSetDefault::Dword(549),
+ VarSetDefault::Dword(630),
VarSetDefault::Dword(0),
]
);
assert!(loaded.script_bundle_count >= 1);
+
+ let full = load_mission(
+ &mut engine,
+ campaign_request(),
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
+ )
+ .expect("full load licensed campaign mission");
+ assert_eq!(full.script_bundle_count, 4);
+ assert_eq!(full.script_init_state_count, 4);
+ assert_eq!(full.script_varset_state_count, 4);
+ let full_bundles = loaded_mission_script_bundles(&engine).expect("full script bundles");
+ assert_eq!(full_bundles.len(), 4);
+ assert!(full_bundles.iter().all(|bundle| matches!(
+ &bundle.init_execution,
+ MissionScriptInitExecution::Executed { event_index: 0 }
+ )));
+ assert!(full_bundles
+ .iter()
+ .all(|bundle| bundle.behavior_warm_start.is_some()));
+ let full_states = loaded_mission_script_init_states(&engine).expect("full Init writes");
+ let first_full_init = full_states
+ .iter()
+ .find(|state| state.clan_index == 0)
+ .expect("campaign clan 0 Init");
+ assert_eq!(
+ first_full_init
+ .writes
+ .iter()
+ .map(|write| write.index)
+ .collect::<Vec<_>>(),
+ [224, 225, 226, 188, 189, 190, 191]
+ );
+ assert!(first_full_init.writes[3..]
+ .iter()
+ .all(|write| write.value == VarSetDefault::Dword(0)));
+ let campaign_summary = loaded_mission(&engine).expect("full campaign").clone();
+ let campaign_bundles = full_bundles.to_vec();
+ let campaign_init_states = full_states.to_vec();
+ let campaign_varsets = loaded_mission_script_varset_states(&engine)
+ .expect("full per-clan varsets")
+ .to_vec();
+ let campaign_rng = engine.behavior_rng_state;
+ let before = step_headless(&mut engine, InputSnapshot, 0)
+ .expect("step loaded campaign")
+ .snapshot
+ .objects;
+
+ let single_error = load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Single.01/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ MissionStartupInput {
+ queue_time_ms: 100_500,
+ research_creation_gate: false,
+ },
+ )
+ .expect_err("Single.01 reaches the later Handler(43) boundary");
+ assert!(matches!(
+ single_error,
+ EngineError::ScriptInitUnsupported {
+ clan_index: 1,
+ path,
+ event_index: 0,
+ instruction_index: 10,
+ source: ScriptInitResolveError::UnsupportedSelector {
+ found: fparkan_script::ScriptDispatchSelector::Handler(43),
+ },
+ } if path.ends_with("scream.scr")
+ ));
+ assert_eq!(loaded_mission(&engine), Some(&campaign_summary));
+ assert_eq!(
+ loaded_mission_script_bundles(&engine),
+ Some(campaign_bundles.as_slice())
+ );
+ assert_eq!(
+ loaded_mission_script_init_states(&engine),
+ Some(campaign_init_states.as_slice())
+ );
+ assert_eq!(
+ loaded_mission_script_varset_states(&engine),
+ Some(campaign_varsets.as_slice())
+ );
+ assert_eq!(engine.behavior_rng_state, campaign_rng);
+ let after = step_headless(&mut engine, InputSnapshot, 0)
+ .expect("step retained campaign")
+ .snapshot
+ .objects;
+ assert_eq!(before, after);
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn licensed_single_script_bundle_load_initializes_ai_slots_before_later_init_work() {
+ let root = licensed_root("IS");
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
+ let path = normalize_engine_path("mission", "MISSIONS/Single.01/data.tma")
+ .expect("Single.01 path");
+ let mission = decode_mission_payload(
+ read_vfs(&vfs, &path).expect("Single.01 TMA"),
+ TmaProfile::Strict,
+ )
+ .expect("decode Single.01 TMA");
+ assert_eq!(script_slots_test_tags(&mission), [9, 9]);
+
+ let full = load_mission_scripts(&vfs, &mission, MissionAssetScope::Full)
+ .expect("Full script bundle load");
+ assert_eq!(
+ full.ai_startup_states
+ .iter()
+ .map(|state| state.record_slots.len())
+ .collect::<Vec<_>>(),
+ [9, 9]
+ );
+ assert_eq!(
+ full.ai_startup_states
+ .iter()
+ .map(|state| state.clan_index)
+ .collect::<Vec<_>>(),
+ [0, 1]
+ );
+ assert!(full
+ .ai_startup_states
+ .iter()
+ .all(|state| { state.record_slots == [u32::MAX; 9] }));
+ assert!(full
+ .bundles
+ .iter()
+ .all(|bundle| bundle.init_execution == MissionScriptInitExecution::Missing));
+
+ let preview = load_mission_scripts(&vfs, &mission, MissionAssetScope::FirstMeshPreview)
+ .expect("preview script bundle load");
+ assert!(preview.ai_startup_states.is_empty());
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn licensed_single_preview_loads_each_formula_table_and_stops_at_handler43() {
+ let root = licensed_root("IS");
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
+ let mut engine = create(EngineServices::new(vfs)).expect("engine");
+ load_mission_static_preview_roots(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Single.01/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ NonZeroUsize::new(1).expect("non-zero root count"),
+ )
+ .expect("load licensed Single.01 preview");
+
+ assert!(loaded_mission_behavior_control_groupings(&engine)
+ .expect("preview grouping projection")
+ .is_empty());
+
+ let bundles = loaded_mission_script_bundles(&engine).expect("script bundles");
+ assert_eq!(bundles.len(), 2);
+ let first = bundles
+ .iter()
+ .find(|bundle| bundle.clan_index == 0)
+ .expect("scr_pl_1 bundle");
+ assert_eq!(
+ first.base_path.replace('\\', "/"),
+ "MISSIONS/SCRIPTS/scr_pl_1"
+ );
+ assert_eq!(first.formula_table.as_ref().unwrap().rows.len(), 5);
+ assert_eq!(
+ first.init_execution,
+ MissionScriptInitExecution::Executed { event_index: 0 }
+ );
+ let second = bundles
+ .iter()
+ .find(|bundle| bundle.clan_index == 1)
+ .expect("scream bundle");
+ assert_eq!(
+ second.base_path.replace('\\', "/"),
+ "MISSIONS/SCRIPTS/scream"
+ );
+ let formulas = second.formula_table.as_ref().expect("scream FML");
+ assert_eq!(formulas.rows.len(), 149);
+ for (index, expected_bits) in [
+ 0x3ecc_cccd,
+ 0x3f4c_cccd,
+ 0x3f00_0000,
+ 0x3f80_0000,
+ 0x4120_0000,
+ 0x3e4c_cccd,
+ 0x42c8_0000,
+ 0x43c8_0000,
+ 0x4000_0000,
+ ]
+ .into_iter()
+ .enumerate()
+ {
+ assert_eq!(
+ formulas
+ .evaluate_numeric_literal(index as u32)
+ .expect("referenced native literal")
+ .to_bits(),
+ expected_bits
+ );
+ }
+ assert_eq!(
+ second.init_execution,
+ MissionScriptInitExecution::Unsupported {
+ event_index: 0,
+ instruction_index: 10,
+ error: ScriptInitResolveError::UnsupportedSelector {
+ found: fparkan_script::ScriptDispatchSelector::Handler(43),
+ },
+ }
+ );
+
+ let states = loaded_mission_script_init_states(&engine).expect("preview Init writes");
+ assert!(states.iter().any(|state| state.clan_index == 0));
+ assert!(states.iter().all(|state| state.clan_index != 1));
+ let first_varset = loaded_mission_script_varset_states(&engine)
+ .expect("per-clan varsets")
+ .iter()
+ .find(|state| state.clan_index == 0)
+ .expect("scr_pl_1 varset");
+ assert_eq!(
+ &first_varset.values[186..188],
+ &[VarSetDefault::Dword(0), VarSetDefault::Dword(0)]
+ );
+ let declarations = loaded_mission_script_varset(&engine)
+ .expect("shared script varset")
+ .declarations
+ .declarations
+ .iter()
+ .map(|declaration| declaration.default_value)
+ .collect::<Vec<_>>();
+ let second_varset = loaded_mission_script_varset_states(&engine)
+ .expect("per-clan varsets")
+ .iter()
+ .find(|state| state.clan_index == 1)
+ .expect("scream varset");
+ assert_eq!(second_varset.values, declarations);
+ assert!(bundles
+ .iter()
+ .all(|bundle| bundle.behavior_warm_start.is_none()));
}
#[test]
@@ -13800,8 +15692,15 @@ mod tests {
.snapshot
.objects;
- let error = load_mission(&mut engine, request(), 0)
- .expect_err("malformed profile archive prevents full mission load");
+ let error = load_mission(
+ &mut engine,
+ request(),
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: false,
+ },
+ )
+ .expect_err("malformed profile archive prevents full mission load");
assert!(matches!(
error,
EngineError::Nres { ref path, .. } if path == "behpsp.res"
@@ -13930,7 +15829,10 @@ mod tests {
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
mode: MissionMode::SingleMission,
},
- 0,
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: false,
+ },
)
.expect("load mission");
@@ -14003,7 +15905,15 @@ mod tests {
});
let mut engine = create(EngineServices::new(vfs)).expect("engine");
let before = step_headless(&mut engine, InputSnapshot, 0).expect("before");
- let err = load_mission(&mut engine, mission, 0).expect_err("load error");
+ let err = load_mission(
+ &mut engine,
+ mission,
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: false,
+ },
+ )
+ .expect_err("load error");
match denied {
DenyRule::Suffix("Land.map") => assert!(matches!(err, EngineError::Vfs { .. })),
DenyRule::Suffix("objects.rlb") => {
@@ -14035,7 +15945,10 @@ mod tests {
fail_after_registered_objects: Some(1),
..MissionLoadOptions::default()
},
- Some(0),
+ Some(MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: false,
+ }),
)
.expect_err("forced registration failure");
@@ -14064,16 +15977,31 @@ mod tests {
.iter()
.position(|event| event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..]))
.expect("Init event");
- assert_eq!(
- package.events[init_index].instructions[0].header_words[0],
- 19
+ let init = &mut package.events[init_index];
+ let handler = init
+ .instructions
+ .first()
+ .expect("original Handler(19) instruction");
+ assert_eq!(handler.header_words[0], 19);
+ assert_eq!(handler.references.as_slice(), &[224, 225, 226]);
+ let original_instruction_count = init.instructions.len();
+ init.instructions.push(ScriptInstruction {
+ header_words: [u32::MAX, 164, u32::MAX, u32::MAX, 6, 0, 0],
+ references: Vec::new(),
+ });
+ init.instructions.push(ScriptInstruction {
+ header_words: [u32::MAX, 158, u32::MAX, u32::MAX, 6, 0, 1],
+ references: Vec::new(),
+ });
+ let replacement_script = Arc::<[u8]>::from(encode_script_package(&package));
+ let replacement_formula = Arc::<[u8]>::from(
+ &b"FUNCTION(add, ClanBaseX + 0.8, )\nFUNCTION(unsupported, fT * 2, )\n"[..],
);
- package.events[init_index].instructions[0].header_words[0] = 18;
- let replacement = Arc::<[u8]>::from(encode_script_package(&package));
let vfs = Arc::new(SwappingScriptVfs {
inner: DirectoryVfs::new(&root),
- replacement,
+ replacement_script,
+ replacement_formula,
enabled: std::sync::atomic::AtomicBool::new(false),
});
let services_vfs: Arc<dyn Vfs> = vfs.clone();
@@ -14082,7 +16010,15 @@ mod tests {
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
mode: MissionMode::AutoDemo,
};
- load_mission(&mut engine, mission(), 100_000).expect("initial full startup");
+ load_mission(
+ &mut engine,
+ mission(),
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
+ )
+ .expect("initial full startup");
let previous_loaded = loaded_mission(&engine).expect("previous mission").clone();
let previous_bundles = loaded_mission_script_bundles(&engine)
@@ -14098,16 +16034,25 @@ mod tests {
.objects;
vfs.enabled.store(true, std::sync::atomic::Ordering::SeqCst);
- let error = load_mission(&mut engine, mission(), 100_500)
- .expect_err("unsupported Init must fail after new-world registration");
+ let error = load_mission(
+ &mut engine,
+ mission(),
+ MissionStartupInput {
+ queue_time_ms: 100_500,
+ research_creation_gate: false,
+ },
+ )
+ .expect_err("unsupported Init must fail after new-world registration");
assert!(matches!(
error,
EngineError::ScriptInitUnsupported {
- source: Handler19ResolveError::UnsupportedSelector {
- found: ScriptDispatchSelector::Handler(18),
+ instruction_index,
+ source: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 1,
+ expression,
},
..
- }
+ } if instruction_index == original_instruction_count + 1 && expression == b" fT * 2"
));
assert_eq!(loaded_mission(&engine), Some(&previous_loaded));
assert_eq!(
@@ -14176,7 +16121,10 @@ mod tests {
key: case.1.to_string(),
mode: MissionMode::SingleMission,
},
- 0,
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: false,
+ },
)
.expect("load mission");
let drafts = loaded_mission_object_drafts(&engine).expect("object drafts");
@@ -14290,7 +16238,10 @@ mod tests {
key: case.mission.to_string(),
mode: MissionMode::SingleMission,
},
- 0,
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: false,
+ },
)
.expect("load selected mission");
assert_eq!(loaded.object_count, case.object_count);
@@ -14320,7 +16271,10 @@ mod tests {
key: mission,
mode: MissionMode::SingleMission,
},
- 0,
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: false,
+ },
)
.expect("load mission foundation");
assert_eq!(loaded.object_count, loaded.registered_objects);
@@ -14460,6 +16414,516 @@ mod tests {
bytes
}
+ fn research_test_trf() -> Vec<u8> {
+ let mut row = [0_u32; 10];
+ row[0] = 0x1122_3344;
+ row[1] = 0x5566_7788;
+ row[8] = 0x09ff_0000;
+ let mut row_bytes = Vec::with_capacity(40);
+ for word in row {
+ row_bytes.extend_from_slice(&word.to_le_bytes());
+ }
+ let mut alias = Vec::new();
+ alias.extend_from_slice(&0_u16.to_le_bytes());
+ alias.extend_from_slice(&0_u16.to_le_bytes());
+ let zero_group = 0_u32.to_le_bytes().to_vec();
+ build_nres(&[
+ (u32::from_le_bytes(*b"TRF0"), 1, 3, 0, row_bytes),
+ (u32::from_le_bytes(*b"TRF1"), 1, 0, 0, vec![0x5a]),
+ (u32::from_le_bytes(*b"TRF2"), 1, 0, 0, zero_group.clone()),
+ (u32::from_le_bytes(*b"TRF4"), 1, 0, 0, zero_group),
+ (u32::from_le_bytes(*b"TRF6"), 0, 0, 0, b"test\0".to_vec()),
+ (u32::from_le_bytes(*b"TRF7"), 0, 0, 0, Vec::new()),
+ (u32::from_le_bytes(*b"TRF8"), 0, 0, 0, Vec::new()),
+ (u32::from_le_bytes(*b"TRF9"), 0, 0, 0, Vec::new()),
+ (u32::from_le_bytes(*b"TRFA"), 0, 0, 0, Vec::new()),
+ (u32::from_le_bytes(*b"TRFB"), 1, 0, 0, alias),
+ ])
+ }
+
+ fn research_startup_test_vfs(
+ script_bases: &[Option<&[u8]>],
+ research_paths: &[&[u8]],
+ trf: Option<&[u8]>,
+ ) -> Arc<dyn Vfs> {
+ research_startup_test_vfs_with_raw_tag(script_bases, research_paths, 9, trf)
+ }
+
+ fn research_startup_test_vfs_with_raw_tag(
+ script_bases: &[Option<&[u8]>],
+ research_paths: &[&[u8]],
+ raw_tag: i32,
+ trf: Option<&[u8]>,
+ ) -> Arc<dyn Vfs> {
+ fn push_u32(bytes: &mut Vec<u8>, value: u32) {
+ bytes.extend_from_slice(&value.to_le_bytes());
+ }
+ fn push_lp(bytes: &mut Vec<u8>, value: &[u8]) {
+ push_u32(
+ bytes,
+ u32::try_from(value.len()).expect("string length fits u32"),
+ );
+ bytes.extend_from_slice(value);
+ }
+ assert_eq!(script_bases.len(), research_paths.len());
+ let mut mission = Vec::new();
+ push_u32(&mut mission, 1);
+ push_u32(&mut mission, 0);
+ push_u32(&mut mission, 6);
+ push_u32(&mut mission, script_bases.len() as u32);
+ for (index, (script_base, research_path)) in
+ script_bases.iter().zip(research_paths).enumerate()
+ {
+ push_lp(&mut mission, b"Synthetic clan");
+ push_u32(&mut mission, u32::MAX);
+ mission.extend_from_slice(&(index as f32).to_le_bytes());
+ mission.extend_from_slice(&0.0_f32.to_le_bytes());
+ push_u32(&mut mission, 1);
+ push_lp(&mut mission, script_base.unwrap_or_default());
+ push_u32(&mut mission, 0);
+ push_lp(&mut mission, research_path);
+ push_u32(&mut mission, raw_tag as u32);
+ push_u32(&mut mission, 0);
+ }
+ push_u32(&mut mission, 10);
+ push_u32(&mut mission, 0);
+ push_lp(&mut mission, b"DATA\\MAPS\\SYNTHETIC\\land");
+ push_u32(&mut mission, 0);
+ push_lp(&mut mission, b"");
+ push_u32(&mut mission, 1);
+ push_u32(&mut mission, 0);
+
+ let land_mesh = empty_land_mesh();
+ let land_map = minimal_land_map();
+
+ let script_package = encode_script_package(&ScriptPackage {
+ opcode_handler_count: 73,
+ events: Vec::new(),
+ trailing_bytes: Vec::new(),
+ raw: Arc::from(&b""[..]),
+ });
+ let mut files = MemoryVfs::default();
+ for (path, bytes) in [
+ ("MISSIONS/TEST/data.tma", mission),
+ ("DATA/MAPS/SYNTHETIC/Land.msh", land_mesh),
+ ("DATA/MAPS/SYNTHETIC/Land.map", land_map),
+ ] {
+ files.insert(
+ normalize_engine_path("research startup test", path).expect("test path"),
+ Arc::from(bytes.into_boxed_slice()),
+ );
+ }
+ files.insert(
+ normalize_engine_path("test BuildDat", "BuildDat.lst").expect("BuildDat path"),
+ Arc::from(&b""[..]),
+ );
+ if script_bases.iter().any(Option::is_some) {
+ files.insert(
+ normalize_engine_path("test script", "MISSIONS/SCRIPTS/shared.scr")
+ .expect("script path"),
+ Arc::from(script_package.into_boxed_slice()),
+ );
+ files.insert(
+ normalize_engine_path("test varset", "MISSIONS/SCRIPTS/shared.var")
+ .expect("varset path"),
+ Arc::from(&b"VAR(DWORD, value, 0)\n"[..]),
+ );
+ }
+ if let Some(trf) = trf {
+ files.insert(
+ normalize_engine_path("test TRF", "DATA/Research/shared.trf").expect("TRF path"),
+ Arc::from(trf),
+ );
+ }
+ Arc::new(files)
+ }
+
+ #[test]
+ fn ai_record_slot_budget_error_keeps_loaded_mission_clock_and_rng() {
+ let valid_vfs = research_startup_test_vfs_with_raw_tag(
+ &[Some(b"MISSIONS/SCRIPTS/shared"), None],
+ &[b"", b""],
+ 9,
+ None,
+ );
+ let mut engine = create(EngineServices::new(valid_vfs)).expect("startup test engine");
+ let request = || MissionRequest {
+ key: "MISSIONS/TEST/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ };
+ load_mission(
+ &mut engine,
+ request(),
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
+ )
+ .expect("initial valid full load");
+ let previous_mission = loaded_mission(&engine).expect("loaded mission").clone();
+ let previous_bundles = loaded_mission_script_bundles(&engine)
+ .expect("loaded script bundles")
+ .to_vec();
+ let previous_ai_startup_states = loaded_mission_ai_startup_states(&engine)
+ .expect("loaded AI startup states")
+ .to_vec();
+ assert_eq!(previous_ai_startup_states.len(), 2);
+ assert_eq!(
+ previous_ai_startup_states
+ .iter()
+ .map(|state| state.clan_index)
+ .collect::<Vec<_>>(),
+ [0, 1]
+ );
+ assert!(previous_ai_startup_states
+ .iter()
+ .all(|state| state.record_slots == [u32::MAX; 9]));
+ assert_eq!(previous_bundles.len(), 1);
+ assert_eq!(previous_bundles[0].clan_index, 0);
+ assert_eq!(
+ previous_bundles[0].behavior_warm_start,
+ Some(BehaviorWarmStartState {
+ first_time_ms: 100_000,
+ next_deadline_ms: 100_202,
+ cleared_words: [0; 5],
+ })
+ );
+ let previous_rng = engine.behavior_rng_state;
+ assert_ne!(previous_rng, INITIAL_BEHAVIOR_RNG_STATE);
+ let previous_world = fparkan_world::canonical_state_hash(&engine.world);
+
+ engine.services.vfs = Some(research_startup_test_vfs_with_raw_tag(
+ &[None, None],
+ &[b"", b""],
+ -1,
+ None,
+ ));
+ let error = load_mission(
+ &mut engine,
+ request(),
+ MissionStartupInput {
+ queue_time_ms: 100_500,
+ research_creation_gate: false,
+ },
+ )
+ .expect_err("negative raw tag is unsigned u32::MAX and exceeds the budget");
+
+ assert!(matches!(
+ error,
+ EngineError::MissionAiRecordSlots {
+ clan_index: 0,
+ raw_count: u32::MAX,
+ ..
+ }
+ ));
+ assert_eq!(loaded_mission(&engine), Some(&previous_mission));
+ assert_eq!(
+ loaded_mission_script_bundles(&engine),
+ Some(previous_bundles.as_slice())
+ );
+ assert_eq!(
+ loaded_mission_ai_startup_states(&engine),
+ Some(previous_ai_startup_states.as_slice())
+ );
+ assert_eq!(engine.behavior_rng_state, previous_rng);
+ assert_eq!(
+ fparkan_world::canonical_state_hash(&engine.world),
+ previous_world
+ );
+ }
+
+ #[test]
+ fn full_load_keeps_independent_trf_tables_for_each_ai_state_clan() {
+ let vfs = research_startup_test_vfs(
+ &[Some(b"MISSIONS/SCRIPTS/shared"), None],
+ &[
+ b"DATA/Research/shared.trf\0ignored.trf",
+ b"DATA/Research/shared.trf",
+ ],
+ Some(&research_test_trf()),
+ );
+ let mut engine = create(EngineServices::new(vfs)).expect("research startup test engine");
+
+ load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/TEST/data.tma".to_string(),
+ mode: MissionMode::AutoDemo,
+ },
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: true,
+ },
+ )
+ .expect("full load with explicit research gate");
+
+ let tables = loaded_mission_research_trf_tables(&engine).expect("TRF tables");
+ assert_eq!(tables.len(), 2, "each AI state owner gets a table");
+ let bundles = loaded_mission_script_bundles(&engine).expect("script bundles");
+ assert_eq!(bundles.len(), 1, "only the scripted clan has a bundle");
+ assert_eq!(bundles[0].clan_index, 0);
+ let ai_states = loaded_mission_ai_startup_states(&engine).expect("AI states");
+ assert_eq!(
+ ai_states
+ .iter()
+ .map(|state| state.clan_index)
+ .collect::<Vec<_>>(),
+ [0, 1]
+ );
+ assert!(ai_states
+ .iter()
+ .all(|state| state.record_slots == [u32::MAX; 9]));
+ assert_eq!(tables[0].clan_index, 0);
+ assert_eq!(tables[0].path, "DATA/Research/shared.trf");
+ assert_eq!(tables[1].clan_index, 1);
+ assert_eq!(tables[1].path, "DATA/Research/shared.trf");
+ assert_eq!(
+ tables[0].document.row_words(0),
+ tables[1].document.row_words(0)
+ );
+ assert_eq!(tables[0].document.flag_byte(0), Some(0x5a));
+ assert_ne!(
+ tables[0].document.row_words(0).unwrap().as_ptr(),
+ tables[1].document.row_words(0).unwrap().as_ptr(),
+ "each clan owns an independently decoded row buffer"
+ );
+ }
+
+ #[test]
+ fn full_load_skips_research_trf_when_gate_or_c_string_is_missing() {
+ for (script_bases, research_paths, gate, mode) in [
+ (
+ vec![Some(&b"MISSIONS/SCRIPTS/shared"[..])],
+ vec![&b"DATA/Research/missing.trf"[..]],
+ false,
+ MissionMode::AutoDemo,
+ ),
+ (
+ vec![Some(&b"MISSIONS/SCRIPTS/shared"[..])],
+ vec![&b""[..]],
+ true,
+ MissionMode::SingleMission,
+ ),
+ (
+ vec![Some(&b"MISSIONS/SCRIPTS/shared"[..])],
+ vec![&b"\0ignored.trf"[..]],
+ true,
+ MissionMode::SingleMission,
+ ),
+ ] {
+ let vfs = research_startup_test_vfs(&script_bases, &research_paths, None);
+ let mut engine =
+ create(EngineServices::new(vfs)).expect("research startup test engine");
+ load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/TEST/data.tma".to_string(),
+ mode,
+ },
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: gate,
+ },
+ )
+ .expect("skipped TRF input must not be read");
+ assert!(loaded_mission_research_trf_tables(&engine)
+ .expect("TRF state")
+ .is_empty());
+ }
+ }
+
+ #[test]
+ fn static_preview_skips_research_trf_reads() {
+ let vfs = research_startup_test_vfs(
+ &[Some(b"MISSIONS/SCRIPTS/shared")],
+ &[b"DATA/Research/missing.trf"],
+ None,
+ );
+ let mut engine = create(EngineServices::new(vfs)).expect("research startup test engine");
+
+ load_mission_static_preview(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/TEST/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ )
+ .expect("preview must not read research TRF paths");
+
+ assert!(loaded_mission_research_trf_tables(&engine)
+ .expect("TRF state")
+ .is_empty());
+ assert!(loaded_mission_ai_startup_states(&engine)
+ .expect("AI state")
+ .is_empty());
+ }
+
+ #[test]
+ fn no_script_ai_research_trf_read_and_decode_errors_keep_loaded_world_and_rng() {
+ let initial_vfs = research_startup_test_vfs(
+ &[Some(b"MISSIONS/SCRIPTS/shared"), None],
+ &[b"", b"DATA/Research/shared.trf"],
+ Some(&research_test_trf()),
+ );
+ let mut engine = create(EngineServices::new(initial_vfs)).expect("research startup engine");
+ let request = || MissionRequest {
+ key: "MISSIONS/TEST/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ };
+ load_mission(
+ &mut engine,
+ request(),
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: true,
+ },
+ )
+ .expect("initial valid TRF table load");
+ let previous_summary = loaded_mission(&engine).expect("loaded mission").clone();
+ let previous_table =
+ &loaded_mission_research_trf_tables(&engine).expect("initial TRF table")[0];
+ let previous_owner = previous_table.clan_index;
+ let previous_path = previous_table.path.clone();
+ let previous_row = *previous_table.document.row_words(0).expect("initial row");
+ let previous_row_ptr = previous_table.document.row_words(0).unwrap().as_ptr();
+ let before = step_headless(&mut engine, InputSnapshot, 100_000)
+ .expect("step initial mission")
+ .snapshot
+ .objects;
+ let previous_rng = engine.behavior_rng_state;
+
+ for trf in [None, Some(&b"malformed"[..])] {
+ let vfs = research_startup_test_vfs(
+ &[Some(b"MISSIONS/SCRIPTS/shared"), None],
+ &[b"", b"DATA/Research/shared.trf"],
+ trf,
+ );
+ engine.services.vfs = Some(vfs);
+
+ let error = load_mission(
+ &mut engine,
+ request(),
+ MissionStartupInput {
+ queue_time_ms: 100_100,
+ research_creation_gate: true,
+ },
+ )
+ .expect_err("gate true must report a missing or malformed TRF");
+ assert_eq!(loaded_mission(&engine), Some(&previous_summary));
+ assert_eq!(engine.behavior_rng_state, previous_rng);
+ let retained_tables =
+ loaded_mission_research_trf_tables(&engine).expect("retained TRF tables");
+ assert_eq!(retained_tables.len(), 1);
+ assert_eq!(retained_tables[0].clan_index, previous_owner);
+ assert_eq!(retained_tables[0].path, previous_path);
+ assert_eq!(
+ retained_tables[0].document.row_words(0),
+ Some(&previous_row)
+ );
+ assert_eq!(
+ retained_tables[0].document.row_words(0).unwrap().as_ptr(),
+ previous_row_ptr
+ );
+ let after = step_headless(&mut engine, InputSnapshot, 100_000)
+ .expect("step retained mission")
+ .snapshot
+ .objects;
+ assert_eq!(before, after);
+ match (trf, error) {
+ (None, EngineError::Vfs { path, .. }) => {
+ assert_eq!(path, "DATA/Research/shared.trf");
+ }
+ (
+ Some(_),
+ EngineError::ResearchTrf {
+ clan_index, path, ..
+ },
+ ) => {
+ assert_eq!(clan_index, 1);
+ assert_eq!(path, "DATA/Research/shared.trf");
+ }
+ (_, error) => panic!("unexpected TRF failure: {error:?}"),
+ }
+ }
+ }
+
+ #[test]
+ fn research_trf_c_string_paths_reject_non_ascii_bytes() {
+ let vfs = research_startup_test_vfs(
+ &[Some(b"MISSIONS/SCRIPTS/shared"), None],
+ &[b"", b"DATA/Research/\xe9.trf"],
+ None,
+ );
+ let mut engine = create(EngineServices::new(vfs)).expect("research startup test engine");
+ let error = load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/TEST/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ MissionStartupInput {
+ queue_time_ms: 0,
+ research_creation_gate: true,
+ },
+ )
+ .expect_err("non-ASCII TRF path must fail explicitly");
+ assert!(matches!(
+ error,
+ EngineError::ResearchTrfPath { clan_index: 1, path }
+ if path == b"DATA/Research/\xe9.trf"
+ ));
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn licensed_campaign_full_load_creates_trf_tables_for_ai_clan_owners() {
+ let root = licensed_root("IS");
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
+ let mut engine = create(EngineServices::new(vfs)).expect("engine");
+ load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: true,
+ },
+ )
+ .expect("full campaign load with explicit research gate");
+
+ let tables = loaded_mission_research_trf_tables(&engine).expect("TRF tables");
+ assert_eq!(
+ tables
+ .iter()
+ .map(|table| (table.clan_index, table.path.as_str()))
+ .collect::<Vec<_>>(),
+ [
+ (0, "MISSIONS/SCRIPTS/data.trf"),
+ (1, "MISSIONS/SCRIPTS/data.trf"),
+ (2, "MISSIONS/SCRIPTS/data.trf"),
+ ]
+ );
+ assert!(tables.iter().all(|table| table.document.row_count() > 0));
+ assert_eq!(
+ tables[0].document.row_words(0),
+ tables[1].document.row_words(0)
+ );
+ assert_eq!(
+ tables[0].document.row_words(0),
+ tables[2].document.row_words(0)
+ );
+ assert_ne!(
+ tables[0].document.row_words(0).unwrap().as_ptr(),
+ tables[1].document.row_words(0).unwrap().as_ptr()
+ );
+ assert_ne!(
+ tables[1].document.row_words(0).unwrap().as_ptr(),
+ tables[2].document.row_words(0).unwrap().as_ptr()
+ );
+ }
+
#[derive(Clone, Copy)]
enum DenyRule {
Suffix(&'static str),
@@ -14472,24 +16936,37 @@ mod tests {
struct SwappingScriptVfs {
inner: DirectoryVfs,
- replacement: Arc<[u8]>,
+ replacement_script: Arc<[u8]>,
+ replacement_formula: Arc<[u8]>,
enabled: std::sync::atomic::AtomicBool,
}
impl SwappingScriptVfs {
- fn use_replacement(&self, path: &NormalizedPath) -> bool {
- self.enabled.load(std::sync::atomic::Ordering::SeqCst)
- && path
- .as_str()
- .eq_ignore_ascii_case("MISSIONS/SCRIPTS/default.scr")
+ fn replacement(&self, path: &NormalizedPath) -> Option<&Arc<[u8]>> {
+ if !self.enabled.load(std::sync::atomic::Ordering::SeqCst) {
+ return None;
+ }
+ if path
+ .as_str()
+ .eq_ignore_ascii_case("MISSIONS/SCRIPTS/default.scr")
+ {
+ Some(&self.replacement_script)
+ } else if path
+ .as_str()
+ .eq_ignore_ascii_case("MISSIONS/SCRIPTS/default.fml")
+ {
+ Some(&self.replacement_formula)
+ } else {
+ None
+ }
}
}
impl Vfs for SwappingScriptVfs {
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
- if self.use_replacement(path) {
+ if let Some(replacement) = self.replacement(path) {
return Ok(VfsMetadata {
- len: self.replacement.len() as u64,
+ len: replacement.len() as u64,
fingerprint: [0x18; 32],
});
}
@@ -14497,8 +16974,8 @@ mod tests {
}
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
- if self.use_replacement(path) {
- return Ok(self.replacement.clone());
+ if let Some(replacement) = self.replacement(path) {
+ return Ok(replacement.clone());
}
self.inner.read(path)
}
diff --git a/crates/fparkan-runtime/src/mesh_collision.rs b/crates/fparkan-runtime/src/mesh_collision.rs
new file mode 100644
index 0000000..6e9ae2d
--- /dev/null
+++ b/crates/fparkan-runtime/src/mesh_collision.rs
@@ -0,0 +1,1255 @@
+//! World-space segment queries over selected building MSH nodes and slots.
+
+use fparkan_msh::{
+ node38_metadata, query_slot_segment, ModelAsset, MshError, NodeId, SegmentQueryMasks, SlotId,
+};
+use fparkan_render::RawCameraTransform;
+
+/// One selected MSH node/slot and its affine transform into world space.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct BuildingMeshNode {
+ /// Model-local node id.
+ pub node: NodeId,
+ /// Currently selected slot for this node.
+ pub slot: SlotId,
+ /// Row-major affine transform from node-local space to world space.
+ pub world_from_node: [f32; 16],
+}
+
+/// One accepted building mesh hit in world space.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct BuildingMeshHit {
+ /// Node that supplied this hit.
+ pub node: NodeId,
+ /// Selected slot that supplied this hit.
+ pub slot: SlotId,
+ /// Hit position in world space.
+ pub position: [f32; 3],
+ /// Squared world distance from the original segment start.
+ pub distance_squared: f32,
+ /// Global Batch20 index.
+ pub batch_index: usize,
+ /// Global type-7 triangle descriptor index.
+ pub triangle_descriptor_index: usize,
+ /// Global Res3 position indices of the hit triangle.
+ pub vertex_indices: [usize; 3],
+}
+
+const MAX_INTERSECTION_VISITS: usize = 200;
+
+/// Queries a world-space segment against all supplied building nodes and slots.
+///
+/// Node flags use the native camera query's `0/0` masks. The initial batch
+/// query excludes `0x0200` while keeping bit `0x0008` visible to the building
+/// retry rule; triangle flags use `0/0x0024`. A nearest bit-8 batch hit restarts
+/// the query from that world-space point toward the original endpoint, just as
+/// the native building camera does. Each helper's Batch20 `local_batch_index`
+/// selects the next Node38 root; that retry visits its eligible descendants
+/// child-before-self and queries only nodes with supplied geometry bindings.
+pub fn query_building_segment_world(
+ model: &ModelAsset,
+ nodes: &[BuildingMeshNode],
+ world_segment: [[f32; 3]; 2],
+) -> Result<Option<BuildingMeshHit>, MshError> {
+ if !world_segment
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ {
+ return Err(MshError::InvalidGeometry(
+ "building query segment contains a non-finite endpoint".to_string(),
+ ));
+ }
+
+ let prepared_nodes = nodes
+ .iter()
+ .map(|node| {
+ prepare_node(*node).map_err(|reason| {
+ MshError::InvalidGeometry(format!(
+ "building node {} has an invalid world transform: {reason}",
+ node.node.0
+ ))
+ })
+ })
+ .collect::<Result<Vec<_>, _>>()?;
+
+ let original_start = world_segment[0];
+ let original_end = world_segment[1];
+ let mut segment_start = original_start;
+ let mut linked_root = None;
+ let mut intersection_visits = 0;
+ loop {
+ let mut nearest = None;
+ let query_nodes = if let Some(root) = linked_root {
+ let order = linked_node_postorder(model, root)?;
+ let mut ordered = Vec::new();
+ for node_id in order {
+ ordered.extend(
+ prepared_nodes
+ .iter()
+ .filter(|node| node.source.node == node_id),
+ );
+ }
+ ordered
+ } else {
+ prepared_nodes.iter().collect()
+ };
+ for node in query_nodes {
+ let local_segment = [
+ transform_point(node.node_from_world, segment_start)
+ .ok_or_else(|| invalid_transformed_point(node.source.node, "world start"))?,
+ transform_point(node.node_from_world, original_end)
+ .ok_or_else(|| invalid_transformed_point(node.source.node, "world endpoint"))?,
+ ];
+ let hit = query_slot_segment(
+ model,
+ node.source.node,
+ node.source.slot,
+ local_segment,
+ SegmentQueryMasks {
+ node_include: 0,
+ node_exclude: 0,
+ batch_include: 0,
+ // CBuilding clears bit 8 from the camera's 0x208 exclude
+ // mask so it can restart after inspecting the raw batch.
+ batch_exclude: 0x0200,
+ triangle_include: 0,
+ triangle_exclude: 0x0024,
+ },
+ )
+ .map_err(|source| {
+ MshError::InvalidGeometry(format!(
+ "building node {} slot {} segment query failed: {source}",
+ node.source.node.0, node.source.slot.0
+ ))
+ })?;
+ let Some(hit) = hit else {
+ continue;
+ };
+ let position = transform_point(node.source.world_from_node, hit.position)
+ .ok_or_else(|| invalid_transformed_point(node.source.node, "local hit"))?;
+ let offset = subtract3(position, original_start);
+ let distance_squared = dot3(offset, offset);
+ if !distance_squared.is_finite() {
+ return Err(MshError::InvalidGeometry(format!(
+ "building node {} produced a non-finite world hit distance",
+ node.source.node.0
+ )));
+ }
+ let batch_flags = model
+ .batches
+ .get(hit.batch_index)
+ .ok_or_else(|| {
+ MshError::InvalidGeometry(format!(
+ "building node {} query returned missing batch {}",
+ node.source.node.0, hit.batch_index
+ ))
+ })?
+ .batch_flags;
+ let candidate = BuildingMeshHit {
+ node: node.source.node,
+ slot: node.source.slot,
+ position,
+ distance_squared,
+ batch_index: hit.batch_index,
+ triangle_descriptor_index: hit.triangle_descriptor_index,
+ vertex_indices: hit.vertex_indices,
+ };
+ if nearest.is_none_or(|(best, _): (BuildingMeshHit, u16)| {
+ candidate.distance_squared <= best.distance_squared
+ }) {
+ nearest = Some((candidate, batch_flags));
+ }
+ }
+
+ let Some((nearest, batch_flags)) = nearest else {
+ return Ok(None);
+ };
+ intersection_visits += 1;
+ if intersection_visits == MAX_INTERSECTION_VISITS {
+ return Err(MshError::InvalidGeometry(format!(
+ "building query exhausted the native {MAX_INTERSECTION_VISITS}-intersection visit limit"
+ )));
+ }
+ if batch_flags & 0x0008 == 0 {
+ return Ok(Some(nearest));
+ }
+ let batch = model.batches.get(nearest.batch_index).ok_or_else(|| {
+ MshError::InvalidGeometry(format!(
+ "building query returned missing batch {}",
+ nearest.batch_index
+ ))
+ })?;
+ if batch.local_batch_index == u16::MAX {
+ return Err(MshError::InvalidGeometry(format!(
+ "building helper batch {} has the sentinel linked-node key",
+ nearest.batch_index
+ )));
+ }
+ let linked_node = NodeId(u32::from(batch.local_batch_index));
+ if usize::try_from(linked_node.0).unwrap_or(usize::MAX) >= model.node_count {
+ return Err(MshError::InvalidGeometry(format!(
+ "building helper batch {} has linked node {} outside Node38 count {}",
+ nearest.batch_index, linked_node.0, model.node_count
+ )));
+ }
+ segment_start = nearest.position;
+ linked_root = Some(linked_node);
+ }
+}
+
+/// Builds the native child-before-self query order rooted at a portal link.
+///
+/// Node metadata is traversed even when the caller has no selected geometry
+/// binding for that node, because eligible descendants can still carry hits.
+fn linked_node_postorder(model: &ModelAsset, root: NodeId) -> Result<Vec<NodeId>, MshError> {
+ let root_index = usize::try_from(root.0).map_err(|_| {
+ MshError::InvalidGeometry(format!("linked query node {} is not addressable", root.0))
+ })?;
+ if root_index >= model.node_count {
+ return Err(MshError::InvalidGeometry(format!(
+ "linked query node {} is outside Node38 count {}",
+ root.0, model.node_count
+ )));
+ }
+
+ let metadata = (0..model.node_count)
+ .map(|index| {
+ let node = NodeId(u32::try_from(index).map_err(|_| {
+ MshError::InvalidGeometry(format!("Node38 index {index} is not addressable"))
+ })?);
+ node38_metadata(model, node).ok_or_else(|| {
+ MshError::InvalidGeometry(format!(
+ "linked query requires Node38 metadata for node {index}"
+ ))
+ })
+ })
+ .collect::<Result<Vec<_>, _>>()?;
+ for (index, entry) in metadata.iter().enumerate() {
+ if entry.parent_or_link_raw != u16::MAX
+ && usize::from(entry.parent_or_link_raw) >= model.node_count
+ {
+ return Err(MshError::InvalidGeometry(format!(
+ "Node38 parent {} for node {index} is outside node count {}",
+ entry.parent_or_link_raw, model.node_count
+ )));
+ }
+ }
+
+ // Each stack frame marks entry or postorder exit. This keeps traversal
+ // bounded by the model table rather than by the native hierarchy depth.
+ let mut stack = vec![(root_index, false)];
+ let mut active = vec![false; model.node_count];
+ let mut visited = vec![false; model.node_count];
+ let mut ordered = Vec::new();
+ while let Some((index, exiting)) = stack.pop() {
+ if exiting {
+ active[index] = false;
+ visited[index] = true;
+ ordered.push(NodeId(u32::try_from(index).map_err(|_| {
+ MshError::InvalidGeometry(format!("Node38 index {index} is not addressable"))
+ })?));
+ continue;
+ }
+ if active[index] {
+ return Err(MshError::InvalidGeometry(format!(
+ "linked Node38 query contains a cycle at node {index}"
+ )));
+ }
+ if visited[index] {
+ continue;
+ }
+
+ active[index] = true;
+ stack.push((index, true));
+ if metadata[index].header_flags & 0x0010 == 0 {
+ let parent_word = u16::try_from(index).map_err(|_| {
+ MshError::InvalidGeometry(format!(
+ "Node38 index {index} cannot be represented by its parent field"
+ ))
+ })?;
+ for child in (0..model.node_count).rev() {
+ if metadata[child].parent_or_link_raw != u16::MAX
+ && metadata[child].parent_or_link_raw == parent_word
+ && metadata[child].header_flags & 0x0004 == 0
+ {
+ stack.push((child, false));
+ }
+ }
+ }
+ }
+ Ok(ordered)
+}
+
+#[derive(Clone, Copy)]
+struct PreparedNode {
+ source: BuildingMeshNode,
+ node_from_world: [f32; 16],
+}
+
+fn prepare_node(node: BuildingMeshNode) -> Result<PreparedNode, &'static str> {
+ if !node.world_from_node.iter().all(|value| value.is_finite()) {
+ return Err("matrix contains a non-finite value");
+ }
+ if node.world_from_node[12].abs() > f32::EPSILON
+ || node.world_from_node[13].abs() > f32::EPSILON
+ || node.world_from_node[14].abs() > f32::EPSILON
+ || (node.world_from_node[15] - 1.0).abs() > f32::EPSILON
+ {
+ return Err("matrix is not affine");
+ }
+ let node_from_world = RawCameraTransform {
+ words: node.world_from_node.map(f32::to_bits),
+ }
+ .try_inverse_affine_row_major()
+ .ok_or("matrix is singular or its inverse is not finite")?;
+ if !node_from_world.iter().all(|value| value.is_finite()) {
+ return Err("matrix inverse contains a non-finite value");
+ }
+ Ok(PreparedNode {
+ source: node,
+ node_from_world,
+ })
+}
+
+fn invalid_transformed_point(node: NodeId, label: &str) -> MshError {
+ MshError::InvalidGeometry(format!(
+ "building node {} transformed {label} to a non-finite point",
+ node.0
+ ))
+}
+
+fn transform_point(matrix: [f32; 16], point: [f32; 3]) -> Option<[f32; 3]> {
+ let transformed = [
+ matrix[0] * point[0] + matrix[1] * point[1] + matrix[2] * point[2] + matrix[3],
+ matrix[4] * point[0] + matrix[5] * point[1] + matrix[6] * point[2] + matrix[7],
+ matrix[8] * point[0] + matrix[9] * point[1] + matrix[10] * point[2] + matrix[11],
+ ];
+ transformed
+ .iter()
+ .all(|value| value.is_finite())
+ .then_some(transformed)
+}
+
+fn subtract3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [left[0] - right[0], left[1] - right[1], left[2] - right[2]]
+}
+
+fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 {
+ left[0] * right[0] + left[1] * right[1] + left[2] * right[2]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ query_building_segment_world, BuildingMeshHit, BuildingMeshNode, MAX_INTERSECTION_VISITS,
+ };
+ use fparkan_msh::{
+ query_slot_segment, Batch, ModelAsset, MshError, NodeId, SegmentQueryMasks, Slot, SlotId,
+ TriangleDescriptor,
+ };
+
+ const IDENTITY: [f32; 16] = [
+ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
+ ];
+ const CAMERA_MASKS: SegmentQueryMasks = SegmentQueryMasks {
+ node_include: 0,
+ node_exclude: 0,
+ batch_include: 0,
+ batch_exclude: 0x0200,
+ triangle_include: 0,
+ triangle_exclude: 0x0024,
+ };
+
+ fn node(node: u32, world_from_node: [f32; 16]) -> BuildingMeshNode {
+ node_with_slot(node, 0, world_from_node)
+ }
+
+ fn node_with_slot(node: u32, slot: u32, world_from_node: [f32; 16]) -> BuildingMeshNode {
+ BuildingMeshNode {
+ node: NodeId(node),
+ slot: SlotId(slot),
+ world_from_node,
+ }
+ }
+
+ fn model_with_planes(planes: &[(f32, u16)]) -> ModelAsset {
+ model_with_slots(&[planes])
+ }
+
+ fn model_with_slots(planes_per_slot: &[&[(f32, u16)]]) -> ModelAsset {
+ model_with_slots_and_nodes(planes_per_slot, 2)
+ }
+
+ fn model_with_slots_and_nodes(
+ planes_per_slot: &[&[(f32, u16)]],
+ node_count: usize,
+ ) -> ModelAsset {
+ let mut positions = Vec::new();
+ let mut indices = Vec::new();
+ let mut batches = Vec::new();
+ let mut triangle_descriptors = Vec::new();
+ let mut slots = Vec::new();
+ for planes in planes_per_slot {
+ let descriptor_start = triangle_descriptors.len();
+ let batch_start = batches.len();
+ let min_y = planes.iter().map(|(y, _)| *y).fold(0.0, f32::min);
+ let max_y = planes.iter().map(|(y, _)| *y).fold(0.0, f32::max);
+ let center_y = (min_y + max_y) * 0.5;
+ let half_height = (max_y - min_y) * 0.5;
+ for (y, batch_flags) in *planes {
+ let base_vertex = positions.len();
+ let index_start = indices.len();
+ positions.extend([[-20.0, *y, -20.0], [0.0, *y, 20.0], [20.0, *y, -20.0]]);
+ indices.extend([0, 1, 2]);
+ batches.push(Batch {
+ batch_flags: *batch_flags,
+ material_index_hi: 0,
+ material_index: 0,
+ lightmap_index: 0,
+ local_batch_index: u16::MAX,
+ index_count: 3,
+ index_start: u32::try_from(index_start).expect("small synthetic index offset"),
+ vertex_count: 3,
+ base_vertex: u32::try_from(base_vertex).expect("small synthetic vertex offset"),
+ });
+ triangle_descriptors.push(TriangleDescriptor {
+ tri_flags: 0,
+ opaque: [0; 4],
+ normal: [0, 32767, 0],
+ });
+ }
+ slots.push(Slot {
+ tri_start: u16::try_from(descriptor_start)
+ .expect("small synthetic descriptor start"),
+ tri_count: u16::try_from(triangle_descriptors.len() - descriptor_start)
+ .expect("small synthetic descriptor count"),
+ batch_start: u16::try_from(batch_start).expect("small synthetic batch start"),
+ batch_count: u16::try_from(batches.len() - batch_start)
+ .expect("small synthetic batch count"),
+ aabb_min: [-20.0, min_y, -20.0],
+ aabb_max: [20.0, max_y, 20.0],
+ sphere_center: [0.0, center_y, 0.0],
+ sphere_radius: (20.0_f32.powi(2) * 2.0 + half_height.powi(2)).sqrt(),
+ opaque: [0; 5],
+ });
+ }
+
+ ModelAsset {
+ node_stride: 38,
+ node_count,
+ nodes_raw: vec![0; node_count * 38],
+ type16_present: false,
+ bounding_sphere_center: [0.0; 3],
+ bounding_sphere_radius: 0.0,
+ slots,
+ positions,
+ normals: None,
+ uv0: None,
+ uv1: None,
+ indices,
+ batches,
+ triangle_descriptors: Some(triangle_descriptors),
+ node_names: None,
+ animation: None,
+ }
+ }
+
+ fn set_node_metadata(model: &mut ModelAsset, node: usize, flags: u16, parent: u16) {
+ let offset = node * model.node_stride;
+ model.nodes_raw[offset..offset + 2].copy_from_slice(&flags.to_le_bytes());
+ model.nodes_raw[offset + 2..offset + 4].copy_from_slice(&parent.to_le_bytes());
+ }
+
+ fn set_batch_link(model: &mut ModelAsset, batch: usize, node: u16) {
+ model.batches[batch].local_batch_index = node;
+ }
+
+ fn set_root_node_metadata(model: &mut ModelAsset) {
+ for node in 0..model.node_count {
+ set_node_metadata(model, node, 0, u16::MAX);
+ }
+ }
+
+ fn translated(x: f32, y: f32, z: f32) -> [f32; 16] {
+ let mut matrix = IDENTITY;
+ matrix[3] = x;
+ matrix[7] = y;
+ matrix[11] = z;
+ matrix
+ }
+
+ fn hit(result: Option<BuildingMeshHit>) -> BuildingMeshHit {
+ result.expect("segment should hit the synthetic plane")
+ }
+
+ fn assert_point(actual: [f32; 3], expected: [f32; 3]) {
+ for axis in 0..3 {
+ assert!(
+ (actual[axis] - expected[axis]).abs() < 1.0e-4,
+ "{actual:?} != {expected:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn translation_returns_a_world_space_hit() {
+ let result = query_building_segment_world(
+ &model_with_planes(&[(0.0, 0)]),
+ &[node(0, translated(4.0, 5.0, -3.0))],
+ [[4.0, 8.0, -3.0], [4.0, 2.0, -3.0]],
+ )
+ .expect("valid world query");
+
+ let hit = hit(result);
+ assert_point(hit.position, [4.0, 5.0, -3.0]);
+ assert_eq!(hit.distance_squared, 9.0);
+ }
+
+ #[test]
+ fn rotation_transforms_the_world_segment_into_node_space() {
+ let rotated = [
+ 0.0, -1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
+ ];
+ let result = query_building_segment_world(
+ &model_with_planes(&[(0.0, 0)]),
+ &[node(0, rotated)],
+ [[-3.0, 0.0, 0.0], [3.0, 0.0, 0.0]],
+ )
+ .expect("valid world query");
+
+ assert_point(hit(result).position, [0.0, 0.0, 0.0]);
+ }
+
+ #[test]
+ fn nonuniform_scale_preserves_world_hit_coordinates() {
+ let scaled = [
+ 2.0, 0.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.0, 1.0,
+ ];
+ let result = query_building_segment_world(
+ &model_with_planes(&[(0.0, 0)]),
+ &[node(0, scaled)],
+ [[0.0, 2.0, 0.25], [0.0, -2.0, 0.25]],
+ )
+ .expect("valid world query");
+
+ let hit = hit(result);
+ assert_point(hit.position, [0.0, 0.0, 0.25]);
+ assert_eq!(hit.distance_squared, 4.0);
+ }
+
+ #[test]
+ fn mirrored_transform_uses_the_mirrored_world_side() {
+ let mirrored = [
+ 1.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
+ ];
+ let result = query_building_segment_world(
+ &model_with_planes(&[(0.0, 0)]),
+ &[node(0, mirrored)],
+ [[0.0, -3.0, 0.0], [0.0, 3.0, 0.0]],
+ )
+ .expect("valid mirrored world query");
+
+ assert_point(hit(result).position, [0.0, 0.0, 0.0]);
+ }
+
+ #[test]
+ fn nearest_hit_is_ranked_in_world_space_from_original_start() {
+ let mut farther = translated(0.0, 0.0, 0.0);
+ let mut nearer = translated(0.0, 2.0, 0.0);
+ // Make local distances deliberately incomparable across nodes.
+ farther[5] = 2.0;
+ nearer[5] = 0.5;
+ let result = query_building_segment_world(
+ &model_with_planes(&[(0.0, 0)]),
+ &[node(0, farther), node(1, nearer)],
+ [[0.0, 5.0, 0.0], [0.0, -1.0, 0.0]],
+ )
+ .expect("valid world query");
+
+ let hit = hit(result);
+ assert_eq!(hit.node, NodeId(1));
+ assert_point(hit.position, [0.0, 2.0, 0.0]);
+ assert_eq!(hit.distance_squared, 9.0);
+ }
+
+ #[test]
+ fn retries_from_bit8_helper_to_the_farther_wall() {
+ let helper_plane = [(2.0, 0x0008)];
+ let wall_plane = [(0.0, 0)];
+ let mut model = model_with_slots(&[&helper_plane, &wall_plane]);
+ set_root_node_metadata(&mut model);
+ set_batch_link(&mut model, 0, 1);
+ let result = query_building_segment_world(
+ &model,
+ &[
+ node_with_slot(0, 0, IDENTITY),
+ node_with_slot(1, 1, IDENTITY),
+ ],
+ [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
+ )
+ .expect("valid world query");
+
+ let hit = hit(result);
+ assert_eq!(hit.node, NodeId(1));
+ assert_eq!(hit.slot, SlotId(1));
+ assert_eq!(hit.batch_index, 1);
+ assert_point(hit.position, [0.0, 0.0, 0.0]);
+ assert_eq!(hit.distance_squared, 9.0);
+ }
+
+ #[test]
+ fn coplanar_helper_hit_is_requeried_instead_of_filtered_out() {
+ let mut model = model_with_slots(&[&[(0.0, 0)], &[(0.0, 0x0008)]]);
+ set_root_node_metadata(&mut model);
+ set_batch_link(&mut model, 1, 1);
+ let result = query_building_segment_world(
+ &model,
+ &[
+ node_with_slot(1, 0, IDENTITY),
+ node_with_slot(0, 1, IDENTITY),
+ ],
+ [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
+ )
+ .expect("valid world query");
+
+ assert!(result.is_none());
+ }
+
+ #[test]
+ fn world_roundtrip_helper_cannot_be_reselected_after_portal_link() {
+ const START: [f32; 3] = [301.39682, 806.24396, 2.868942];
+ const END: [f32; 3] = [304.3922, 806.4102, 2.868942];
+ let world_from_node = [
+ f32::from_bits(0xbd62_f745),
+ f32::from_bits(0xbf7f_9b4f),
+ 0.0,
+ f32::from_bits(0x4379_43ca),
+ f32::from_bits(0x3f7f_9b4f),
+ f32::from_bits(0xbd62_f745),
+ 0.0,
+ f32::from_bits(0x4448_b667),
+ 0.0,
+ 0.0,
+ 1.0,
+ f32::from_bits(0x4110_2494),
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.0,
+ ];
+ let mut model = model_with_slots_and_nodes(&[&[(-54.239994, 0x0008)]], 4);
+ set_batch_link(&mut model, 0, 2);
+ set_node_metadata(&mut model, 0, 0, u16::MAX);
+ set_node_metadata(&mut model, 1, 0x0104, 0);
+ set_node_metadata(&mut model, 2, 0x0004, 1);
+ set_node_metadata(&mut model, 3, 0x00d1, 2);
+ let source = node(1, world_from_node);
+
+ // Reproduce the old all-node retry: the world-space first hit maps
+ // back to a local start just above this same helper plane.
+ let prepared = super::prepare_node(source).expect("invert actual AutoDemo matrix");
+ let local_start =
+ super::transform_point(prepared.node_from_world, START).expect("finite local start");
+ let local_end =
+ super::transform_point(prepared.node_from_world, END).expect("finite local endpoint");
+ let first_hit = query_slot_segment(
+ &model,
+ NodeId(1),
+ SlotId(0),
+ [local_start, local_end],
+ CAMERA_MASKS,
+ )
+ .expect("valid first helper query")
+ .expect("first helper crossing");
+ let world_hit =
+ super::transform_point(world_from_node, first_hit.position).expect("finite world hit");
+ let repeated_local_start = super::transform_point(prepared.node_from_world, world_hit)
+ .expect("finite round-tripped start");
+ assert!(query_slot_segment(
+ &model,
+ NodeId(1),
+ SlotId(0),
+ [repeated_local_start, local_end],
+ CAMERA_MASKS,
+ )
+ .expect("valid repeated helper query")
+ .is_some());
+
+ let result = query_building_segment_world(&model, &[source], [START, END])
+ .expect("valid portal-linked world query");
+ assert!(result.is_none());
+ }
+
+ #[test]
+ fn linked_subtree_finds_drawable_child_through_nondrawable_parent() {
+ let mut model = model_with_slots_and_nodes(&[&[(2.0, 0x0008)], &[(0.0, 0)]], 4);
+ set_root_node_metadata(&mut model);
+ set_batch_link(&mut model, 0, 2);
+ set_node_metadata(&mut model, 1, 0x0104, 0);
+ set_node_metadata(&mut model, 2, 0x0004, 1);
+ set_node_metadata(&mut model, 3, 0x00d1, 2);
+
+ let result = query_building_segment_world(
+ &model,
+ &[
+ node_with_slot(1, 0, IDENTITY),
+ node_with_slot(3, 1, IDENTITY),
+ ],
+ [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
+ )
+ .expect("valid linked-child query");
+
+ let hit = hit(result);
+ assert_eq!(hit.node, NodeId(3));
+ assert_eq!(hit.slot, SlotId(1));
+ assert_eq!(hit.batch_index, 1);
+ assert_point(hit.position, [0.0, 0.0, 0.0]);
+ }
+
+ #[test]
+ fn linked_query_is_child_before_self_and_self_bit4_does_not_skip_geometry() {
+ let mut model =
+ model_with_slots_and_nodes(&[&[(2.0, 0x0008)], &[(0.0, 0)], &[(0.0, 0)]], 4);
+ set_root_node_metadata(&mut model);
+ set_batch_link(&mut model, 0, 2);
+ set_node_metadata(&mut model, 1, 0x0104, 0);
+ set_node_metadata(&mut model, 2, 0x0004, 1);
+ set_node_metadata(&mut model, 3, 0x00d1, 2);
+
+ let result = query_building_segment_world(
+ &model,
+ &[
+ node_with_slot(1, 0, IDENTITY),
+ node_with_slot(2, 1, IDENTITY),
+ node_with_slot(3, 2, IDENTITY),
+ ],
+ [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
+ )
+ .expect("valid child-first linked query");
+
+ let hit = hit(result);
+ // The equal-distance later candidate replaces the child result.
+ assert_eq!(hit.node, NodeId(2));
+ assert_eq!(hit.slot, SlotId(1));
+ }
+
+ #[test]
+ fn linked_query_traverses_nondrawable_nodes_and_skips_child_bit4_branches() {
+ let mut model = model_with_slots_and_nodes(
+ &[&[(2.0, 0x0008)], &[(0.0, 0)], &[(0.0, 0)], &[(0.0, 0)]],
+ 7,
+ );
+ set_root_node_metadata(&mut model);
+ set_batch_link(&mut model, 0, 2);
+ set_node_metadata(&mut model, 1, 0x0104, 0);
+ set_node_metadata(&mut model, 2, 0x0004, 1);
+ set_node_metadata(&mut model, 3, 0x0000, 2);
+ set_node_metadata(&mut model, 4, 0x0010, 3);
+ set_node_metadata(&mut model, 5, 0x0004, 3);
+ set_node_metadata(&mut model, 6, 0x0004, 2);
+
+ let result = query_building_segment_world(
+ &model,
+ &[
+ node_with_slot(1, 0, IDENTITY),
+ node_with_slot(4, 1, IDENTITY),
+ node_with_slot(5, 2, IDENTITY),
+ node_with_slot(6, 3, IDENTITY),
+ ],
+ [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
+ )
+ .expect("valid linked branch query");
+
+ let hit = hit(result);
+ assert_eq!(hit.node, NodeId(4));
+ assert_eq!(hit.slot, SlotId(1));
+ }
+
+ #[test]
+ fn linked_query_keeps_native_batch_and_triangle_exclude_masks() {
+ let mut model =
+ model_with_slots_and_nodes(&[&[(2.0, 0x0008)], &[(0.0, 0)], &[(0.0, 0x0200)]], 5);
+ set_root_node_metadata(&mut model);
+ set_batch_link(&mut model, 0, 2);
+ set_node_metadata(&mut model, 1, 0x0104, 0);
+ set_node_metadata(&mut model, 2, 0x0004, 1);
+ set_node_metadata(&mut model, 3, 0x00d1, 2);
+ set_node_metadata(&mut model, 4, 0x00d1, 2);
+ model
+ .triangle_descriptors
+ .as_mut()
+ .expect("synthetic type-7 table")[1]
+ .tri_flags = 0x0024;
+
+ let result = query_building_segment_world(
+ &model,
+ &[
+ node_with_slot(1, 0, IDENTITY),
+ node_with_slot(3, 1, IDENTITY),
+ node_with_slot(4, 2, IDENTITY),
+ ],
+ [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
+ )
+ .expect("valid query with native exclusion masks");
+
+ assert!(result.is_none());
+ }
+
+ #[test]
+ fn linked_order_stops_descent_at_header_bit10_but_keeps_self() {
+ let mut model = model_with_slots_and_nodes(&[&[]], 5);
+ set_root_node_metadata(&mut model);
+ set_node_metadata(&mut model, 1, 0, 0);
+ set_node_metadata(&mut model, 2, 0x0014, 1);
+ set_node_metadata(&mut model, 3, 0, 2);
+ set_node_metadata(&mut model, 4, 0, 3);
+
+ let order = super::linked_node_postorder(&model, NodeId(2)).expect("valid linked tree");
+
+ assert_eq!(order, [NodeId(2)]);
+ }
+
+ #[test]
+ fn invalid_portal_link_and_linked_cycle_are_explicit_errors() {
+ let mut invalid_link = model_with_planes(&[(2.0, 0x0008)]);
+ set_root_node_metadata(&mut invalid_link);
+ set_batch_link(&mut invalid_link, 0, 2);
+ assert!(matches!(
+ query_building_segment_world(
+ &invalid_link,
+ &[node(0, IDENTITY)],
+ [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
+ ),
+ Err(MshError::InvalidGeometry(message)) if message.contains("linked")
+ ));
+
+ let mut cycle = model_with_slots_and_nodes(&[&[(2.0, 0x0008)]], 3);
+ set_batch_link(&mut cycle, 0, 1);
+ set_node_metadata(&mut cycle, 0, 0, u16::MAX);
+ set_node_metadata(&mut cycle, 1, 0, 2);
+ set_node_metadata(&mut cycle, 2, 0, 1);
+ assert!(matches!(
+ query_building_segment_world(
+ &cycle,
+ &[node(0, IDENTITY)],
+ [[0.0, 3.0, 0.0], [0.0, -1.0, 0.0]],
+ ),
+ Err(MshError::InvalidGeometry(message)) if message.contains("cycle")
+ ));
+ }
+
+ #[test]
+ fn invalid_non_affine_singular_and_nonfinite_transforms_are_explicit_errors() {
+ let mut non_affine = IDENTITY;
+ non_affine[12] = 0.5;
+ assert!(matches!(
+ query_building_segment_world(
+ &model_with_planes(&[(0.0, 0)]),
+ &[node(0, non_affine)],
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ ),
+ Err(MshError::InvalidGeometry(message)) if message.contains("not affine")
+ ));
+
+ let mut singular = IDENTITY;
+ singular[5] = 0.0;
+ assert!(matches!(
+ query_building_segment_world(
+ &model_with_planes(&[(0.0, 0)]),
+ &[node(0, singular)],
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ ),
+ Err(MshError::InvalidGeometry(message)) if message.contains("singular")
+ ));
+
+ let mut nonfinite = IDENTITY;
+ nonfinite[0] = f32::NAN;
+ assert!(matches!(
+ query_building_segment_world(
+ &model_with_planes(&[(0.0, 0)]),
+ &[node(0, nonfinite)],
+ [[0.0, 1.0, 0.0], [0.0, -1.0, 0.0]],
+ ),
+ Err(MshError::InvalidGeometry(message)) if message.contains("non-finite")
+ ));
+ }
+
+ #[test]
+ fn wall_after_198_helpers_is_accepted_as_the_199th_hit() {
+ let helper_count = MAX_INTERSECTION_VISITS - 2;
+ let mut planes = (0..helper_count)
+ .map(|index| ((helper_count - index) as f32, 0x0008))
+ .collect::<Vec<_>>();
+ planes.push((0.0, 0));
+ let mut model = model_with_planes(&planes);
+ set_root_node_metadata(&mut model);
+ for batch in 0..helper_count {
+ set_batch_link(&mut model, batch, 0);
+ }
+ let result = query_building_segment_world(
+ &model,
+ &[node(0, IDENTITY)],
+ [[0.0, (helper_count + 1) as f32, 0.0], [0.0, -1.0, 0.0]],
+ )
+ .expect("199th intersection is below the native limit");
+
+ let hit = hit(result);
+ assert_eq!(hit.batch_index, helper_count);
+ assert_point(hit.position, [0.0, 0.0, 0.0]);
+ }
+
+ #[test]
+ fn wall_after_199_helpers_errors_as_the_200th_hit() {
+ let helper_count = MAX_INTERSECTION_VISITS - 1;
+ let mut planes = (0..helper_count)
+ .map(|index| ((helper_count - index) as f32, 0x0008))
+ .collect::<Vec<_>>();
+ planes.push((0.0, 0));
+ let mut model = model_with_planes(&planes);
+ set_root_node_metadata(&mut model);
+ for batch in 0..helper_count {
+ set_batch_link(&mut model, batch, 0);
+ }
+ let result = query_building_segment_world(
+ &model,
+ &[node(0, IDENTITY)],
+ [[0.0, (helper_count + 1) as f32, 0.0], [0.0, -1.0, 0.0]],
+ );
+
+ assert!(matches!(
+ result,
+ Err(MshError::InvalidGeometry(message)) if message.contains("200")
+ ));
+ }
+
+ fn compose_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
+ let mut result = [0.0; 16];
+ for row in 0..4 {
+ for column in 0..4 {
+ for inner in 0..4 {
+ result[row * 4 + column] += left[row * 4 + inner] * right[inner * 4 + column];
+ }
+ }
+ }
+ result
+ }
+
+ fn pose_matrix(pose: fparkan_animation::Pose) -> [f32; 16] {
+ let x = rotate_unit_vector([1.0, 0.0, 0.0], pose.rotation);
+ let y = rotate_unit_vector([0.0, 1.0, 0.0], pose.rotation);
+ let z = rotate_unit_vector([0.0, 0.0, 1.0], pose.rotation);
+ [
+ x[0],
+ y[0],
+ z[0],
+ pose.translation[0],
+ x[1],
+ y[1],
+ z[1],
+ pose.translation[1],
+ x[2],
+ y[2],
+ z[2],
+ pose.translation[2],
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.0,
+ ]
+ }
+
+ fn rotate_unit_vector(value: [f32; 3], rotation: [f32; 4]) -> [f32; 3] {
+ let [x, y, z, w] = rotation;
+ let tx = 2.0 * (y * value[2] - z * value[1]);
+ let ty = 2.0 * (z * value[0] - x * value[2]);
+ let tz = 2.0 * (x * value[1] - y * value[0]);
+ [
+ value[0] + w * tx + (y * tz - z * ty),
+ value[1] + w * ty + (z * tx - x * tz),
+ value[2] + w * tz + (x * ty - y * tx),
+ ]
+ }
+
+ fn scale_matrix(scale: [f32; 3]) -> [f32; 16] {
+ [
+ scale[0], 0.0, 0.0, 0.0, 0.0, scale[1], 0.0, 0.0, 0.0, 0.0, scale[2], 0.0, 0.0, 0.0,
+ 0.0, 1.0,
+ ]
+ }
+
+ fn node_times_from_bindings(
+ node_count: usize,
+ bindings: &[fparkan_assets::PreparedControlNodeBinding],
+ ) -> Vec<Option<fparkan_animation::AnimationTime>> {
+ use fparkan_animation::AnimationTime;
+
+ let mut times = vec![Some(AnimationTime(0.0)); node_count];
+ for binding in bindings {
+ if binding.flags & 0x04 != 0 {
+ continue;
+ }
+ assert!(binding.node_index < node_count);
+ let mut blend = if binding.flags & 0x01 != 0 {
+ let mut value = binding.initial_blend;
+ if value > 1.0 {
+ value -= 1.0;
+ }
+ if value < 0.0 {
+ value += 1.0;
+ }
+ value
+ } else {
+ binding.initial_blend.clamp(0.0, 1.0)
+ };
+ if binding.flags & 0x02 != 0 {
+ blend = 1.0 - blend;
+ }
+ times[binding.node_index] = Some(AnimationTime(
+ (1.0 - blend) * binding.frame_a + blend * binding.frame_b,
+ ));
+ }
+ times
+ }
+
+ fn nodes_for_hierarchy(
+ model: &ModelAsset,
+ poses: &[fparkan_animation::Pose],
+ root_from_body: [f32; 16],
+ owner_scale: [f32; 3],
+ ) -> Vec<BuildingMeshNode> {
+ use fparkan_msh::{selected_slot, Group, Lod};
+
+ let scale = scale_matrix(owner_scale);
+ (0..model.node_count)
+ .filter_map(|index| {
+ let node = NodeId(u32::try_from(index).expect("model node id fits u32"));
+ let slot = selected_slot(model, node, Lod(0), Group(0))?;
+ let slot_data = &model.slots[usize::try_from(slot.0).expect("slot id fits usize")];
+ let batch_start = usize::from(slot_data.batch_start);
+ let batch_end = batch_start + usize::from(slot_data.batch_count);
+ if !model.batches[batch_start..batch_end]
+ .iter()
+ .any(|batch| batch.index_count != 0)
+ {
+ return None;
+ }
+ let node_pose = pose_matrix(*poses.get(index).expect("Node38 pose available"));
+ let body_from_node = compose_row_major(scale, node_pose);
+ let world_from_node = compose_row_major(root_from_body, body_from_node);
+ Some(BuildingMeshNode {
+ node,
+ slot,
+ world_from_node,
+ })
+ })
+ .collect()
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn factory_plant_world_query_preserves_native_fixture_hit_under_affine_transforms() {
+ use fparkan_assets::decode_nres_payload;
+ use fparkan_msh::{
+ decode_msh, node38_fallback_hierarchy, selected_slot, validate_msh, Group, Lod, NodeId,
+ SlotId,
+ };
+ use std::{path::PathBuf, sync::Arc};
+
+ const BODY_WALL: [[f32; 3]; 2] = [[-10.5, 22.16, 2.5], [-10.5, 19.16, 2.5]];
+ const BODY_DOOR: [[f32; 3]; 2] = [[0.5, 22.16, 2.5], [0.5, 19.16, 2.5]];
+ const BODY_REVERSED_WALL: [[f32; 3]; 2] = [BODY_WALL[1], BODY_WALL[0]];
+ const BODY_HIT: [f32; 3] = [-10.5, 20.16, 2.5];
+ const ROTATED_SCALED_TRANSLATED: [f32; 16] = [
+ 0.0, -3.0, 0.0, 7.0, 2.0, 0.0, 0.0, -4.0, 0.0, 0.0, 0.5, 2.0, 0.0, 0.0, 0.0, 1.0,
+ ];
+
+ let root = PathBuf::from(
+ std::env::var_os("FPARKAN_CORPUS_PART1_ROOT")
+ .expect("FPARKAN_CORPUS_PART1_ROOT is required for licensed corpus tests"),
+ );
+ let archive_bytes = std::fs::read(root.join("fortif.rlb")).expect("fortif.rlb bytes");
+ let archive = decode_nres_payload(Arc::from(archive_bytes.into_boxed_slice()))
+ .expect("fortif.rlb archive");
+ let entry = archive
+ .entries()
+ .iter()
+ .find(|entry| entry.name_bytes().eq_ignore_ascii_case(b"fr_l_plant.msh"))
+ .expect("plant factory MSH");
+ let nested = decode_nres_payload(Arc::from(
+ archive
+ .payload(entry.id())
+ .expect("MSH payload")
+ .to_vec()
+ .into_boxed_slice(),
+ ))
+ .expect("nested plant MSH");
+ let model = validate_msh(&decode_msh(&nested).expect("plant MSH document"))
+ .expect("plant MSH model");
+ let node_id = NodeId(1);
+ let slot = selected_slot(&model, node_id, Lod(0), Group(0)).expect("Node 1 slot");
+ assert_eq!(slot, SlotId(0));
+
+ let pose = node38_fallback_hierarchy(&model)
+ .expect("Node38 fallback hierarchy")
+ .poses[1];
+ assert_eq!(pose.rotation, [0.0, 0.0, 0.0, 1.0]);
+ let body_from_node = translated(
+ pose.translation[0],
+ pose.translation[1],
+ pose.translation[2],
+ );
+
+ for body_to_world in [IDENTITY, ROTATED_SCALED_TRANSLATED] {
+ let world_from_node = compose_row_major(body_to_world, body_from_node);
+ let node = BuildingMeshNode {
+ node: node_id,
+ slot,
+ world_from_node,
+ };
+ let to_world = |point| {
+ super::transform_point(body_to_world, point).expect("finite fixture transform")
+ };
+ let world_wall = [to_world(BODY_WALL[0]), to_world(BODY_WALL[1])];
+ let expected_hit = to_world(BODY_HIT);
+ let hit = query_building_segment_world(&model, &[node], world_wall)
+ .expect("valid factory wall query")
+ .expect("solid factory wall hit");
+
+ assert_eq!(hit.node, node_id);
+ assert_eq!(hit.slot, slot);
+ assert_eq!(hit.batch_index, 5);
+ assert_eq!(hit.triangle_descriptor_index, 62);
+ for axis in 0..3 {
+ assert!(
+ (hit.position[axis] - expected_hit[axis]).abs() <= 1.0e-3,
+ "world hit {:?} != {:?}",
+ hit.position,
+ expected_hit
+ );
+ }
+ let world_delta = [
+ expected_hit[0] - world_wall[0][0],
+ expected_hit[1] - world_wall[0][1],
+ expected_hit[2] - world_wall[0][2],
+ ];
+ let expected_distance_squared = super::dot3(world_delta, world_delta);
+ assert!(
+ (hit.distance_squared - expected_distance_squared).abs() <= 1.0e-2,
+ "world distance squared {} != {}",
+ hit.distance_squared,
+ expected_distance_squared
+ );
+
+ for body_segment in [BODY_DOOR, BODY_REVERSED_WALL] {
+ let world_segment = [to_world(body_segment[0]), to_world(body_segment[1])];
+ assert!(query_building_segment_world(&model, &[node], world_segment)
+ .expect("valid factory miss query")
+ .is_none());
+ }
+ }
+ }
+
+ #[test]
+ #[ignore = "requires licensed AutoDemo corpus"]
+ fn autodemo_portal_uses_node3_authored_closed_and_open_poses() {
+ use crate::{
+ create, load_mission_static_preview_roots, loaded_mission_assets,
+ loaded_mission_object_drafts, EngineServices, MissionMode, MissionRequest,
+ };
+ use fparkan_animation::AnimationTime;
+ use fparkan_msh::{node38_fallback_hierarchy, node38_sampled_hierarchy_at_times};
+ use fparkan_render::LegacyIron3dEulerTransform;
+ use fparkan_vfs::DirectoryVfs;
+ use std::{num::NonZeroUsize, path::PathBuf, sync::Arc};
+
+ const OBJECT_INDEX: usize = 4;
+ const BODY_DOOR: [[f32; 3]; 2] = [[0.5, 22.16, 2.5], [0.5, 19.16, 2.5]];
+
+ let root = PathBuf::from(
+ std::env::var_os("FPARKAN_CORPUS_PART1_ROOT")
+ .expect("FPARKAN_CORPUS_PART1_ROOT is required for licensed corpus tests"),
+ );
+ let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(&root))))
+ .expect("create SingleMission fixture engine");
+ load_mission_static_preview_roots(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Autodemo.00/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ NonZeroUsize::MAX,
+ )
+ .expect("load SingleMission preview roots");
+ let assets = loaded_mission_assets(&engine).expect("loaded mission assets");
+ let drafts = loaded_mission_object_drafts(&engine).expect("loaded object drafts");
+ let draft = &drafts[OBJECT_INDEX];
+ assert!(draft
+ .resource_name_raw
+ .eq_ignore_ascii_case(b"UNITS\\BUILDS\\PLANT\\wor_l_n1.dat"));
+ let visual_id = *assets
+ .visuals_for_object(OBJECT_INDEX)
+ .first()
+ .expect("PLANT root visual");
+ let visual = assets.visual_by_id(visual_id).expect("PLANT visual");
+ let model_id = visual.model_id.expect("PLANT MSH model");
+ let model = &assets
+ .model_by_id(model_id)
+ .expect("prepared PLANT model")
+ .validated;
+ assert_eq!(model.node_count, 29);
+
+ let controls = assets
+ .object_component_controls
+ .get(OBJECT_INDEX)
+ .and_then(|components| components.first())
+ .and_then(Option::as_ref)
+ .expect("PLANT component CTLD bindings");
+ let node3_binding = controls
+ .bindings
+ .iter()
+ .find(|binding| binding.node_index == 3)
+ .expect("Node3 CTLD binding");
+ assert_eq!(node3_binding.frame_a, 0.0);
+ assert_eq!(node3_binding.frame_b, 1.0);
+ assert_eq!(node3_binding.initial_blend, 0.0);
+
+ let mut node_times = node_times_from_bindings(model.node_count, &controls.bindings);
+ let closed_hierarchy = node38_sampled_hierarchy_at_times(model, &node_times)
+ .or_else(|| node38_fallback_hierarchy(model))
+ .expect("initial Node38 hierarchy");
+ assert_eq!(node_times[3], Some(AnimationTime(0.0)));
+
+ let root_transform = LegacyIron3dEulerTransform {
+ translation: draft.position,
+ orientation_radians: draft.orientation_raw,
+ };
+ let root_from_body = root_transform
+ .try_row_major()
+ .expect("finite PLANT root matrix");
+ let world_door = BODY_DOOR.map(|point| {
+ root_transform
+ .try_transform_scaled_point(point, draft.scale)
+ .expect("finite BODY_DOOR world point")
+ });
+ let closed_nodes =
+ nodes_for_hierarchy(model, &closed_hierarchy.poses, root_from_body, draft.scale);
+ let closed = query_building_segment_world(model, &closed_nodes, world_door)
+ .expect("closed-door building query")
+ .expect("authored time-0 doorway blocker");
+ assert_eq!(closed.node, NodeId(3));
+ assert_eq!(closed.slot, SlotId(25));
+ assert_eq!(closed.batch_index, 100);
+ assert_eq!(closed.triangle_descriptor_index, 855);
+ assert_eq!(model.batches[closed.batch_index].batch_flags, 0x2000);
+
+ node_times[3] = Some(AnimationTime(node3_binding.frame_b));
+ let open_hierarchy = node38_sampled_hierarchy_at_times(model, &node_times)
+ .expect("authored Node3 time-1 hierarchy");
+ let open_nodes =
+ nodes_for_hierarchy(model, &open_hierarchy.poses, root_from_body, draft.scale);
+ assert!(query_building_segment_world(model, &open_nodes, world_door)
+ .expect("time-1 building query")
+ .is_none());
+ }
+}
diff --git a/crates/fparkan-script/src/lib.rs b/crates/fparkan-script/src/lib.rs
index 088b51f..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, handler-specific resolver for the confirmed `Init` path.
+//! narrow resolver for confirmed `Init` handlers and bounded FML expressions.
use fparkan_binary::{checked_allocation_len, Cursor, DecodeError, Limits};
use std::sync::Arc;
@@ -40,7 +40,7 @@ pub enum VarSetType {
Dword,
}
-/// A lossless-in-meaning numeric default from `varset.var`.
+/// A numeric cell value that preserves the declared `varset.var` type.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum VarSetDefault {
/// IEEE-754 bits parsed from a `float` literal.
@@ -151,33 +151,155 @@ pub struct Handler19DwordWrite {
pub value: u32,
}
-/// Result of resolving the first event whose case-sensitive C-string name is
-/// `Init` (bytes through the first NUL) with the supported
-/// `Handler(19)` action subset.
+/// A typed variable write emitted by the recovered first-`Init` subset.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct ScriptInitWrite {
+ /// Compiled varset declaration index.
+ pub index: u32,
+ /// Value in the original cell's numeric representation.
+ pub value: VarSetDefault,
+}
+
+/// Result of resolving the first case-sensitive `Init` event.
#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum Handler19InitResolution {
- /// No event has the case-sensitive C-string name `Init`; the native lookup
- /// makes no call.
+pub enum ScriptInitResolution {
+ /// No event has the case-sensitive C-string name `Init`.
Missing,
- /// The first `Init\0` event was walked in source order.
+ /// The first `Init` event completed within the recovered subset.
Executed {
- /// Zero-based event table index selected by the native name lookup.
+ /// Zero-based selected event index.
event_index: usize,
- /// Opaque event word retained from the selected record.
+ /// Opaque event word retained from the package.
event_word: u32,
- /// Ordered writes emitted by every supported instruction in the event.
- writes: Vec<Handler19DwordWrite>,
+ /// Ordered, typed writes emitted by the event.
+ writes: Vec<ScriptInitWrite>,
},
- /// The first unsupported instruction stopped this restricted resolver.
- /// Any earlier local writes are discarded because the event did not finish.
+ /// The event reached a known but unsupported form; earlier writes are discarded.
Unsupported {
- /// Zero-based event table index selected by the native name lookup.
+ /// Zero-based selected event index.
event_index: usize,
- /// Zero-based instruction index within that event.
+ /// Zero-based unsupported instruction index.
instruction_index: usize,
- /// Unsupported action shape.
- error: Handler19ResolveError,
+ /// Unsupported instruction or expression form.
+ error: ScriptInitResolveError,
+ },
+}
+
+/// A typed failure or unsupported form in the recovered `Init` subset.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum ScriptInitResolveError {
+ /// The selector is outside the recovered `Handler(19)` and formula assignment paths.
+ UnsupportedSelector {
+ /// Observed selector.
+ found: ScriptDispatchSelector,
+ },
+ /// A `Handler(19)` reference or declaration did not match its recovered contract.
+ Handler19(Handler19ResolveError),
+ /// A formula record has an unsupported control word.
+ UnsupportedFormulaControlWord {
+ /// Raw disk word at index 3.
+ found: u32,
+ },
+ /// A formula record requests the separate whole-varset-record copy path.
+ UnsupportedFormulaCopySource {
+ /// Raw disk word at index 2.
+ found: u32,
+ },
+ /// The selected script base did not provide an FML table.
+ MissingFormulaTable,
+ /// The FML ordinal is outside the preserved row table.
+ FormulaIndexOutOfBounds {
+ /// Zero-based FML row index.
+ index: u32,
+ /// Number of preserved FML rows.
+ rows: usize,
},
+ /// The referenced FML row is outside the supported atom-expression subset.
+ UnsupportedFormulaExpression {
+ /// Zero-based FML row index.
+ index: u32,
+ /// Raw expression bytes from the row.
+ expression: Vec<u8>,
+ },
+ /// A DWORD target cannot represent the native truncating qword conversion.
+ DwordConversionOutOfRange {
+ /// Compiled target index.
+ index: u32,
+ /// Evaluated binary32 payload.
+ value_bits: u32,
+ },
+ /// The formula record target is outside the selected varset.
+ VarSetIndexOutOfBounds {
+ /// Compiled target index.
+ index: u32,
+ /// Available declaration count.
+ declarations: usize,
+ },
+}
+
+impl std::fmt::Display for ScriptInitResolveError {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::UnsupportedSelector { found } => {
+ write!(formatter, "unsupported script selector {found:?} in Init")
+ }
+ Self::Handler19(source) => write!(formatter, "Handler(19): {source}"),
+ Self::UnsupportedFormulaControlWord { found } => write!(
+ formatter,
+ "unsupported formula control word 0x{found:08x}; expected 0xffffffff"
+ ),
+ Self::UnsupportedFormulaCopySource { found } => write!(
+ formatter,
+ "unsupported formula source 0x{found:08x}; whole-varset copy is not modeled"
+ ),
+ Self::MissingFormulaTable => write!(formatter, "formula assignment has no FML table"),
+ Self::FormulaIndexOutOfBounds { index, rows } => write!(
+ formatter,
+ "formula index {index} is outside the FML table with {rows} rows"
+ ),
+ Self::UnsupportedFormulaExpression { index, expression } => write!(
+ formatter,
+ "formula {index} expression {:?} is outside the supported atom and binary +/− subset",
+ String::from_utf8_lossy(expression)
+ ),
+ Self::DwordConversionOutOfRange { index, value_bits } => write!(
+ formatter,
+ "formula value 0x{value_bits:08x} cannot be converted to DWORD target {index}"
+ ),
+ Self::VarSetIndexOutOfBounds {
+ index,
+ declarations,
+ } => write!(
+ formatter,
+ "formula target {index} is outside {declarations} varset declarations"
+ ),
+ }
+ }
+}
+
+impl std::error::Error for ScriptInitResolveError {}
+
+enum InitInstructionResolution {
+ Executed(Vec<ScriptInitWrite>),
+ Unsupported(ScriptInitResolveError),
+}
+
+/// Rows loaded from one script base's `.fml` file in native table order.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct FormulaTable {
+ /// Accepted `FUNCTION` rows in their original order, including unsupported expressions.
+ pub rows: Vec<FormulaRow>,
+}
+
+/// One source row accepted by the native FML line loader.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct FormulaRow {
+ /// Name token retained from the first comma-delimited field.
+ pub name_raw: Vec<u8>,
+ /// Expression bytes from the second field; interpretation is lazy.
+ pub expression_raw: Vec<u8>,
+ /// Final field bytes, retained without interpreting their meaning.
+ pub trailing_raw: Vec<u8>,
}
/// Inputs already expressed at the original `Handler(19)` setter ABI boundary.
@@ -205,12 +327,12 @@ pub enum Handler19ResolveError {
},
/// Only action instructions with control word `0xffff_ffff` are supported.
UnsupportedControlWord {
- /// Opaque control word from instruction header word one.
+ /// Opaque control word from disk-order header index 3.
found: u32,
},
/// Only instructions with no result-save reference are supported.
UnsupportedResultSave {
- /// Opaque variable reference from instruction header word two.
+ /// Opaque variable reference from disk-order header index 1.
found: u32,
},
/// One of the three required references was absent.
@@ -583,95 +705,220 @@ impl VarSet {
])
}
- /// Resolves only the first event whose case-sensitive C-string name is
- /// `Init` (bytes through the first NUL), and its supported
- /// `Handler(19)` action instructions in source order.
+ fn resolve_handler19_target(
+ &self,
+ instruction: &ScriptInstruction,
+ position: usize,
+ ) -> Result<u32, Handler19ResolveError> {
+ let index = *instruction
+ .references
+ .get(position)
+ .ok_or(Handler19ResolveError::MissingReference { position })?;
+ match self.declarations.get(index as usize) {
+ Some(VarSetDeclaration {
+ type_name: VarSetType::Dword,
+ ..
+ }) => Ok(index),
+ Some(declaration) => Err(Handler19ResolveError::UnexpectedType {
+ position,
+ found: declaration.type_name,
+ }),
+ None => Err(Handler19ResolveError::VarSetIndexOutOfBounds {
+ index,
+ declarations: self.declarations.len(),
+ }),
+ }
+ }
+
+ /// Resolves the first case-sensitive `Init` event's recovered write subset.
+ ///
+ /// Handler(19) writes use the caller's original setter-boundary words. A
+ /// 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
///
- /// A missing event is a normal no-write result, matching the native event
- /// lookup. Unsupported selector/control/result-save forms stop at their
- /// first instruction and return no partial writes. Malformed handler
- /// references and declaration types remain errors.
- pub fn resolve_handler19_init(
+ /// Returns a typed error for malformed Handler(19) references, invalid
+ /// formula or varset indices, and targets outside the selected varset.
+ pub fn resolve_init(
&self,
package: &ScriptPackage,
input: Handler19InitInput,
- ) -> Result<Handler19InitResolution, Handler19ResolveError> {
+ formulas: Option<&FormulaTable>,
+ ) -> Result<ScriptInitResolution, ScriptInitResolveError> {
let Some((event_index, event)) = package.events.iter().enumerate().find(|(_, event)| {
event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..])
}) else {
- return Ok(Handler19InitResolution::Missing);
+ 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() {
- let instruction_writes =
- match self.resolve_handler19_init_instruction(instruction, input) {
- Ok(writes) => writes,
- Err(error @ Handler19ResolveError::UnsupportedSelector { .. })
- | Err(error @ Handler19ResolveError::UnsupportedControlWord { .. })
- | Err(error @ Handler19ResolveError::UnsupportedResultSave { .. }) => {
- return Ok(Handler19InitResolution::Unsupported {
- event_index,
- instruction_index,
- error,
- });
+ 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;
}
- Err(error) => return Err(error),
- };
- writes.extend(instruction_writes);
+ writes.extend(instruction_writes);
+ }
+ InitInstructionResolution::Unsupported(error) => {
+ return Ok(ScriptInitResolution::Unsupported {
+ event_index,
+ instruction_index,
+ error,
+ });
+ }
+ }
}
- Ok(Handler19InitResolution::Executed {
+ Ok(ScriptInitResolution::Executed {
event_index,
event_word: event.event_word,
writes,
})
}
- fn resolve_handler19_init_instruction(
+ fn resolve_init_instruction(
&self,
instruction: &ScriptInstruction,
input: Handler19InitInput,
- ) -> Result<[Handler19DwordWrite; 3], Handler19ResolveError> {
- let selector = instruction.dispatch_selector();
- if selector != ScriptDispatchSelector::Handler(19) {
- return Err(Handler19ResolveError::UnsupportedSelector { found: selector });
- }
- if instruction.header_words[1] != u32::MAX {
- return Err(Handler19ResolveError::UnsupportedControlWord {
- found: instruction.header_words[1],
- });
- }
- if instruction.header_words[2] != u32::MAX {
- return Err(Handler19ResolveError::UnsupportedResultSave {
- found: instruction.header_words[2],
- });
- }
- self.resolve_handler19(instruction, input)
- }
-
- fn resolve_handler19_target(
- &self,
- instruction: &ScriptInstruction,
- position: usize,
- ) -> Result<u32, Handler19ResolveError> {
- let index = *instruction
- .references
- .get(position)
- .ok_or(Handler19ResolveError::MissingReference { position })?;
- match self.declarations.get(index as usize) {
- Some(VarSetDeclaration {
- type_name: VarSetType::Dword,
- ..
- }) => Ok(index),
- Some(declaration) => Err(Handler19ResolveError::UnexpectedType {
- position,
- found: declaration.type_name,
- }),
- None => Err(Handler19ResolveError::VarSetIndexOutOfBounds {
- index,
- declarations: self.declarations.len(),
- }),
+ formulas: Option<&FormulaTable>,
+ values: &[VarSetDefault],
+ ) -> Result<InitInstructionResolution, ScriptInitResolveError> {
+ match instruction.dispatch_selector() {
+ ScriptDispatchSelector::Handler(19) => {
+ if instruction.header_words[3] != u32::MAX {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::Handler19(
+ Handler19ResolveError::UnsupportedControlWord {
+ found: instruction.header_words[3],
+ },
+ ),
+ ));
+ }
+ if instruction.header_words[1] != u32::MAX {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::Handler19(
+ Handler19ResolveError::UnsupportedResultSave {
+ found: instruction.header_words[1],
+ },
+ ),
+ ));
+ }
+ match self.resolve_handler19(instruction, input) {
+ Ok(writes) => Ok(InitInstructionResolution::Executed(
+ writes
+ .into_iter()
+ .map(|write| ScriptInitWrite {
+ index: write.index,
+ value: VarSetDefault::Dword(write.value),
+ })
+ .collect(),
+ )),
+ Err(error @ Handler19ResolveError::UnsupportedControlWord { .. })
+ | Err(error @ Handler19ResolveError::UnsupportedResultSave { .. }) => {
+ Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::Handler19(error),
+ ))
+ }
+ Err(error) => Err(ScriptInitResolveError::Handler19(error)),
+ }
+ }
+ ScriptDispatchSelector::Sentinel => {
+ if instruction.header_words[3] != u32::MAX {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::UnsupportedFormulaControlWord {
+ found: instruction.header_words[3],
+ },
+ ));
+ }
+ if instruction.header_words[2] != u32::MAX {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::UnsupportedFormulaCopySource {
+ found: instruction.header_words[2],
+ },
+ ));
+ }
+ let Some(formulas) = formulas else {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::MissingFormulaTable,
+ ));
+ };
+ let formula_index = instruction.header_words[6];
+ 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 {
+ return Err(ScriptInitResolveError::VarSetIndexOutOfBounds {
+ index: target_index,
+ 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 => {
+ let Some(value) = formula_dword_value(value) else {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::DwordConversionOutOfRange {
+ index: target_index,
+ value_bits: value.to_bits(),
+ },
+ ));
+ };
+ VarSetDefault::Dword(value)
+ }
+ };
+ Ok(InitInstructionResolution::Executed(vec![ScriptInitWrite {
+ index: target_index,
+ value,
+ }]))
+ }
+ found => Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::UnsupportedSelector { found },
+ )),
}
}
@@ -870,6 +1117,393 @@ impl std::fmt::Display for VarSetError {
impl std::error::Error for VarSetError {}
+/// A failure evaluating one row as a single native numeric literal.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum FormulaLiteralError {
+ /// The referenced ordinal was outside the preserved table.
+ IndexOutOfBounds {
+ /// Zero-based row index.
+ index: u32,
+ /// Number of accepted rows.
+ rows: usize,
+ },
+ /// The row's expression was not one numeric token.
+ UnsupportedExpression {
+ /// Zero-based row index.
+ index: u32,
+ /// Raw expression bytes.
+ expression: Vec<u8>,
+ },
+}
+
+impl std::fmt::Display for FormulaLiteralError {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::IndexOutOfBounds { index, rows } => {
+ write!(formatter, "formula index {index} is outside {rows} rows")
+ }
+ Self::UnsupportedExpression { index, expression } => write!(
+ formatter,
+ "formula {index} expression {:?} is not one numeric literal",
+ String::from_utf8_lossy(expression)
+ ),
+ }
+ }
+}
+
+impl std::error::Error for FormulaLiteralError {}
+
+impl FormulaTable {
+ /// Parses native `FUNCTION` rows without compiling their expressions.
+ ///
+ /// Input is read as text: CRLF becomes LF, Ctrl-Z ends input, and each
+ /// 255-byte read chunk is parsed independently. Accepted rows stay in
+ /// source order so formula references retain their original ordinals.
+ pub fn parse(bytes: &[u8]) -> Self {
+ let mut text = Vec::with_capacity(bytes.len());
+ let mut offset = 0;
+ while let Some(&byte) = bytes.get(offset) {
+ if byte == 0x1a {
+ break;
+ }
+ if byte == b'\r' && bytes.get(offset + 1) == Some(&b'\n') {
+ text.push(b'\n');
+ offset += 2;
+ } else {
+ text.push(byte);
+ offset += 1;
+ }
+ }
+
+ let mut table = Self::default();
+ let mut offset = 0;
+ while offset < text.len() {
+ let limit = (offset + 255).min(text.len());
+ let end = text[offset..limit]
+ .iter()
+ .position(|byte| *byte == b'\n')
+ .map_or(limit, |newline| offset + newline + 1);
+ parse_formula_chunk(&mut table, &text[offset..end]);
+ offset = end;
+ }
+ table
+ }
+
+ /// Evaluates a preserved row only when it is referenced by a formula assignment.
+ ///
+ /// The lexer behavior is bounded to the original number-token rules. The
+ /// converter consumes the longest decimal prefix, converts it to `f64`,
+ /// then rounds to the node's binary32 payload.
+ ///
+ /// # Errors
+ ///
+ /// Returns a typed error for an out-of-range ordinal or an expression
+ /// containing more than one supported numeric token.
+ pub fn evaluate_numeric_literal(&self, index: u32) -> Result<f32, FormulaLiteralError> {
+ let Some(row) = self.rows.get(index as usize) else {
+ return Err(FormulaLiteralError::IndexOutOfBounds {
+ index,
+ rows: self.rows.len(),
+ });
+ };
+ parse_native_numeric_literal(&row.expression_raw).ok_or_else(|| {
+ FormulaLiteralError::UnsupportedExpression {
+ index,
+ expression: row.expression_raw.clone(),
+ }
+ })
+ }
+}
+
+fn parse_formula_chunk(table: &mut FormulaTable, chunk: &[u8]) {
+ let visible_len = chunk
+ .iter()
+ .position(|byte| *byte == 0)
+ .unwrap_or(chunk.len());
+ let visible = &chunk[..visible_len];
+ let first_token = native_first_token(visible);
+ if first_token.iter().take(2).any(|byte| *byte == b'/') {
+ return;
+ }
+ if !visible
+ .windows(b"FUNCTION".len())
+ .any(|part| part == b"FUNCTION")
+ {
+ return;
+ }
+ let Some(open) = visible.iter().position(|byte| *byte == b'(') else {
+ return;
+ };
+ let first_comma = visible.iter().position(|byte| *byte == b',');
+ let second_comma = first_comma.and_then(|first| {
+ visible[first + 1..]
+ .iter()
+ .position(|byte| *byte == b',')
+ .map(|next| first + 1 + next)
+ });
+ let mut buffer = visible.to_vec();
+ buffer.push(0);
+ for byte in &mut buffer[..visible_len] {
+ if *byte == b',' {
+ *byte = 0;
+ }
+ }
+
+ let name_start = open + 1;
+ let expression_start = first_comma.map(|comma| comma + 1);
+ let trailing_start = second_comma.map(|comma| comma + 1);
+ let last_field_start = trailing_start.or(expression_start).unwrap_or(name_start);
+ let last_field_end = c_string_end(&buffer, last_field_start);
+ if let Some(close) = buffer[last_field_start..last_field_end]
+ .iter()
+ .rposition(|byte| *byte == b')')
+ {
+ buffer[last_field_start + close] = 0;
+ }
+
+ let name_field = c_string(&buffer, name_start);
+ if name_field.is_empty() {
+ return;
+ }
+ let expression_field = expression_start.map_or(&[][..], |start| c_string(&buffer, start));
+ let trailing_field = trailing_start.map_or(&[][..], |start| c_string(&buffer, start));
+ let name_token = native_first_token(name_field);
+ let name_raw = if name_token.is_empty() {
+ b"no_name".to_vec()
+ } else {
+ name_token.to_vec()
+ };
+ table.rows.push(FormulaRow {
+ name_raw,
+ expression_raw: expression_field.to_vec(),
+ trailing_raw: trailing_field.to_vec(),
+ });
+}
+
+fn c_string_end(buffer: &[u8], start: usize) -> usize {
+ buffer[start..]
+ .iter()
+ .position(|byte| *byte == 0)
+ .map_or(buffer.len(), |offset| start + offset)
+}
+
+fn c_string(buffer: &[u8], start: usize) -> &[u8] {
+ &buffer[start..c_string_end(buffer, start)]
+}
+
+fn native_first_token(field: &[u8]) -> &[u8] {
+ let start = field
+ .iter()
+ .position(|byte| *byte == 0 || *byte > 32)
+ .unwrap_or(field.len());
+ let end = field[start..]
+ .iter()
+ .position(|byte| *byte == 0 || *byte <= 32)
+ .map_or(field.len(), |offset| start + offset);
+ &field[start..end]
+}
+
+fn parse_native_numeric_literal(expression: &[u8]) -> Option<f32> {
+ let start = expression
+ .iter()
+ .position(|byte| *byte == 0 || *byte > 32)
+ .unwrap_or(expression.len());
+ let expression = &expression[start..];
+ let token_len = native_numeric_token_len(expression)?;
+ if expression[token_len..]
+ .iter()
+ .any(|byte| *byte != 0 && *byte > 32)
+ {
+ return None;
+ }
+ let converted_len = native_decimal_prefix_len(&expression[..token_len])?;
+ let prefix = std::str::from_utf8(&expression[..converted_len]).ok()?;
+ prefix.parse::<f64>().ok().map(|value| value as f32)
+}
+
+fn native_numeric_token_len(expression: &[u8]) -> Option<usize> {
+ if !expression.first().is_some_and(u8::is_ascii_digit) {
+ return None;
+ }
+ let mut index = 0;
+ let mut dot_allowed = true;
+ let mut exponent_sign_allowed = false;
+ while let Some(&byte) = expression.get(index) {
+ if byte.is_ascii_digit() {
+ exponent_sign_allowed = false;
+ index += 1;
+ } else if byte == b'.' && dot_allowed {
+ dot_allowed = false;
+ exponent_sign_allowed = false;
+ index += 1;
+ } else if matches!(byte, b'e' | b'E') {
+ dot_allowed = true;
+ exponent_sign_allowed = true;
+ index += 1;
+ } else if matches!(byte, b'+' | b'-') && exponent_sign_allowed {
+ exponent_sign_allowed = false;
+ index += 1;
+ } else {
+ break;
+ }
+ }
+ Some(index)
+}
+
+fn native_decimal_prefix_len(token: &[u8]) -> Option<usize> {
+ let mut index = 0;
+ while token.get(index).is_some_and(u8::is_ascii_digit) {
+ index += 1;
+ }
+ if token.get(index) == Some(&b'.') {
+ index += 1;
+ while token.get(index).is_some_and(u8::is_ascii_digit) {
+ index += 1;
+ }
+ }
+ if index == 0 {
+ return None;
+ }
+
+ if token
+ .get(index)
+ .is_some_and(|byte| matches!(byte, b'e' | b'E'))
+ {
+ let exponent_start = index;
+ index += 1;
+ if token
+ .get(index)
+ .is_some_and(|byte| matches!(byte, b'+' | b'-'))
+ {
+ index += 1;
+ }
+ let digits_start = index;
+ while token.get(index).is_some_and(u8::is_ascii_digit) {
+ index += 1;
+ }
+ if digits_start == index {
+ index = exponent_start;
+ }
+ }
+ 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);
+ if !value.is_finite() || value < -SIGNED_QWORD_LIMIT || value >= SIGNED_QWORD_LIMIT {
+ return None;
+ }
+ Some(value.trunc() as i64 as u32)
+}
+
/// A compiled `.scr` package.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ScriptPackage {
@@ -1317,15 +1951,58 @@ fn read_instruction(
#[cfg(test)]
mod tests {
use super::{
- decode, decode_with_limits, parse_varset, Handler15ResolveError, Handler15TargetPayload,
- Handler19DwordWrite, Handler19InitInput, Handler19InitResolution, Handler19ResolveError,
+ decode, decode_with_limits, parse_varset, FormulaTable, Handler15ResolveError,
+ Handler15TargetPayload, Handler19DwordWrite, Handler19InitInput, Handler19ResolveError,
Handler2RecordInput, Handler2RecordScheduler, Handler30ResolveError, Handler8Reset,
- Handler8ResolveError, ScriptDispatchSelector, ScriptEvent, ScriptInstruction,
- ScriptPackage, VarSetDefault, VarSetError, VarSetType, VmHostCallbackCommand,
- GOG_HANDLER_COUNT, INSTRUCTION_WORDS,
+ Handler8ResolveError, ScriptDispatchSelector, ScriptEvent, ScriptInitResolution,
+ ScriptInitResolveError, ScriptInitWrite, ScriptInstruction, ScriptPackage, VarSetDefault,
+ VarSetError, VarSetType, VmHostCallbackCommand, GOG_HANDLER_COUNT, INSTRUCTION_WORDS,
};
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,
@@ -1470,6 +2147,73 @@ STRING( 8, ignored, ignored, ignored)\r\n";
}
#[test]
+ fn formula_table_preserves_native_rows_and_field_defaults() {
+ let table = FormulaTable::parse(
+ b"// a comment\r\nFUNCTION(no_fields)\r\nFUNCTION(name,0)\r\nFUNCTION(name,0,)\r\nFUNCTION( , 2, )\r\nFUNCTION(, 3,)\r\nFUNCTION missing_parenthesis,4,\r\nFUNCTION()\r\n/x FUNCTION(ignored,5,)\r\nx/FUNCTION(ignored,6,)\r\nx,FUNCTION(name,0,)\r\nx,FUNCTION(name)\r\n",
+ );
+
+ assert_eq!(table.rows.len(), 6);
+ assert_eq!(table.rows[0].name_raw, b"no_fields");
+ assert!(table.rows[0].expression_raw.is_empty());
+ assert_eq!(table.rows[1].name_raw, b"name");
+ assert_eq!(table.rows[1].expression_raw, b"0");
+ assert!(table.rows[1].trailing_raw.is_empty());
+ assert_eq!(table.rows[2].expression_raw, b"0");
+ assert!(table.rows[2].trailing_raw.is_empty());
+ assert_eq!(table.rows[3].name_raw, b"no_name");
+ assert_eq!(table.rows[3].expression_raw, b" 2");
+ assert_eq!(table.rows[4].name_raw, b"name");
+ assert_eq!(table.rows[4].expression_raw, b"FUNCTION(name");
+ assert_eq!(table.rows[4].trailing_raw, b"0");
+ assert_eq!(table.rows[5].name_raw, b"name");
+ assert_eq!(table.rows[5].expression_raw, b"FUNCTION(name");
+ assert!(table.rows[5].trailing_raw.is_empty());
+ }
+
+ #[test]
+ fn formula_table_chunks_without_reassembling_and_stops_at_text_eof() {
+ let mut source = vec![b' '; 255];
+ source.extend_from_slice(b"FUNCTION(before, 1, )\r\n");
+ source.extend_from_slice(b"FUNCTION(after, 2, )\r\n");
+ source.push(0x1a);
+ source.extend_from_slice(b"FUNCTION(ignored, 3, )\r\n");
+
+ let table = FormulaTable::parse(&source);
+ assert_eq!(table.rows.len(), 2);
+ assert_eq!(table.rows[0].name_raw, b"before");
+ assert_eq!(table.rows[1].name_raw, b"after");
+ }
+
+ #[test]
+ fn formula_table_uses_only_the_nul_terminated_view_of_each_chunk() {
+ let table = FormulaTable::parse(
+ b"FUNCTION(visible, 1, )\0 FUNCTION(hidden, 2, )\nFUNCTION(next, 3, )\n",
+ );
+ assert_eq!(table.rows.len(), 2);
+ assert_eq!(table.rows[0].name_raw, b"visible");
+ assert_eq!(table.rows[1].name_raw, b"next");
+ }
+
+ #[test]
+ fn formula_literal_evaluation_uses_the_native_numeric_prefix() {
+ let table = FormulaTable::parse(
+ b"FUNCTION(scientific,1e-2,)\nFUNCTION(integer_dot,1.,)\nFUNCTION(exp_prefix,1e2.3,)\nFUNCTION(incomplete,1e+,)\nFUNCTION(unary,-1,)\nFUNCTION(operator,1+2,)\n",
+ );
+
+ for (index, expected) in [(0, 0.01_f32), (1, 1.0), (2, 100.0), (3, 1.0)] {
+ assert_eq!(
+ table
+ .evaluate_numeric_literal(index)
+ .expect("native numeric token"),
+ expected
+ );
+ }
+ assert!(table.evaluate_numeric_literal(4).is_err());
+ assert!(table.evaluate_numeric_literal(5).is_err());
+ assert!(table.evaluate_numeric_literal(6).is_err());
+ }
+
+ #[test]
fn handler_thirty_resolves_only_proven_dword_operands() {
let varset = parse_varset(
b"VAR( float, f0, 0.5)\nVAR( DWORD, first, 0x12)\nVAR( DWORD, second, 9)\n",
@@ -1684,11 +2428,11 @@ STRING( 8, ignored, ignored, ignored)\r\n";
}
#[test]
- fn handler_nineteen_init_uses_first_exact_event_and_keeps_instruction_order() {
+ fn script_init_uses_first_exact_event_and_keeps_instruction_order() {
let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
.expect("DWORD targets");
let action = |references: Vec<u32>| ScriptInstruction {
- header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
+ header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX],
references,
};
let package = ScriptPackage {
@@ -1715,34 +2459,53 @@ STRING( 8, ignored, ignored, ignored)\r\n";
};
let result = varset
- .resolve_handler19_init(
+ .resolve_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
+ None,
)
.expect("valid first Init event");
assert_eq!(
result,
- Handler19InitResolution::Executed {
+ ScriptInitResolution::Executed {
event_index: 0,
event_word: 0x1234,
writes: vec![
- Handler19DwordWrite { index: 0, value: 7 },
- Handler19DwordWrite { index: 1, value: 8 },
- Handler19DwordWrite { index: 2, value: 9 },
- Handler19DwordWrite { index: 1, value: 7 },
- Handler19DwordWrite { index: 0, value: 8 },
- Handler19DwordWrite { index: 2, value: 9 },
+ ScriptInitWrite {
+ index: 0,
+ value: VarSetDefault::Dword(7),
+ },
+ ScriptInitWrite {
+ index: 1,
+ value: VarSetDefault::Dword(8),
+ },
+ ScriptInitWrite {
+ index: 2,
+ value: VarSetDefault::Dword(9),
+ },
+ ScriptInitWrite {
+ index: 1,
+ value: VarSetDefault::Dword(7),
+ },
+ ScriptInitWrite {
+ index: 0,
+ value: VarSetDefault::Dword(8),
+ },
+ ScriptInitWrite {
+ index: 2,
+ value: VarSetDefault::Dword(9),
+ },
],
}
);
}
#[test]
- fn handler_nineteen_init_missing_exact_name_has_no_writes() {
+ fn script_init_missing_exact_name_has_no_writes() {
let varset = parse_varset(b"").expect("empty varset");
let package = ScriptPackage {
opcode_handler_count: 73,
@@ -1765,20 +2528,21 @@ STRING( 8, ignored, ignored, ignored)\r\n";
};
assert_eq!(
- varset.resolve_handler19_init(
+ varset.resolve_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
+ None,
),
- Ok(Handler19InitResolution::Missing)
+ Ok(ScriptInitResolution::Missing)
);
}
#[test]
- fn handler_nineteen_init_accepts_empty_first_event_as_executed_with_no_writes() {
+ fn script_init_accepts_empty_first_event_as_executed_with_no_writes() {
let varset = parse_varset(b"").expect("empty varset");
let package = ScriptPackage {
opcode_handler_count: 73,
@@ -1804,15 +2568,16 @@ STRING( 8, ignored, ignored, ignored)\r\n";
};
assert_eq!(
- varset.resolve_handler19_init(
+ varset.resolve_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
+ None,
),
- Ok(Handler19InitResolution::Executed {
+ Ok(ScriptInitResolution::Executed {
event_index: 0,
event_word: 0x1234,
writes: Vec::new(),
@@ -1821,7 +2586,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
}
#[test]
- fn handler_nineteen_init_reports_unsupported_selector_and_discards_prior_writes() {
+ fn script_init_reports_unsupported_selector_and_discards_prior_writes() {
let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
.expect("DWORD targets");
let package = ScriptPackage {
@@ -1832,7 +2597,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
event_word: 0,
instructions: vec![
ScriptInstruction {
- header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
+ header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX],
references: vec![0, 1, 2],
},
ScriptInstruction {
@@ -1840,7 +2605,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
references: Vec::new(),
},
ScriptInstruction {
- header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
+ header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX],
references: vec![0, 1, 2],
},
],
@@ -1850,18 +2615,19 @@ STRING( 8, ignored, ignored, ignored)\r\n";
};
assert_eq!(
- varset.resolve_handler19_init(
+ varset.resolve_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
+ None,
),
- Ok(Handler19InitResolution::Unsupported {
+ Ok(ScriptInitResolution::Unsupported {
event_index: 0,
instruction_index: 1,
- error: Handler19ResolveError::UnsupportedSelector {
+ error: ScriptInitResolveError::UnsupportedSelector {
found: super::ScriptDispatchSelector::Handler(18),
},
})
@@ -1869,7 +2635,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
}
#[test]
- fn handler_nineteen_init_stops_at_unsupported_selector_before_any_write() {
+ fn script_init_stops_at_unsupported_selector_before_any_write() {
let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
.expect("DWORD targets");
let package = ScriptPackage {
@@ -1884,7 +2650,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
references: Vec::new(),
},
ScriptInstruction {
- header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
+ header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX],
references: vec![0, 1, 2],
},
],
@@ -1894,18 +2660,19 @@ STRING( 8, ignored, ignored, ignored)\r\n";
};
assert_eq!(
- varset.resolve_handler19_init(
+ varset.resolve_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
+ None,
),
- Ok(Handler19InitResolution::Unsupported {
+ Ok(ScriptInitResolution::Unsupported {
event_index: 0,
instruction_index: 0,
- error: Handler19ResolveError::UnsupportedSelector {
+ error: ScriptInitResolveError::UnsupportedSelector {
found: super::ScriptDispatchSelector::Handler(18),
},
})
@@ -1913,7 +2680,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
}
#[test]
- fn handler_nineteen_init_types_unsupported_control_and_result_save() {
+ fn script_init_uses_handler_nineteen_control_and_result_fields() {
let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
.expect("DWORD targets");
let input = Handler19InitInput {
@@ -1921,41 +2688,484 @@ STRING( 8, ignored, ignored, ignored)\r\n";
second_x87_word: 8,
third_word: 9,
};
- let instruction = |control_word, result_word| ScriptInstruction {
- header_words: [19, control_word, result_word, 0, 0, 3, 0],
- references: vec![0, 1, 2],
- };
- let resolve = |instruction| {
+ let resolve = |header_words| {
let package = ScriptPackage {
opcode_handler_count: 73,
events: vec![ScriptEvent {
name_len: 4,
name_raw: b"Init\0".to_vec(),
event_word: 0,
- instructions: vec![instruction],
+ instructions: vec![ScriptInstruction {
+ header_words,
+ references: vec![0, 1, 2],
+ }],
}],
trailing_bytes: Vec::new(),
raw: std::sync::Arc::from(&b""[..]),
};
- varset.resolve_handler19_init(&package, input)
+ varset.resolve_init(&package, input, None)
+ };
+
+ let actual = (
+ resolve([19, u32::MAX, u32::MAX, 2, 6, 3, u32::MAX]),
+ resolve([19, 0x1234, u32::MAX, u32::MAX, 6, 3, u32::MAX]),
+ resolve([19, u32::MAX, 0xdecafbad, u32::MAX, 6, 3, u32::MAX]),
+ );
+ assert_eq!(
+ actual,
+ (
+ Ok(ScriptInitResolution::Unsupported {
+ event_index: 0,
+ instruction_index: 0,
+ error: ScriptInitResolveError::Handler19(
+ Handler19ResolveError::UnsupportedControlWord { found: 2 },
+ ),
+ }),
+ Ok(ScriptInitResolution::Unsupported {
+ event_index: 0,
+ instruction_index: 0,
+ error: ScriptInitResolveError::Handler19(
+ Handler19ResolveError::UnsupportedResultSave { found: 0x1234 },
+ ),
+ }),
+ Ok(ScriptInitResolution::Executed {
+ event_index: 0,
+ event_word: 0,
+ writes: vec![
+ ScriptInitWrite {
+ index: 0,
+ value: VarSetDefault::Dword(7),
+ },
+ ScriptInitWrite {
+ index: 1,
+ value: VarSetDefault::Dword(8),
+ },
+ ScriptInitWrite {
+ index: 2,
+ value: VarSetDefault::Dword(9),
+ },
+ ],
+ }),
+ )
+ );
+ }
+
+ #[test]
+ fn script_init_assigns_numeric_literals_using_the_target_type() {
+ let varset = parse_varset(b"VAR(DWORD, integer, 0)\nVAR(float, scalar, 0)\n")
+ .expect("numeric declarations");
+ let package = ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0,
+ instructions: vec![
+ ScriptInstruction {
+ header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 0xdead_beef, 0, 0],
+ references: Vec::new(),
+ },
+ ScriptInstruction {
+ header_words: [u32::MAX, 1, u32::MAX, u32::MAX, 0xdead_beef, 0, 1],
+ references: Vec::new(),
+ },
+ ],
+ }],
+ trailing_bytes: Vec::new(),
+ raw: std::sync::Arc::from(&b""[..]),
+ };
+ let formulas = FormulaTable::parse(
+ b"FUNCTION(integer, 12.75, )\nFUNCTION(scalar, 0.8, )\nFUNCTION(unused, target + 2, )\n",
+ );
+
+ assert_eq!(
+ varset.resolve_init(
+ &package,
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas),
+ ),
+ Ok(ScriptInitResolution::Executed {
+ event_index: 0,
+ event_word: 0,
+ writes: vec![
+ ScriptInitWrite {
+ index: 0,
+ value: VarSetDefault::Dword(12),
+ },
+ ScriptInitWrite {
+ index: 1,
+ value: VarSetDefault::FloatBits(0x3f4c_cccd),
+ },
+ ],
+ })
+ );
+ }
+
+ #[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, target, 0)\nVAR(DWORD, integer, 0)\n")
+ .expect("numeric declaration");
+ let formulas =
+ FormulaTable::parse(b"FUNCTION(unsupported, target * 2, )\nFUNCTION(literal, 7.5, )\n");
+ let action = |formula_index| ScriptInstruction {
+ header_words: [u32::MAX, 1, u32::MAX, u32::MAX, 6, 0, formula_index],
+ references: Vec::new(),
+ };
+ let package = |formula_index| ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0,
+ instructions: vec![action(formula_index)],
+ }],
+ trailing_bytes: Vec::new(),
+ raw: std::sync::Arc::from(&b""[..]),
+ };
+ let input = Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
};
assert_eq!(
- resolve(instruction(0, u32::MAX)),
- Ok(Handler19InitResolution::Unsupported {
+ varset.resolve_init(&package(1), input, Some(&formulas)),
+ Ok(ScriptInitResolution::Executed {
+ event_index: 0,
+ event_word: 0,
+ writes: vec![ScriptInitWrite {
+ index: 1,
+ value: VarSetDefault::Dword(7),
+ }],
+ })
+ );
+ assert_eq!(
+ varset.resolve_init(&package(0), input, Some(&formulas)),
+ Ok(ScriptInitResolution::Unsupported {
event_index: 0,
instruction_index: 0,
- error: Handler19ResolveError::UnsupportedControlWord { found: 0 },
+ error: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 0,
+ expression: b" target * 2".to_vec(),
+ },
})
);
+
+ let mut transactional = package(0);
+ transactional.events[0].instructions.insert(0, action(1));
assert_eq!(
- resolve(instruction(u32::MAX, 2)),
- Ok(Handler19InitResolution::Unsupported {
+ varset.resolve_init(&transactional, input, Some(&formulas)),
+ Ok(ScriptInitResolution::Unsupported {
+ event_index: 0,
+ instruction_index: 1,
+ error: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 0,
+ expression: b" target * 2".to_vec(),
+ },
+ })
+ );
+ }
+
+ #[test]
+ fn script_init_keeps_missing_table_and_bad_indices_typed() {
+ let varset = parse_varset(b"VAR(DWORD, integer, 0)\n").expect("numeric declaration");
+ let instruction = |target, formula_index| ScriptInstruction {
+ header_words: [u32::MAX, target, u32::MAX, u32::MAX, 6, 0, formula_index],
+ references: Vec::new(),
+ };
+ let package = |target, formula_index| ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0,
+ instructions: vec![instruction(target, formula_index)],
+ }],
+ trailing_bytes: Vec::new(),
+ raw: std::sync::Arc::from(&b""[..]),
+ };
+ let input = Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ };
+ let formulas = FormulaTable::parse(b"FUNCTION(literal, 1, )\n");
+
+ assert_eq!(
+ varset.resolve_init(&package(0, 0), input, None),
+ Ok(ScriptInitResolution::Unsupported {
event_index: 0,
instruction_index: 0,
- error: Handler19ResolveError::UnsupportedResultSave { found: 2 },
+ error: ScriptInitResolveError::MissingFormulaTable,
})
);
+ assert_eq!(
+ varset.resolve_init(&package(1, 0), input, Some(&formulas)),
+ Err(ScriptInitResolveError::VarSetIndexOutOfBounds {
+ index: 1,
+ declarations: 1,
+ })
+ );
+ assert_eq!(
+ varset.resolve_init(&package(0, 1), input, Some(&formulas)),
+ Err(ScriptInitResolveError::FormulaIndexOutOfBounds { index: 1, rows: 1 })
+ );
}
#[test]
diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs
index 54946e7..5e17a23 100644
--- a/crates/fparkan-terrain/src/lib.rs
+++ b/crates/fparkan-terrain/src/lib.rs
@@ -409,6 +409,13 @@ impl TerrainWorld {
self.surfaces.len()
}
+ /// Bounds of the indexed surface geometry, excluding unused source vertices.
+ #[must_use]
+ pub fn surface_bounds(&self) -> Option<[[f32; 3]; 2]> {
+ let root = self.surface_bvh.nodes.get(self.surface_bvh.root?)?;
+ Some([root.min, root.max])
+ }
+
/// Returns the validated source mesh retained for renderer integration.
///
/// Runtime collision queries use their own compact triangle representation;
@@ -1215,6 +1222,15 @@ mod tests {
}
#[test]
+ fn surface_bounds_cover_query_geometry_and_ignore_unused_positions() {
+ let mut mesh = synthetic_land_mesh();
+ mesh.positions.push([1000.0, -1000.0, 1000.0]);
+ let world = TerrainWorld::from_land_msh(&mesh).expect("world");
+ assert_eq!(world.surface_bounds(), Some([[0.0; 3], [1.0, 1.0, 2.0]]));
+ assert_eq!(TerrainWorld::default().surface_bounds(), None);
+ }
+
+ #[test]
fn synthetic_surface_height_and_raycast_work() {
let world = TerrainWorld::from_land_msh(&synthetic_land_mesh()).expect("world");