diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/fparkan-assets/src/lib.rs | 1865 | ||||
| -rw-r--r-- | crates/fparkan-assets/src/research.rs | 1151 | ||||
| -rw-r--r-- | crates/fparkan-font/Cargo.toml | 12 | ||||
| -rw-r--r-- | crates/fparkan-font/src/lib.rs | 402 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/ambient_op3.rs | 218 | ||||
| -rw-r--r-- | crates/fparkan-msh/src/lib.rs | 924 | ||||
| -rw-r--r-- | crates/fparkan-runtime/Cargo.toml | 3 | ||||
| -rw-r--r-- | crates/fparkan-runtime/src/lib.rs | 2753 | ||||
| -rw-r--r-- | crates/fparkan-runtime/src/mesh_collision.rs | 1255 | ||||
| -rw-r--r-- | crates/fparkan-script/src/lib.rs | 1476 | ||||
| -rw-r--r-- | crates/fparkan-terrain/src/lib.rs | 16 |
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"); |
