From 1a8e1eaed1de540367bad377e72d228153d8cec6 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 16:25:20 +0400 Subject: feat(animation): recover Node38 fallback hierarchy --- crates/fparkan-animation/src/lib.rs | 39 ++++++++++++++++-- crates/fparkan-inspection/src/lib.rs | 54 ++++++++++++++++++++++++- crates/fparkan-msh/src/lib.rs | 78 +++++++++++++++++++++++++++++++++++- 3 files changed, 166 insertions(+), 5 deletions(-) (limited to 'crates') diff --git a/crates/fparkan-animation/src/lib.rs b/crates/fparkan-animation/src/lib.rs index 36db0e7..34a3fa7 100644 --- a/crates/fparkan-animation/src/lib.rs +++ b/crates/fparkan-animation/src/lib.rs @@ -781,16 +781,29 @@ fn write_f32_bits(out: &mut Vec, value: f32) { } fn compose_pose(parent: Pose, child: Pose) -> Result { + let translated = rotate_point(parent.rotation, child.translation); Ok(Pose { translation: [ - parent.translation[0] + child.translation[0], - parent.translation[1] + child.translation[1], - parent.translation[2] + child.translation[2], + parent.translation[0] + translated[0], + parent.translation[1] + translated[1], + parent.translation[2] + translated[2], ], rotation: normalize_quat(mul_quat(parent.rotation, child.rotation))?, }) } +fn rotate_point(rotation: [f32; 4], point: [f32; 3]) -> [f32; 3] { + let [x, y, z, w] = rotation; + let tx = 2.0 * (y * point[2] - z * point[1]); + let ty = 2.0 * (z * point[0] - x * point[2]); + let tz = 2.0 * (x * point[1] - y * point[0]); + [ + point[0] + w * tx + (y * tz - z * ty), + point[1] + w * ty + (z * tx - x * tz), + point[2] + w * tz + (x * ty - y * tx), + ] +} + fn mul_quat(left: [f32; 4], right: [f32; 4]) -> [f32; 4] { let [lx, ly, lz, lw] = left; let [rx, ry, rz, rw] = right; @@ -1174,6 +1187,26 @@ mod tests { ); } + #[test] + fn hierarchy_rotates_child_translation_by_parent_orientation() { + let half = std::f32::consts::FRAC_1_SQRT_2; + let local = [ + Pose { + translation: [1.0, 0.0, 0.0], + rotation: [0.0, 0.0, half, half], + }, + Pose { + translation: [2.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + ]; + let buffer = evaluate_hierarchy(&[ParentIndex(None), ParentIndex(Some(0))], &local) + .expect("hierarchy"); + assert!((buffer.poses[1].translation[0] - 1.0).abs() < 0.0001); + assert!((buffer.poses[1].translation[1] - 2.0).abs() < 0.0001); + assert!(buffer.poses[1].translation[2].abs() < 0.0001); + } + #[test] fn generated_valid_quaternions_remain_finite() { for index in 1..64_u16 { diff --git a/crates/fparkan-inspection/src/lib.rs b/crates/fparkan-inspection/src/lib.rs index 744670a..7a6a2c7 100644 --- a/crates/fparkan-inspection/src/lib.rs +++ b/crates/fparkan-inspection/src/lib.rs @@ -24,7 +24,9 @@ use fparkan_diagnostics::{ diagnostic, render_human, Diagnostic, DiagnosticCode, DiagnosticContext, Phase, SourceSpan, }; use fparkan_material::{decode_wear, resolve_material, MaterialFallback}; -use fparkan_msh::{decode_msh, validate_msh, ModelAsset}; +use fparkan_msh::{ + decode_msh, node38_metadata, selected_slot, validate_msh, Group, Lod, ModelAsset, NodeId, +}; use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile}; use fparkan_path::{normalize_relative, PathPolicy}; use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository}; @@ -85,6 +87,29 @@ pub struct ModelInspection { pub indices: usize, /// Batch count. pub batches: usize, + /// Original node record stride. + pub node_stride: usize, + /// Standard-node metadata in source order, when the model uses `Node38`. + pub node38: Vec, + /// Number of decoded type-8 animation keys, when available. + pub animation_keys: Option, + /// Declared type-19 animation frame count, when available. + pub animation_frame_count: Option, +} + +/// Inspection view of a standard 38-byte model node. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Node38Inspection { + /// Source node index. + pub index: usize, + /// Opaque source field at byte offset two. + pub parent_or_link_raw: u16, + /// Type-19 frame-map offset, or `0xFFFF`. + pub anim_map_start: u16, + /// Type-8 fallback key index. + pub fallback_key: u16, + /// Whether LOD zero/group zero selects a geometry slot. + pub has_lod0_group0: bool, } /// Texture inspection payload. @@ -312,6 +337,23 @@ pub fn inspect_model_from_root( })?; let msh = decode_msh(&document).map_err(|err| err.to_string())?; let validated = validate_msh(&msh).map_err(|err| err.to_string())?; + let node38 = if validated.node_stride == 38 { + (0..validated.node_count) + .filter_map(|index| { + let node = NodeId(u32::try_from(index).ok()?); + let metadata = node38_metadata(&validated, node)?; + Some(Node38Inspection { + index, + parent_or_link_raw: metadata.parent_or_link_raw, + anim_map_start: metadata.anim_map_start, + fallback_key: metadata.fallback_key, + has_lod0_group0: selected_slot(&validated, node, Lod(0), Group(0)).is_some(), + }) + }) + .collect() + } else { + Vec::new() + }; Ok(ModelInspection { streams: msh.streams().len(), nodes: validated.node_count, @@ -319,6 +361,16 @@ pub fn inspect_model_from_root( positions: validated.positions.len(), indices: validated.indices.len(), batches: validated.batches.len(), + node_stride: validated.node_stride, + node38, + animation_keys: validated + .animation + .as_ref() + .map(|animation| animation.keys.len()), + animation_frame_count: validated + .animation + .as_ref() + .map(|animation| animation.frame_count), }) } diff --git a/crates/fparkan-msh/src/lib.rs b/crates/fparkan-msh/src/lib.rs index 38a8984..dfc4c02 100644 --- a/crates/fparkan-msh/src/lib.rs +++ b/crates/fparkan-msh/src/lib.rs @@ -21,7 +21,7 @@ //! Stage-3 MSH asset contract. use encoding_rs::WINDOWS_1251; -use fparkan_animation::{AnimKey24, Pose}; +use fparkan_animation::{evaluate_hierarchy, AnimKey24, NodePoseBuffer, ParentIndex, Pose}; use fparkan_nres::{EntryMeta, NresDocument, NresError}; /// Node table stream. @@ -450,6 +450,34 @@ pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option { .map(AnimKey24::sampling_pose) } +/// Evaluates the static fallback pose hierarchy of a standard `Node38` model. +/// +/// `0xFFFF` denotes a root. Every non-root source link must name an earlier +/// node, which preserves the producer's parent-before-child ordering. Models +/// that do not meet this established `Node38` contract return `None` so callers +/// can retain an explicitly unhierarchical diagnostic path. +#[must_use] +pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option { + if model.node_stride != 38 || model.node_count == 0 { + return None; + } + let mut parents = Vec::with_capacity(model.node_count); + let mut poses = Vec::with_capacity(model.node_count); + for index in 0..model.node_count { + let node = NodeId(u32::try_from(index).ok()?); + let metadata = node38_metadata(model, node)?; + let parent = if metadata.parent_or_link_raw == u16::MAX { + ParentIndex(None) + } else { + let parent = usize::from(metadata.parent_or_link_raw); + (parent < index).then_some(ParentIndex(Some(metadata.parent_or_link_raw)))? + }; + parents.push(parent); + poses.push(node38_fallback_pose(model, node)?); + } + evaluate_hierarchy(&parents, &poses).ok() +} + /// Returns draw batches for a validated slot. /// /// # Errors @@ -1204,6 +1232,47 @@ mod tests { ); } + #[test] + fn standard_nodes_evaluate_fallback_parent_hierarchy() { + let mut root = node38([u16::MAX; 15]); + root[2..4].copy_from_slice(&u16::MAX.to_le_bytes()); + root[6..8].copy_from_slice(&0_u16.to_le_bytes()); + let mut child = node38([u16::MAX; 15]); + child[2..4].copy_from_slice(&0_u16.to_le_bytes()); + child[6..8].copy_from_slice(&1_u16.to_le_bytes()); + let mut nodes = root; + nodes.extend(child); + let mut keys = Vec::new(); + for (x, y, z, qz, qw) in [ + (1.0, 0.0, 0.0, 23_170_i16, 23_170_i16), + (2.0, 0.0, 0.0, 0_i16, 32_767_i16), + ] { + push_f32(&mut keys, x); + push_f32(&mut keys, y); + push_f32(&mut keys, z); + push_f32(&mut keys, 0.0); + push_u16(&mut keys, 0); + push_u16(&mut keys, 0); + push_u16(&mut keys, qz.cast_unsigned()); + push_u16(&mut keys, qw.cast_unsigned()); + } + let document = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &nodes), + stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_ANIMATION_KEYS, 0, b"Res8", &keys), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + stream(STREAM_ANIMATION_FRAME_MAP, 0, b"Res19", &[]), + ])) + .expect("nested NRes"); + let model = + validate_msh(&decode_msh(&document).expect("msh document")).expect("model asset"); + let hierarchy = node38_fallback_hierarchy(&model).expect("valid node hierarchy"); + assert!((hierarchy.poses[1].translation[0] - 1.0).abs() < 0.001); + assert!((hierarchy.poses[1].translation[1] - 2.0).abs() < 0.001); + } + #[test] fn type2_header_and_slot_tail_framing_are_exact() { let too_small = decode_nested(&build_nres(&[ @@ -1496,6 +1565,13 @@ mod tests { let model = validate_msh(&msh).unwrap_or_else(|err| { panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) }); + if model.node_stride == 38 { + assert!( + node38_fallback_hierarchy(&model).is_some(), + "{corpus} {path:?} {:?}: Node38 hierarchy is not parent-before-child", + entry.name_bytes() + ); + } let preserved = msh.preserved_streams().unwrap_or_else(|err| { panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) }); -- cgit v1.2.3