Parse native CTLD effect rows
Docs Deploy / Build and Deploy MkDocs (push) Successful in 41s

This commit is contained in:
2026-10-11 22:17:45 +04:00
parent 1af4007433
commit 2f0608c259
2 changed files with 493 additions and 1 deletions
+481 -1
View File
@@ -903,6 +903,52 @@ pub struct PreparedComponentControl {
pub source: ResourceKey, pub source: ResourceKey,
/// Enabled source rows in their original order, including duplicate nodes. /// Enabled source rows in their original order, including duplicate nodes.
pub bindings: Vec<PreparedControlNodeBinding>, pub bindings: Vec<PreparedControlNodeBinding>,
/// 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<usize>,
/// Native tag on the first unparsed typed record, when available.
typed_record_tag: Option<u32>,
},
/// Effect rows whose native fixed-row layout is understood.
Parsed(Vec<PreparedControlEffectRow>),
}
/// 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. /// One enabled CTLD row targeting a node in a component model.
@@ -2794,6 +2840,204 @@ fn prepare_component_control_from_resource<R: ResourceRepository>(
decode_component_control_payload(&payload, source, model_node_count, prototype_index).map(Some) 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<F>(
payload: &[u8],
mut cursor: usize,
typed_record_count: usize,
effect_group_count: usize,
invalid: &F,
) -> Result<PreparedControlEffectSection, AssetError>
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<u32> {
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( fn decode_component_control_payload(
payload: &[u8], payload: &[u8],
source: ResourceKey, source: ResourceKey,
@@ -2831,6 +3075,10 @@ fn decode_component_control_payload(
usize::try_from(header[1]).map_err(|_| invalid("section node count overflows usize"))?; usize::try_from(header[1]).map_err(|_| invalid("section node count overflows usize"))?;
let row_count = let row_count =
usize::try_from(header[2]).map_err(|_| invalid("binding count overflows usize"))?; 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 let per_section_node_bytes = SECTION_NODE_BYTES
.checked_mul(per_section_nodes) .checked_mul(per_section_nodes)
.ok_or_else(|| invalid("section node bytes overflow"))?; .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<ResourceName, AssetError> { fn sibling_name(key: &ResourceKey, extension: &str) -> Result<ResourceName, AssetError> {
@@ -3052,6 +3312,173 @@ mod tests {
assert!(decode_component_control_payload(&non_finite, source, 2, 0).is_err()); 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<u8> { fn component_control_payload(rows: &[(u32, f32, f32, f32, u32)]) -> Vec<u8> {
const ROW_START: usize = 5 * 4 + 108; const ROW_START: usize = 5 * 4 + 108;
const ROW_BYTES: usize = 36; const ROW_BYTES: usize = 36;
@@ -3068,6 +3495,59 @@ mod tests {
payload payload
} }
fn append_typed_record(payload: &mut Vec<u8>, 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<u8>, 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] #[test]
fn page_uv_transform_matches_normalized_native_atlas_record() { fn page_uv_transform_matches_normalized_native_atlas_record() {
assert_eq!( assert_eq!(
+12
View File
@@ -414,6 +414,18 @@ sentinel `-1`. Повторные node ID сохраняют исходный п
runtime animation path. Остальные runtime animation path. Остальные
секции CTLD shape records и contact solver не входят в эту интерпретацию. секции 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 ### Terrain queries и movement handoff
Control получает world-interface Terrain и использует поверхность, faces и Control получает world-interface Terrain и использует поверхность, faces и