diff options
Diffstat (limited to 'crates/fparkan-assets/src/lib.rs')
| -rw-r--r-- | crates/fparkan-assets/src/lib.rs | 2086 |
1 files changed, 2060 insertions, 26 deletions
diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs index 2ee829f..a1ae4ec 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(), } } } @@ -2111,6 +2112,33 @@ impl<R: ResourceRepository> AssetManager<R> { ) -> Result<MissionAssetPlan, AssetError> { build_mission_asset_plan_with_repository(&self.repository, prototypes) } + + /// Reads raw opcodes from the validated initial Control effect group. + /// + /// A complete no-effect section or an out-of-range raw initial-group + /// selector returns `Some(Vec::new())`; unsupported shapes return `None`. + /// + /// # Errors + /// + /// Returns [`AssetError`] when the Control resource is malformed. + pub fn initial_control_effect_opcodes( + &self, + source: &ResourceKey, + model_node_count: usize, + prototype_index: usize, + ) -> Result<Option<Vec<u32>>, AssetError> { + let Some(payload) = read_optional_key(&self.repository, source, Some("component control"))? + else { + return Ok(None); + }; + let (_, opcodes) = decode_component_control_payload_with_opcodes( + &payload, + source.clone(), + model_node_count, + prototype_index, + )?; + Ok(opcodes) + } } /// Produces a count-only plan from a prototype graph. @@ -3866,7 +3894,7 @@ fn control_i32_at(payload: &[u8], offset: usize) -> Option<i32> { } const CONTROL_TYPED_COMMON_PARSER_TAGS: &[u32] = - &[1, 2, 3, 4, 5, 8, 9, 10, 12, 13, 15, 19, 21, 25, 26, 27]; + &[1, 2, 3, 4, 5, 8, 9, 10, 12, 13, 15, 17, 19, 21, 25, 26, 27]; const CONTROL_TYPED_RECORD_FIXED_BYTES: usize = 0xB0; const CONTROL_TYPED_RECORD_COUNT_OFFSET: usize = 0xAC; const CONTROL_TYPED_RECORD_LIMIT: usize = 4096; @@ -3880,6 +3908,7 @@ const CONTROL_EFFECT_ROW_BYTES: usize = 100; struct DecodedComponentEffectSection { section: PreparedControlEffectSection, initial_effect_group: Option<usize>, + initial_group_opcodes: Option<Vec<u32>>, typed_records: Vec<PreparedControlTypedRecord>, } @@ -3904,6 +3933,7 @@ where } }, initial_effect_group: None, + initial_group_opcodes: (cursor == payload.len()).then(Vec::new), typed_records: Vec::new(), }); } @@ -3925,6 +3955,7 @@ where typed_record_tag: Some(tag), }, initial_effect_group: None, + initial_group_opcodes: None, typed_records, }); } @@ -4014,8 +4045,9 @@ where } if effect_group_count == 0 { + let complete = cursor == payload.len(); return Ok(DecodedComponentEffectSection { - section: if cursor == payload.len() { + section: if complete { PreparedControlEffectSection::Parsed(Vec::new()) } else { PreparedControlEffectSection::Unsupported { @@ -4024,6 +4056,7 @@ where } }, initial_effect_group: None, + initial_group_opcodes: complete.then(Vec::new), typed_records, }); } @@ -4042,6 +4075,7 @@ where .filter(|group| *group < effect_group_count); cursor = group_header_end; + let mut initial_group_opcodes = Some(Vec::new()); let mut effect_rows = Vec::new(); for table_index in 0..effect_group_count { let row_count = usize::try_from( @@ -4068,7 +4102,14 @@ where for row_index in 0..row_count { let row_start = rows_start + row_index * CONTROL_EFFECT_ROW_BYTES; let row = &payload[row_start..row_start + CONTROL_EFFECT_ROW_BYTES]; - if control_u32_at(row, 0x0C) != Some(4) { + let opcode = control_u32_at(row, 0x0C).expect("fixed effect row has opcode"); + if initial_effect_group == Some(table_index) { + initial_group_opcodes + .as_mut() + .expect("valid initial group has an opcode list") + .push(opcode); + } + if opcode != 4 { continue; } let raw_condition = [ @@ -4107,12 +4148,14 @@ where typed_record_tag: None, }, initial_effect_group: None, + initial_group_opcodes: None, typed_records, }); } Ok(DecodedComponentEffectSection { section: PreparedControlEffectSection::Parsed(effect_rows), initial_effect_group, + initial_group_opcodes, typed_records, }) } @@ -4129,6 +4172,21 @@ fn decode_component_control_payload( model_node_count: usize, prototype_index: usize, ) -> Result<PreparedComponentControl, AssetError> { + decode_component_control_payload_with_opcodes( + payload, + source, + model_node_count, + prototype_index, + ) + .map(|(control, _)| control) +} + +fn decode_component_control_payload_with_opcodes( + payload: &[u8], + source: ResourceKey, + model_node_count: usize, + prototype_index: usize, +) -> Result<(PreparedComponentControl, Option<Vec<u32>>), AssetError> { const HEADER_BYTES: usize = 5 * 4; const FIXED_SECTION_BYTES: usize = 108; const SECTION_FIXED_BYTES: usize = 156; @@ -4266,18 +4324,22 @@ fn decode_component_control_payload( &invalid, )?; - Ok(PreparedComponentControl { - source, - native_profile_words, - model_node_count, - bindings, - timeline_rows, - typed_records: decoded_effect.typed_records, - node_data: None, - effect_section: decoded_effect.section, - initial_effect_group: decoded_effect.initial_effect_group, - point_list: None, - }) + let initial_group_opcodes = decoded_effect.initial_group_opcodes; + Ok(( + PreparedComponentControl { + source, + native_profile_words, + model_node_count, + bindings, + timeline_rows, + typed_records: decoded_effect.typed_records, + node_data: None, + effect_section: decoded_effect.section, + initial_effect_group: decoded_effect.initial_effect_group, + point_list: None, + }, + initial_group_opcodes, + )) } fn sibling_name(key: &ResourceKey, extension: &str) -> Result<ResourceName, AssetError> { @@ -4376,7 +4438,8 @@ impl Hasher for StableHasher { #[cfg(test)] mod tests { use super::*; - use fparkan_prototype::build_prototype_graph; + use fparkan_binary::{sha256, sha256_hex}; + use fparkan_prototype::{build_prototype_graph, resolve_prototype_all}; use fparkan_resource::{resource_name, CachedResourceRepository}; use fparkan_vfs::{DirectoryVfs, MemoryVfs, Vfs}; use std::path::PathBuf; @@ -4478,6 +4541,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 +6596,132 @@ mod tests { } #[test] + fn component_control_tag17_shares_common_cursor_and_preserves_raw_record_tails() { + let source = ResourceKey { + archive: parse_path("controls.rlb").expect("archive"), + name: resource_name(b"parts.ctl"), + type_id: None, + }; + let mut payload = component_control_payload(&[(1, 0.0, 3.0, 0.5, 0)]); + payload[12..16].copy_from_slice(&2_u32.to_le_bytes()); + payload[16..20].copy_from_slice(&1_u32.to_le_bytes()); + + let tag17_start = payload.len(); + append_typed_record(&mut payload, 17, 2); + payload[tag17_start + 0x14..tag17_start + 0x18] + .copy_from_slice(&0xAABB_CCDDu32.to_le_bytes()); + payload[tag17_start + 0xAC..tag17_start + 0xB0].copy_from_slice(&2_u32.to_le_bytes()); + payload.splice( + tag17_start + 0xB0..tag17_start + 0xB0, + [0x1122_3344u32.to_le_bytes(), 0x5566_7788u32.to_le_bytes()].concat(), + ); + payload.extend_from_slice(b"fx\0"); + + let known_start = payload.len(); + append_typed_record(&mut payload, 4, 3); + payload[known_start + 0x28..known_start + 0x2C] + .copy_from_slice(&0xCAFE_D00Du32.to_le_bytes()); + payload[known_start + 0xAC..known_start + 0xB0].copy_from_slice(&1_u32.to_le_bytes()); + payload.splice( + known_start + 0xB0..known_start + 0xB0, + 0x0102_0304u32.to_le_bytes(), + ); + payload.extend_from_slice(b"ctl\0"); + + let group_header = payload.len(); + payload.resize(group_header + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0); + append_effect_table( + &mut payload, + &[effect_row( + 4, + [4, 5, 6], + [12, 13, 14], + 15, + "effects.rlb", + "first", + )], + ); + + let control = decode_component_control_payload(&payload, source, 2, 0) + .expect("tag 17 and the following known record share the typed cursor"); + + assert_eq!( + control + .typed_records + .iter() + .map(|record| record.tag) + .collect::<Vec<_>>(), + [17, 4] + ); + assert_eq!(control.typed_records[0].fixed_words[5], 0xAABB_CCDD); + assert_eq!(control.typed_records[0].values, [0x1122_3344, 0x5566_7788]); + assert_eq!(control.typed_records[0].string_bytes, b"fx"); + assert_eq!( + control.typed_records[0].gate_provider, + PreparedControlGateProvider::NotApplicable + ); + assert_eq!(control.typed_records[1].fixed_words[10], 0xCAFE_D00D); + assert_eq!(control.typed_records[1].values, [0x0102_0304]); + assert_eq!(control.typed_records[1].string_bytes, b"ctl"); + assert_eq!(control.initial_effect_group, Some(0)); + assert_eq!( + control.effect_section, + PreparedControlEffectSection::Parsed(vec![PreparedControlEffectRow { + table_index: 0, + row_index: 0, + raw_condition: [4, 5, 6], + point_offsets: [12, 13, 14], + selector: 15, + resource: PreparedControlResourcePair { + archive: fixed_name("effects.rlb"), + name: fixed_name("first"), + }, + }]) + ); + } + + #[test] + fn component_control_truncated_tag17_frames_are_rejected() { + let source = ResourceKey { + archive: parse_path("controls.rlb").expect("archive"), + name: resource_name(b"parts.ctl"), + type_id: None, + }; + let mut valid = component_control_payload(&[]); + valid[12..16].copy_from_slice(&1_u32.to_le_bytes()); + let record_start = valid.len(); + append_typed_record(&mut valid, 17, 2); + valid[record_start + 0xAC..record_start + 0xB0].copy_from_slice(&2_u32.to_le_bytes()); + valid.splice( + record_start + 0xB0..record_start + 0xB0, + [0x1234_5678u32.to_le_bytes(), 0x89AB_CDEFu32.to_le_bytes()].concat(), + ); + valid.extend_from_slice(b"xy\0"); + + let assert_invalid = |payload: Vec<u8>, label: &str| { + assert!( + matches!( + decode_component_control_payload(&payload, source.clone(), 0, 0), + Err(AssetError::InvalidPrototype(_)) + ), + "truncated recognized tag 17 {label} must be InvalidPrototype" + ); + }; + + for (end, label) in [ + (record_start + 0xAF, "prefix"), + (record_start + 0xB0 + 4, "values"), + (record_start + 0xB0 + 8, "length word"), + (record_start + 0xB0 + 8 + 4 + 2, "string bytes"), + ] { + assert_invalid(valid[..end].to_vec(), label); + } + let mut bad_nul = valid; + *bad_nul.last_mut().expect("string has trailing NUL") = b'!'; + assert_invalid(bad_nul, "trailing NUL"); + } + + #[test] fn component_control_malformed_recognized_typed_tail_is_rejected() { let source = ResourceKey { archive: parse_path("controls.rlb").expect("archive"), @@ -5206,6 +6865,138 @@ mod tests { } #[test] + fn initial_control_effect_opcodes_keep_raw_selected_rows_in_order() { + let source = ResourceKey { + archive: parse_path("controls.rlb").expect("archive"), + name: resource_name(b"parts.ctl"), + type_id: None, + }; + let mut payload = component_control_payload(&[]); + payload[16..20].copy_from_slice(&1_u32.to_le_bytes()); + let group_header = payload.len(); + payload.resize(group_header + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0); + let opcodes = [1, 4, 4, 12]; + append_effect_table( + &mut payload, + &opcodes.map(|opcode| effect_row(opcode, [0; 3], [0; 3], 0, "", "")), + ); + + let (control, decoded_opcodes) = + decode_component_control_payload_with_opcodes(&payload, source.clone(), 2, 0) + .expect("complete selected group"); + assert_eq!(decoded_opcodes, Some(opcodes.to_vec())); + let PreparedControlEffectSection::Parsed(rows) = control.effect_section else { + panic!("opcode-4 projection should remain parsed"); + }; + assert_eq!( + rows.iter().map(|row| row.row_index).collect::<Vec<_>>(), + [1, 2] + ); + + let repository = repository_with_archives(&[("controls.rlb", &[(b"parts.ctl", &payload)])]); + let manager = AssetManager::new(repository); + assert_eq!( + manager + .initial_control_effect_opcodes(&source, 2, 0) + .expect("public raw-opcode scanner"), + Some(opcodes.to_vec()) + ); + } + + #[test] + fn initial_control_effect_opcodes_distinguish_complete_empty_from_unknown_shapes() { + let source = |name: &[u8]| ResourceKey { + archive: parse_path("controls.rlb").expect("archive"), + name: resource_name(name), + type_id: None, + }; + let empty = component_control_payload(&[]); + + let mut selected_noop = component_control_payload(&[]); + selected_noop[16..20].copy_from_slice(&1_u32.to_le_bytes()); + let noop_header = selected_noop.len(); + selected_noop.resize(noop_header + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0); + append_effect_table(&mut selected_noop, &[]); + + let mut unsupported = component_control_payload(&[]); + unsupported[12..16].copy_from_slice(&1_u32.to_le_bytes()); + append_typed_record(&mut unsupported, 99, 0); + + let mut trailing_data = component_control_payload(&[]); + trailing_data.push(0xAA); + let repository = repository_with_archives(&[( + "controls.rlb", + &[ + (b"empty.ctl", &empty), + (b"noop.ctl", &selected_noop), + (b"unsupported.ctl", &unsupported), + (b"trailing.ctl", &trailing_data), + ], + )]); + let manager = AssetManager::new(repository); + + assert_eq!( + manager + .initial_control_effect_opcodes(&source(b"empty.ctl"), 2, 0) + .expect("complete empty CTLD"), + Some(Vec::new()) + ); + assert_eq!( + manager + .initial_control_effect_opcodes(&source(b"noop.ctl"), 2, 0) + .expect("complete no-op selected group"), + Some(Vec::new()) + ); + for name in [b"unsupported.ctl".as_slice(), b"trailing.ctl".as_slice()] { + assert_eq!( + manager + .initial_control_effect_opcodes(&source(name), 2, 0) + .expect("unsupported but bounded shape"), + None, + "{} remains unavailable", + String::from_utf8_lossy(name) + ); + } + } + + #[test] + fn initial_control_effect_opcodes_treat_out_of_range_initial_indices_as_known_empty() { + let make_payload = |raw_initial_group: u32| { + let mut payload = component_control_payload(&[]); + payload[16..20].copy_from_slice(&1_u32.to_le_bytes()); + let group_header = payload.len(); + payload.resize(group_header + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0); + payload[group_header..group_header + 4] + .copy_from_slice(&raw_initial_group.to_le_bytes()); + append_effect_table(&mut payload, &[effect_row(1, [0; 3], [0; 3], 0, "", "")]); + payload + }; + let sentinel = make_payload(u32::MAX); + let exact_count = make_payload(1); + let repository = repository_with_archives(&[( + "controls.rlb", + &[ + (b"sentinel.ctl", &sentinel), + (b"exact-count.ctl", &exact_count), + ], + )]); + let manager = AssetManager::new(repository); + let sources = + [b"sentinel.ctl".as_slice(), b"exact-count.ctl".as_slice()].map(|name| ResourceKey { + archive: parse_path("controls.rlb").expect("archive"), + name: resource_name(name), + type_id: None, + }); + let results = sources.map(|source| { + manager + .initial_control_effect_opcodes(&source, 2, 0) + .expect("complete CTLD frames") + }); + + assert_eq!(results, [Some(Vec::new()), Some(Vec::new())]); + } + + #[test] fn tag21_gate_provider_comes_from_validated_explicit_ndp() { let source = ResourceKey { archive: parse_path("parts.rlb").expect("CTL archive"), @@ -6678,6 +8469,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 +8647,165 @@ mod tests { #[test] #[ignore = "requires licensed corpus"] + fn tag17_bareobject_controls_preserve_explicit_assets_and_real_effect_rows() { + let root = fixture_root("IS"); + let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root)); + let repository = CachedResourceRepository::new(Arc::clone(&vfs)); + let cases = [ + ( + "bm_m_01", + "bm_m_01.ctl", + "bm_m_a.cpt", + "bm_m_01.ndp", + "e45658e9f3a6b72519eb47bd03e3f41ebde12ab361361297ccb916d0aed5894d", + 0x3F66_6666, + 0x458C_A000, + ["engine_mis_m", "smoke_m_ms_01", "gunf_missile_m"], + ), + ( + "bm_l_01", + "bm_l_01.ctl", + "bm_l_a.cpt", + "bm_l_01.ndp", + "14fadfa27a4d4eab6ab9758f76a89031e085e880b42085219fa117562ab398cc", + 0x3F59_999A, + 0x457A_0000, + ["engine_mis_l", "smoke_l_ms_01", "gunf_missile_l"], + ), + ]; + + for ( + root_name, + control_name, + point_name, + ndp_name, + payload_hash, + word11_bits, + word13_bits, + names, + ) in cases + { + let prototypes = resolve_prototype_all( + &repository, + vfs.as_ref(), + &resource_name(root_name.as_bytes()), + ) + .expect("resolve the original bare-object registry entry"); + assert_eq!(prototypes.len(), 1, "{root_name}"); + let prototype = &prototypes[0]; + let control_source = prototype + .control_resource + .clone() + .expect("bare object has an explicit CTL reference"); + let point_source = prototype + .point_resource + .clone() + .expect("bare object has an explicit CPT reference"); + let ndp_source = prototype + .node_data_resource + .clone() + .expect("bare object has an explicit NDP reference"); + assert_eq!(control_source.archive.as_str(), "weapon.rlb"); + assert_eq!(control_source.name, resource_name(control_name.as_bytes())); + assert_eq!(point_source.archive.as_str(), "weapon.rlb"); + assert_eq!(point_source.name, resource_name(point_name.as_bytes())); + assert_eq!(ndp_source.archive.as_str(), "weapon.rlb"); + assert_eq!(ndp_source.name, resource_name(ndp_name.as_bytes())); + + let archive_id = repository + .open_archive(&control_source.archive) + .expect("open the referenced CTL archive"); + let entry = repository + .find(archive_id, &control_source.name) + .expect("find the referenced CTL") + .expect("referenced CTL exists"); + let payload = repository.read(entry).expect("read the referenced CTL"); + assert_eq!( + sha256_hex(&sha256(payload.as_slice())), + payload_hash, + "{root_name} CTL payload hash" + ); + + let assets = prepare_mission_assets_with_repository( + &repository, + &[0..prototypes.len()], + &prototypes, + ) + .expect("prepare the resolved object through the production asset path"); + assert_eq!(assets.object_component_controls.len(), 1, "{root_name}"); + assert_eq!(assets.object_component_controls[0].len(), 1, "{root_name}"); + let control = assets.object_component_controls[0][0] + .as_ref() + .expect("the resolved component has prepared control"); + assert_eq!(control.source, control_source, "{root_name} CTL source"); + let point_list = control + .point_list + .as_ref() + .expect("the parsed effect rows load the explicitly registered CPT"); + assert_eq!(point_list.source, point_source, "{root_name} CPT source"); + assert!( + point_list.points.len() >= 3, + "{root_name} effect point indices" + ); + let node_data = control + .node_data + .as_ref() + .expect("the explicit NDP remains attached"); + assert_eq!(node_data.source, ndp_source, "{root_name} NDP source"); + assert_eq!(node_data.rows.len(), 1, "{root_name} NDP rows"); + + assert_eq!(control.typed_records.len(), 1, "{root_name}"); + let record = &control.typed_records[0]; + assert_eq!(record.tag, 17, "{root_name}"); + let mut expected_fixed_words = [0_u32; 44]; + expected_fixed_words[0] = 17; + expected_fixed_words[3..=6].fill(u32::MAX); + expected_fixed_words[9] = 0x3F80_0000; + expected_fixed_words[10] = 0x3F80_0000; + expected_fixed_words[11] = word11_bits; + expected_fixed_words[12] = 0x43FA_0000; + expected_fixed_words[13] = word13_bits; + assert_eq!( + record.fixed_words, expected_fixed_words, + "{root_name} raw words" + ); + assert!(record.values.is_empty(), "{root_name} values"); + assert!(record.string_bytes.is_empty(), "{root_name} string"); + + let expected_rows = names + .iter() + .enumerate() + .map(|(row_index, name)| { + let mut raw_name = fixed_name(name); + match row_index { + 0 => raw_name[13..15].copy_from_slice(b"rd"), + 1 => raw_name[14..16].copy_from_slice(b"rd"), + _ => {} + } + PreparedControlEffectRow { + table_index: 0, + row_index, + raw_condition: [0; 3], + point_offsets: [0, 1, 2], + selector: u32::try_from(row_index + 1).expect("small effect selector"), + resource: PreparedControlResourcePair { + archive: fixed_name("effects.rlb"), + name: raw_name, + }, + } + }) + .collect::<Vec<_>>(); + assert_eq!(control.initial_effect_group, Some(0), "{root_name}"); + assert_eq!( + control.effect_section, + PreparedControlEffectSection::Parsed(expected_rows), + "{root_name} parsed type-4 rows" + ); + } + } + + #[test] + #[ignore = "requires licensed corpus"] fn prepares_real_unit_asset_plan() { let root = fixture_root("IS"); let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root)); |
