diff options
Diffstat (limited to 'crates/fparkan-assets')
| -rw-r--r-- | crates/fparkan-assets/src/lib.rs | 2086 | ||||
| -rw-r--r-- | crates/fparkan-assets/src/research.rs | 1151 |
2 files changed, 3211 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)); 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) + )); + } +} |
