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