From ec62f4f2e2c8d64107d17e54908c1c28a52fa1b0 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sun, 18 Oct 2026 11:42:05 +0400 Subject: fix(assets): parse native CTL tags 2 and 5 --- crates/fparkan-assets/src/lib.rs | 75 ++++++++++++++++++++++++++++------------ docs/tomes/06-behavior.md | 9 +++++ 2 files changed, 62 insertions(+), 22 deletions(-) diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs index 4d7be69..7ef546f 100644 --- a/crates/fparkan-assets/src/lib.rs +++ b/crates/fparkan-assets/src/lib.rs @@ -3599,7 +3599,7 @@ fn control_i32_at(payload: &[u8], offset: usize) -> Option { )) } -const CONTROL_TYPED_COMMON_PARSER_TAGS: &[u32] = &[1, 8, 9, 12, 13, 15, 19, 21, 25, 26]; +const CONTROL_TYPED_COMMON_PARSER_TAGS: &[u32] = &[1, 2, 5, 8, 9, 12, 13, 15, 19, 21, 25, 26]; const CONTROL_TYPED_RECORD_FIXED_BYTES: usize = 0xB0; const CONTROL_TYPED_RECORD_COUNT_OFFSET: usize = 0xAC; const CONTROL_TYPED_RECORD_LIMIT: usize = 4096; @@ -4297,32 +4297,30 @@ mod tests { } #[test] - fn component_control_unproven_effect_tags_preserve_node_bindings() { + fn component_control_unknown_effect_tag_preserves_node_bindings() { let source = ResourceKey { archive: parse_path("controls.rlb").expect("archive"), name: resource_name(b"parts.ctl"), type_id: None, }; - for tag in [2_u32, 5, 0xDEAD_BEEF] { - let mut payload = component_control_payload(&[(1, 0.0, 1.0, 0.25, 0)]); - payload[12..16].copy_from_slice(&1_u32.to_le_bytes()); - payload[16..20].copy_from_slice(&1_u32.to_le_bytes()); - payload.extend_from_slice(&tag.to_le_bytes()); - - let control = decode_component_control_payload(&payload, source.clone(), 2, 3) - .expect("unproven tag is preserved as unsupported"); - - assert_eq!(control.bindings.len(), 1); - assert_eq!(control.bindings[0].node_index, 1); - assert_eq!(control.bindings[0].frame_b, 1.0); - assert_eq!( - control.effect_section, - PreparedControlEffectSection::Unsupported { - typed_record_index: Some(0), - typed_record_tag: Some(tag), - } - ); - } + let mut payload = component_control_payload(&[(1, 0.0, 1.0, 0.25, 0)]); + payload[12..16].copy_from_slice(&1_u32.to_le_bytes()); + payload[16..20].copy_from_slice(&1_u32.to_le_bytes()); + payload.extend_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); + + let control = decode_component_control_payload(&payload, source.clone(), 2, 3) + .expect("unknown tag is retained"); + + assert_eq!(control.bindings.len(), 1); + assert_eq!(control.bindings[0].node_index, 1); + assert_eq!(control.bindings[0].frame_b, 1.0); + assert_eq!( + control.effect_section, + PreparedControlEffectSection::Unsupported { + typed_record_index: Some(0), + typed_record_tag: Some(0xDEAD_BEEF), + } + ); } #[test] @@ -4350,6 +4348,39 @@ mod tests { ); } + #[test] + fn component_control_tags2_and5_share_the_typed_cursor_parser() { + 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, 1.0, 0.25, 0)]); + payload[12..16].copy_from_slice(&3_u32.to_le_bytes()); + append_typed_record(&mut payload, 2, 0); + append_typed_record(&mut payload, 5, 2); + payload.extend_from_slice(b"fx\0"); + append_typed_record(&mut payload, 21, 0); + + let control = decode_component_control_payload(&payload, source, 2, 0) + .expect("tags 2 and 5 share the native typed cursor parser"); + + assert_eq!( + control + .typed_records + .iter() + .map(|record| record.tag) + .collect::>(), + [2, 5, 21], + "tag 21 must begin at the cursor returned after tag 5's string" + ); + assert_eq!(control.typed_records[1].string_bytes, b"fx"); + assert_eq!( + control.effect_section, + PreparedControlEffectSection::Parsed(Vec::new()) + ); + } + #[test] fn component_control_malformed_recognized_typed_tail_is_rejected() { let source = ResourceKey { diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index e663389..ebecddd 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -424,6 +424,15 @@ type 4, в том числе четыре `f_smalllight_y` с offsets 104–107 offsets 119–121. В `fr_l_gener.ctl` найдено 35 строк type 4, включая `f_gener_ball` с offset 85. +Control.dll `+0x8B10` передаёт указатель текущей записи диспетчеру `+0x2D4B0`. +Теги 2 и 5 направляются в общий читатель `+0x21D50`: таблица виртуальных методов +тега 2 (`+0x3CBD8`) вызывает его через обёртку `+0x29650`, а таблица тега 5 +(`+0x3CDF8`) указывает прямо на него в позиции `+0x1C`. Читатель потребляет +заголовок размером `0xB0` байт со счётчиком `u32` по `+0xAC`, затем `count` +значений `u32`, слово длины и, при ненулевой длине, строку с завершающим NUL; +указатель возврата указывает на следующую запись. Поэтому для тегов 2 и 5 подтверждён общий +структурный формат; смысл их полей пока не назначен. + Resolver прототипов хранит явную registry-ссылку на `.cpt` отдельно от ссылки на `.ctl`. Если строки type 4 разобраны и явная ссылка на CPT есть, подготовка читает ровно указанные archive/name; имя CPT не выводится из имени CTL. В CPT -- cgit v1.2.3