diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs index 8f45c8e..8d09847 100644 --- a/crates/fparkan-assets/src/lib.rs +++ b/crates/fparkan-assets/src/lib.rs @@ -903,6 +903,52 @@ pub struct PreparedComponentControl { pub source: ResourceKey, /// Enabled source rows in their original order, including duplicate nodes. pub bindings: Vec, + /// Parsed native effect rows in this component's control resource. + pub effect_section: PreparedControlEffectSection, +} + +/// Status of the trailing typed/effect data in a CTLD resource. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PreparedControlEffectSection { + /// The resource advertises no trailing typed or effect data. + Absent, + /// The resource contains a layout this reader does not yet understand. + /// Existing node bindings remain available in this case. + Unsupported { + /// Index of the first unparsed typed record, when one was present. + typed_record_index: Option, + /// Native tag on the first unparsed typed record, when available. + typed_record_tag: Option, + }, + /// Effect rows whose native fixed-row layout is understood. + Parsed(Vec), +} + +/// One native type-4 effect row, with point indices kept relative to the +/// component-local CPT list selected by its control message. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreparedControlEffectRow { + /// Index of the enclosing fixed-size row table. + pub table_index: usize, + /// Index within the enclosing fixed-size row table. + pub row_index: usize, + /// The first three raw condition words consumed by the native checker. + pub raw_condition: [u32; 3], + /// Component-local offsets into the selected point list. + pub point_offsets: [u32; 3], + /// The raw native effect selector, including `u32::MAX` auto-selector. + pub selector: u32, + /// Raw fixed-width archive/resource strings from the row. + pub resource: PreparedControlResourcePair, +} + +/// Fixed-width resource strings carried by a native Control effect row. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreparedControlResourcePair { + /// Raw 32-byte archive field, including any trailing NUL padding. + pub archive: [u8; 32], + /// Raw 32-byte resource field, including any trailing NUL padding. + pub name: [u8; 32], } /// One enabled CTLD row targeting a node in a component model. @@ -2794,6 +2840,204 @@ fn prepare_component_control_from_resource( decode_component_control_payload(&payload, source, model_node_count, prototype_index).map(Some) } +const CONTROL_TYPED_COMMON_PARSER_TAGS: &[u32] = &[1, 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; +const CONTROL_TYPED_VALUE_LIMIT: usize = 65_536; +const CONTROL_TYPED_STRING_LIMIT: usize = 4096; +const CONTROL_EFFECT_GROUP_LIMIT: usize = 4096; +const CONTROL_EFFECT_ROW_LIMIT: usize = 65_536; +const CONTROL_EFFECT_GROUP_HEADER_BYTES: usize = 0x54; +const CONTROL_EFFECT_ROW_BYTES: usize = 100; + +fn decode_component_effect_section( + payload: &[u8], + mut cursor: usize, + typed_record_count: usize, + effect_group_count: usize, + invalid: &F, +) -> Result +where + F: Fn(&str) -> AssetError, +{ + if typed_record_count == 0 && effect_group_count == 0 { + return Ok(if cursor == payload.len() { + PreparedControlEffectSection::Absent + } else { + PreparedControlEffectSection::Unsupported { + typed_record_index: None, + typed_record_tag: None, + } + }); + } + if typed_record_count > CONTROL_TYPED_RECORD_LIMIT { + return Err(invalid("typed record count exceeds the supported limit")); + } + if effect_group_count > CONTROL_EFFECT_GROUP_LIMIT { + return Err(invalid("effect group count exceeds the supported limit")); + } + + for record_index in 0..typed_record_count { + let tag = control_u32_at(payload, cursor) + .ok_or_else(|| invalid("typed record is missing its tag"))?; + if !CONTROL_TYPED_COMMON_PARSER_TAGS.contains(&tag) { + return Ok(PreparedControlEffectSection::Unsupported { + typed_record_index: Some(record_index), + typed_record_tag: Some(tag), + }); + } + + let fixed_end = cursor + .checked_add(CONTROL_TYPED_RECORD_FIXED_BYTES) + .and_then(|offset| offset.checked_add(4)) + .ok_or_else(|| invalid("typed record header offset overflows"))?; + if fixed_end > payload.len() { + return Err(invalid("typed record header extends past the payload")); + } + let value_count = usize::try_from( + control_u32_at(payload, cursor + CONTROL_TYPED_RECORD_COUNT_OFFSET) + .expect("typed record header was checked"), + ) + .map_err(|_| invalid("typed record value count overflows usize"))?; + if value_count > CONTROL_TYPED_VALUE_LIMIT { + return Err(invalid( + "typed record value count exceeds the supported limit", + )); + } + let values_bytes = value_count + .checked_mul(4) + .ok_or_else(|| invalid("typed record value byte length overflows"))?; + let length_offset = cursor + .checked_add(CONTROL_TYPED_RECORD_FIXED_BYTES) + .and_then(|offset| offset.checked_add(values_bytes)) + .ok_or_else(|| invalid("typed record tail offset overflows"))?; + let string_length = control_u32_at(payload, length_offset) + .ok_or_else(|| invalid("typed record length word extends past the payload"))?; + let after_length = length_offset + .checked_add(4) + .ok_or_else(|| invalid("typed record end offset overflows"))?; + + // Native parsing returns immediately for length zero. For UINT32_MAX, + // its signed length+1 wraps to zero, also consuming no string bytes. + let string_bytes = if string_length == 0 || string_length == u32::MAX { + 0 + } else { + usize::try_from(string_length) + .ok() + .and_then(|length| length.checked_add(1)) + .ok_or_else(|| invalid("typed record string length overflows"))? + }; + if string_bytes == 0 { + cursor = after_length; + continue; + } + if string_bytes > CONTROL_TYPED_STRING_LIMIT { + return Err(invalid("typed record string exceeds the supported limit")); + } + let string_end = after_length + .checked_add(string_bytes) + .ok_or_else(|| invalid("typed record string end overflows"))?; + if string_end > payload.len() { + return Err(invalid("typed record string extends past the payload")); + } + if payload[string_end - 1] != 0 { + return Err(invalid("typed record string is missing its trailing NUL")); + } + cursor = string_end; + } + + if effect_group_count == 0 { + return Ok(if cursor == payload.len() { + PreparedControlEffectSection::Parsed(Vec::new()) + } else { + PreparedControlEffectSection::Unsupported { + typed_record_index: None, + typed_record_tag: None, + } + }); + } + + cursor = cursor + .checked_add(CONTROL_EFFECT_GROUP_HEADER_BYTES) + .ok_or_else(|| invalid("effect group header end overflows"))?; + if cursor > payload.len() { + return Err(invalid("effect group header extends past the payload")); + } + + let mut effect_rows = Vec::new(); + for table_index in 0..effect_group_count { + let row_count = usize::try_from( + control_u32_at(payload, cursor) + .ok_or_else(|| invalid("effect table is missing its row count"))?, + ) + .map_err(|_| invalid("effect row count overflows usize"))?; + if row_count > CONTROL_EFFECT_ROW_LIMIT { + return Err(invalid("effect row count exceeds the supported limit")); + } + let rows_start = cursor + .checked_add(4) + .ok_or_else(|| invalid("effect row start overflows"))?; + let row_bytes = row_count + .checked_mul(CONTROL_EFFECT_ROW_BYTES) + .ok_or_else(|| invalid("effect row byte length overflows"))?; + let rows_end = rows_start + .checked_add(row_bytes) + .ok_or_else(|| invalid("effect table end overflows"))?; + if rows_end > payload.len() { + return Err(invalid("complete effect table extends past the payload")); + } + + 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) { + continue; + } + let raw_condition = [ + control_u32_at(row, 0).expect("fixed effect row has condition word 0"), + control_u32_at(row, 4).expect("fixed effect row has condition word 1"), + control_u32_at(row, 8).expect("fixed effect row has condition word 2"), + ]; + let point_offsets = [ + control_u32_at(row, 0x10).expect("fixed effect row has point offset 0"), + control_u32_at(row, 0x14).expect("fixed effect row has point offset 1"), + control_u32_at(row, 0x18).expect("fixed effect row has point offset 2"), + ]; + effect_rows.push(PreparedControlEffectRow { + table_index, + row_index, + raw_condition, + point_offsets, + selector: control_u32_at(row, 0x1C).expect("fixed effect row has selector"), + resource: PreparedControlResourcePair { + archive: row[0x24..0x44] + .try_into() + .expect("fixed archive field is 32 bytes"), + name: row[0x44..0x64] + .try_into() + .expect("fixed resource field is 32 bytes"), + }, + }); + } + cursor = rows_end; + } + + if cursor != payload.len() { + return Ok(PreparedControlEffectSection::Unsupported { + typed_record_index: None, + typed_record_tag: None, + }); + } + Ok(PreparedControlEffectSection::Parsed(effect_rows)) +} + +fn control_u32_at(payload: &[u8], offset: usize) -> Option { + let end = offset.checked_add(4)?; + let bytes = payload.get(offset..end)?; + Some(u32::from_le_bytes(bytes.try_into().ok()?)) +} + fn decode_component_control_payload( payload: &[u8], source: ResourceKey, @@ -2831,6 +3075,10 @@ fn decode_component_control_payload( usize::try_from(header[1]).map_err(|_| invalid("section node count overflows usize"))?; let row_count = usize::try_from(header[2]).map_err(|_| invalid("binding count overflows usize"))?; + let typed_record_count = + usize::try_from(header[3]).map_err(|_| invalid("typed record count overflows usize"))?; + let effect_group_count = + usize::try_from(header[4]).map_err(|_| invalid("effect group count overflows usize"))?; let per_section_node_bytes = SECTION_NODE_BYTES .checked_mul(per_section_nodes) .ok_or_else(|| invalid("section node bytes overflow"))?; @@ -2896,7 +3144,19 @@ fn decode_component_control_payload( }); } - Ok(PreparedComponentControl { source, bindings }) + let effect_section = decode_component_effect_section( + payload, + row_end, + typed_record_count, + effect_group_count, + &invalid, + )?; + + Ok(PreparedComponentControl { + source, + bindings, + effect_section, + }) } fn sibling_name(key: &ResourceKey, extension: &str) -> Result { @@ -3052,6 +3312,173 @@ mod tests { assert!(decode_component_control_payload(&non_finite, source, 2, 0).is_err()); } + #[test] + 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, + }; + 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, 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] + fn component_control_zero_length_typed_record_ends_before_group_header() { + 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(&1_u32.to_le_bytes()); + payload[16..20].copy_from_slice(&1_u32.to_le_bytes()); + append_typed_record(&mut payload, 21, 0); + let group_header_start = payload.len(); + payload.resize(group_header_start + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0); + payload.extend_from_slice(&0_u32.to_le_bytes()); + + let control = decode_component_control_payload(&payload, source, 2, 3) + .expect("zero-length record has no trailing string byte"); + + assert_eq!(control.bindings.len(), 1); + assert_eq!( + control.effect_section, + PreparedControlEffectSection::Parsed(Vec::new()) + ); + } + + #[test] + fn component_control_malformed_recognized_typed_tail_is_rejected() { + 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(&1_u32.to_le_bytes()); + append_typed_record(&mut payload, 21, 2); + + assert!(decode_component_control_payload(&payload, source, 2, 3).is_err()); + } + + #[test] + fn component_control_type4_rows_preserve_fields_and_skip_other_types() { + 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(&1_u32.to_le_bytes()); + payload[16..20].copy_from_slice(&2_u32.to_le_bytes()); + append_typed_record(&mut payload, 21, 0); + payload.resize(payload.len() + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0); + append_effect_table( + &mut payload, + &[ + effect_row( + 3, + [1, 2, 3], + [10, 0, 0], + 8000, + "effects.rlb", + "door_open_02", + ), + effect_row( + 4, + [0, 7, 9], + [104, 105, 106], + 17, + "effects.rlb", + "f_smalllight_y", + ), + ], + ); + append_effect_table( + &mut payload, + &[effect_row( + 4, + [13, 14, 15], + [85, 85, 85], + 26, + "effects.rlb", + "f_gener_ball", + )], + ); + + let control = decode_component_control_payload(&payload, source, 2, 3) + .expect("complete type-4 effect tables"); + + assert_eq!( + control.effect_section, + PreparedControlEffectSection::Parsed(vec![ + PreparedControlEffectRow { + table_index: 0, + row_index: 1, + raw_condition: [0, 7, 9], + point_offsets: [104, 105, 106], + selector: 17, + resource: PreparedControlResourcePair { + archive: fixed_name("effects.rlb"), + name: fixed_name("f_smalllight_y"), + }, + }, + PreparedControlEffectRow { + table_index: 1, + row_index: 0, + raw_condition: [13, 14, 15], + point_offsets: [85, 85, 85], + selector: 26, + resource: PreparedControlResourcePair { + archive: fixed_name("effects.rlb"), + name: fixed_name("f_gener_ball"), + }, + }, + ]) + ); + } + + #[test] + fn component_control_max_length_uses_wrapped_empty_tail() { + 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(&1_u32.to_le_bytes()); + payload[16..20].copy_from_slice(&1_u32.to_le_bytes()); + append_typed_record(&mut payload, 21, u32::MAX); + payload.resize(payload.len() + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0); + append_effect_table(&mut payload, &[]); + + let control = decode_component_control_payload(&payload, source, 2, 3) + .expect("wrapped empty string tail"); + + assert_eq!(control.bindings.len(), 1); + assert_eq!( + control.effect_section, + PreparedControlEffectSection::Parsed(Vec::new()) + ); + } + fn component_control_payload(rows: &[(u32, f32, f32, f32, u32)]) -> Vec { const ROW_START: usize = 5 * 4 + 108; const ROW_BYTES: usize = 36; @@ -3068,6 +3495,59 @@ mod tests { payload } + fn append_typed_record(payload: &mut Vec, tag: u32, string_length: u32) { + let record_start = payload.len(); + payload.resize(record_start + CONTROL_TYPED_RECORD_FIXED_BYTES + 4, 0); + payload[record_start..record_start + 4].copy_from_slice(&tag.to_le_bytes()); + payload[record_start + CONTROL_TYPED_RECORD_COUNT_OFFSET + ..record_start + CONTROL_TYPED_RECORD_COUNT_OFFSET + 4] + .copy_from_slice(&0_u32.to_le_bytes()); + payload[record_start + CONTROL_TYPED_RECORD_FIXED_BYTES + ..record_start + CONTROL_TYPED_RECORD_FIXED_BYTES + 4] + .copy_from_slice(&string_length.to_le_bytes()); + } + + fn append_effect_table(payload: &mut Vec, rows: &[[u8; CONTROL_EFFECT_ROW_BYTES]]) { + payload.extend_from_slice(&u32::try_from(rows.len()).expect("row count").to_le_bytes()); + for row in rows { + payload.extend_from_slice(row); + } + } + + fn effect_row( + kind: u32, + condition: [u32; 3], + point_offsets: [u32; 3], + selector: u32, + archive: &str, + name: &str, + ) -> [u8; CONTROL_EFFECT_ROW_BYTES] { + let mut row = [0_u8; CONTROL_EFFECT_ROW_BYTES]; + for (index, word) in condition.into_iter().enumerate() { + let offset = index * 4; + row[offset..offset + 4].copy_from_slice(&word.to_le_bytes()); + } + row[0x0C..0x10].copy_from_slice(&kind.to_le_bytes()); + for (index, word) in point_offsets.into_iter().enumerate() { + let offset = 0x10 + index * 4; + row[offset..offset + 4].copy_from_slice(&word.to_le_bytes()); + } + row[0x1C..0x20].copy_from_slice(&selector.to_le_bytes()); + write_fixed_name(&mut row[0x24..0x44], archive); + write_fixed_name(&mut row[0x44..0x64], name); + row + } + + fn fixed_name(value: &str) -> [u8; 32] { + let mut result = [0_u8; 32]; + write_fixed_name(&mut result, value); + result + } + + fn write_fixed_name(target: &mut [u8], value: &str) { + target[..value.len()].copy_from_slice(value.as_bytes()); + } + #[test] fn page_uv_transform_matches_normalized_native_atlas_record() { assert_eq!( diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index 8b5b09f..abe7052 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -414,6 +414,18 @@ sentinel `-1`. Повторные node ID сохраняют исходный п runtime animation path. Остальные секции CTLD shape records и contact solver не входят в эту интерпретацию. +Asset reader traverses common typed-record framing and preserves supported +type-4 effect rows as data: table and row indices, raw condition words, selector, +resource pair, and three offsets into the component-local CPT list. An unknown +typed tag or unsupported layout sets the effect section to +`PreparedControlEffectSection::Unsupported`, сохраняя уже разобранные pose +bindings; malformed распознанная структура отклоняет подготовку ресурса. В +read-only GOG corpus pass `fr_l_plant.ctl` дал 38 type-4 rows, включая четыре +`f_smalllight_y` rows с offsets 104–107 и `smoke_fr_02` с offsets 119–121; +`fr_l_gener.ctl` дал 35 rows, включая `f_gener_ball` с offset 85. Это только +разбор ресурсов: разрешение CPT, оценка условий, activation, выполнение FX +opcodes и видимый рендер эффектов здесь не реализованы. + ### Terrain queries и movement handoff Control получает world-interface Terrain и использует поверхность, faces и