From 1a8e1eaed1de540367bad377e72d228153d8cec6 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 16:25:20 +0400 Subject: [PATCH] feat(animation): recover Node38 fallback hierarchy --- .../fparkan-render-vulkan/src/asset_mesh.rs | 22 ++--- apps/fparkan-cli/src/main.rs | 81 ++++++++++++++++++- crates/fparkan-animation/src/lib.rs | 39 ++++++++- crates/fparkan-inspection/src/lib.rs | 54 ++++++++++++- crates/fparkan-msh/src/lib.rs | 78 +++++++++++++++++- docs/reference/msh.md | 24 +++--- docs/tomes/05-render.md | 21 ++--- 7 files changed, 281 insertions(+), 38 deletions(-) diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs index 7e7bd26..959b51a 100644 --- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs +++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs @@ -2,7 +2,7 @@ use crate::{VulkanStaticDrawRange, VulkanStaticMesh, VulkanStaticVertex}; use fparkan_msh::{ - draw_batches, node38_fallback_pose, selected_slot, Group, Lod, ModelAsset, NodeId, + draw_batches, node38_fallback_hierarchy, selected_slot, Group, Lod, ModelAsset, NodeId, }; use fparkan_render::{LegacyIron3dEulerTransform, LegacyPipelineState}; use fparkan_terrain_format::LandMeshDocument; @@ -261,11 +261,10 @@ pub fn project_msh_to_static_mesh_in_world_space_with_transform( /// Projects a standard `Node38` model using each selected node's decoded /// fallback pose before its recovered `Iron3D` placement transform. /// -/// This is deliberately not a skeleton evaluator: the source field at -/// `Node38 + 2` is still an unassigned link, so no parent transform is -/// inferred here. Each node's source fallback pose is nevertheless enough to -/// keep independently stored static components at their authored local pose. -/// Models without complete standard-node pose data retain the established +/// The source link at `Node38 + 2` is a parent index: `0xFFFF` marks a root +/// and non-root indices are parent-before-child. The bridge composes the +/// fallback poses through that hierarchy before applying the outer transform. +/// Models without complete standard-node hierarchy data retain the established /// unposed static bridge. /// /// # Errors @@ -289,6 +288,9 @@ pub fn project_msh_to_static_mesh_in_world_space_with_node_fallback_poses( if model.node_stride != 38 || model.node_count == 0 || model.animation.is_none() { return project_msh_to_static_mesh_in_world_space_with_transform(model, transform, scale); } + let Some(hierarchy) = node38_fallback_hierarchy(model) else { + return project_msh_to_static_mesh_in_world_space_with_transform(model, transform, scale); + }; let mut vertices = Vec::new(); let mut indices = Vec::new(); @@ -299,11 +301,7 @@ pub fn project_msh_to_static_mesh_in_world_space_with_node_fallback_poses( let Some(slot) = selected_slot(model, node, Lod(0), Group(0)) else { continue; }; - let Some(pose) = node38_fallback_pose(model, node) else { - return project_msh_to_static_mesh_in_world_space_with_transform( - model, transform, scale, - ); - }; + let pose = hierarchy.poses[node_index]; if !pose .translation .iter() @@ -893,7 +891,9 @@ mod tests { #[test] fn world_space_msh_applies_each_node_fallback_pose_before_root_transform() { let mut nodes = vec![0_u8; 76]; + nodes[2..4].copy_from_slice(&u16::MAX.to_le_bytes()); nodes[8..10].copy_from_slice(&0_u16.to_le_bytes()); + nodes[38 + 2..38 + 4].copy_from_slice(&0_u16.to_le_bytes()); nodes[38 + 6..38 + 8].copy_from_slice(&1_u16.to_le_bytes()); nodes[38 + 8..38 + 10].copy_from_slice(&1_u16.to_le_bytes()); let source = ModelAsset { diff --git a/apps/fparkan-cli/src/main.rs b/apps/fparkan-cli/src/main.rs index e118b2e..fd3f9ef 100644 --- a/apps/fparkan-cli/src/main.rs +++ b/apps/fparkan-cli/src/main.rs @@ -25,7 +25,10 @@ use fparkan_assets::{ decode_mission_payload, extend_graph_report_with_visual_dependencies, TmaProfile, }; use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions}; -use fparkan_inspection::{inspect_archive_file, inspect_land_msh_bounds_file, ArchiveInspection}; +use fparkan_inspection::{ + inspect_archive_file, inspect_land_msh_bounds_file, inspect_model_from_root, ArchiveInspection, + ModelInspection, +}; use fparkan_path::{normalize_relative, PathPolicy}; use fparkan_prototype::build_prototype_graph_report; use fparkan_resource::{resource_name, CachedResourceRepository}; @@ -42,6 +45,7 @@ const PROTOTYPE_INSPECT_SCHEMA: &str = "fparkan-prototype-inspect-v1"; const MISSION_GRAPH_SCHEMA: &str = "fparkan-mission-graph-v1"; const MISSION_INSPECT_SCHEMA: &str = "fparkan-mission-inspect-v1"; const TERRAIN_INSPECT_SCHEMA: &str = "fparkan-terrain-inspect-v1"; +const MODEL_INSPECT_SCHEMA: &str = "fparkan-model-inspect-v1"; #[derive(Serialize)] struct ArchiveInspectOutput<'a> { @@ -123,6 +127,34 @@ struct TerrainInspectOutput { max: [f32; 3], } +#[derive(Serialize)] +struct ModelInspectOutput<'a> { + schema_version: &'static str, + archive: &'a str, + resource: &'a str, + streams: usize, + nodes: usize, + node_stride: usize, + slots: usize, + positions: usize, + indices: usize, + batches: usize, + #[serde(skip_serializing_if = "Option::is_none")] + animation_keys: Option, + #[serde(skip_serializing_if = "Option::is_none")] + animation_frame_count: Option, + node38: Vec, +} + +#[derive(Serialize)] +struct ModelNodeInspectOutput { + index: usize, + parent_or_link_raw: u16, + anim_map_start: u16, + fallback_key: u16, + has_lod0_group0: bool, +} + #[derive(Serialize)] struct GraphFailureOutput { root_index: usize, @@ -191,6 +223,10 @@ fn run(args: &[String]) -> Result<(), String> { let rest = strip_format_json(rest)?; inspect_terrain(&rest) } + [domain, command, rest @ ..] if domain == "model" && command == "inspect" => { + let rest = strip_format_json(rest)?; + inspect_model(&rest) + } _ => Err(usage()), } } @@ -429,6 +465,47 @@ fn inspect_terrain(args: &[String]) -> Result<(), String> { Ok(()) } +fn inspect_model(args: &[String]) -> Result<(), String> { + let root = parse_root_alias(args)?; + let archive = parse_required(args, &["--archive"], "--archive")?; + let resource = parse_required(args, &["--resource"], "--resource")?; + let inspection = inspect_model_from_root(&root, &archive, &resource)?; + println!("{}", model_inspect_json(&archive, &resource, &inspection)?); + Ok(()) +} + +fn model_inspect_json( + archive: &str, + resource: &str, + inspection: &ModelInspection, +) -> Result { + serialize_json(&ModelInspectOutput { + schema_version: MODEL_INSPECT_SCHEMA, + archive, + resource, + streams: inspection.streams, + nodes: inspection.nodes, + node_stride: inspection.node_stride, + slots: inspection.slots, + positions: inspection.positions, + indices: inspection.indices, + batches: inspection.batches, + animation_keys: inspection.animation_keys, + animation_frame_count: inspection.animation_frame_count, + node38: inspection + .node38 + .iter() + .map(|node| ModelNodeInspectOutput { + index: node.index, + parent_or_link_raw: node.parent_or_link_raw, + anim_map_start: node.anim_map_start, + fallback_key: node.fallback_key, + has_lod0_group0: node.has_lod0_group0, + }) + .collect(), + }) +} + fn archive_inspect_json( path: &str, kind: &str, @@ -505,7 +582,7 @@ fn prototype_graph_requiredness_label( } fn usage() -> String { - "usage: fparkan corpus discover|validate --root [--format json] | archive inspect [--format json] | terrain inspect [--format json] | prototype inspect --root --key [--format json] | mission graph|inspect --root --mission [--format json]".to_string() + "usage: fparkan corpus discover|validate --root [--format json] | archive inspect [--format json] | terrain inspect [--format json] | model inspect --root --archive --resource [--format json] | prototype inspect --root --key [--format json] | mission graph|inspect --root --mission [--format json]".to_string() } #[cfg(test)] 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()) }); diff --git a/docs/reference/msh.md b/docs/reference/msh.md index 93250a5..774e388 100644 --- a/docs/reference/msh.md +++ b/docs/reference/msh.md @@ -45,18 +45,22 @@ struct Node38 { Validated `ModelAsset` также сохраняет decoded type 8 keys и type 19 map как `ModelAnimation`. `node38_fallback_pose` возвращает pose по `fallback_key`, -то есть доказанный static input. Значение `parent_or_link` сохраняется как raw -field, пока runtime evidence не подтверждает, является ли оно parent index или -другой link semantic. +то есть доказанный static input. `parent_or_link == 0xFFFF` означает root; +иначе это parent index, обязательно меньший индекса child. Этот контракт +подтверждён на licensed animation gates обеих частей и защищён fallback-ом: +модель с нарушенным порядком не получает придуманную hierarchy. В legacy-camera static preview стандартный узел уже получает свой fallback pose -до внешнего TMA/Iron3D transform: quaternion поворачивает local vertex, затем -добавляется node translation, после чего применяется `Rz * Ry * Rx`, scale и -mission translation. Геометрия намеренно дублируется на draw-range узла, потому -что один source vertex может быть нарисован разными node poses. Это local-pose -assembly, а не skeleton: `parent_or_link` по-прежнему не используется, поэтому -вложенные parent transforms и dynamic frame-map sampling остаются отдельной -задачей runtime recovery. +до внешнего TMA/Iron3D transform. Parent pose поворачивает child translation, +затем translation суммируется, а rotations умножаются; после полученной global +pose применяется `Rz * Ry * Rx`, scale и mission translation. Геометрия +намеренно дублируется на draw-range узла, потому что один source vertex может +быть нарисован разными node poses. Это static fallback hierarchy, а не полная +animation parity: dynamic type-19 frame-map sampling остаётся отдельной задачей. + +Для воспроизводимого исследования `fparkan-cli model inspect --root +--archive --resource ` выводит Node38 metadata, включая +parent index, fallback key и наличие LOD0/group0 geometry. ## Slot and batch diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 8dd244f..45bb058 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1601,24 +1601,25 @@ parent hierarchy и animation-key/fallback semantics. Модели без layout Чтобы следующий шаг не перечитывал raw NRes streams в renderer, validated `ModelAsset` теперь переносит `ModelAnimation` с decoded type-8 keys, type-19 map и declared frame count. `node38_fallback_pose` возвращает exact fallback -key pose, а offset `+2` остаётся именованным только как `parent_or_link_raw`. -Licensed gates Part 1/Part 2 повторно подтвердили 435/511 models, 157/200 -animated models, 3,469/5,233 node samples и утверждённые captures. Это -доказывает production handoff animation data, не семантику parent links и не -готовую model assembly. +key pose. Licensed gates Part 1/Part 2 подтвердили 435/511 models, 157/200 +animated models, 3,469/5,233 node samples и утверждённые captures. Следующий узкий шаг применяет этот уже декодированный `fallback_key` в legacy-camera static preview. Для каждого выбранного `Node38` bridge поворачивает вершины normalized quaternion, прибавляет local translation pose и только затем применяет доказанный TMA `Rz * Ry * Rx`, scale и mission translation. Вершины намеренно разворачиваются per node/draw range, чтобы один source vertex мог -получить разные poses. `parent_or_link_raw` не читается как parent, frame map не -семплируется, поэтому это не заявляет восстановленную hierarchy или animation. +получить разные poses. `parent_or_link_raw == 0xFFFF` теперь доказан как root, +а любое меньшее child значение — parent index: inspected `R_B_01.msh` содержит +34-node parent-before-child tree, а Part 1/Part 2 animation gate подтвердил +этот контракт для всех standard nodes. Parent rotation корректно поворачивает +child translation до суммирования. Frame map всё ещё не семплируется, поэтому +это static fallback hierarchy, а не full animation parity. На той же GOG `MISSIONS/Autodemo.00/data.tma` и offline Ngi32 camera capture full preview из 8 objects / 66 mesh components / 67 descriptors завершился за -один native Vulkan run с `clip_visible_vertices=2584`, `validation_warnings=0`, -`validation_errors=0` и readback hash `5769837558651835265`. Рост с 938 -видимых вершин подтверждает, что local source poses реально вошли в geometry +один native Vulkan run с `clip_visible_vertices=3415`, `validation_warnings=0`, +`validation_errors=0` и readback hash `1275533143935640133`. Рост с 2584 +видимых вершин подтверждает, что composed parent poses реально вошли в geometry path; он не является оценкой сходства с original frame. Параллельно bridge для legacy-camera path теперь переводит только высоту