feat(animation): recover Node38 fallback hierarchy
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use crate::{VulkanStaticDrawRange, VulkanStaticMesh, VulkanStaticVertex};
|
use crate::{VulkanStaticDrawRange, VulkanStaticMesh, VulkanStaticVertex};
|
||||||
use fparkan_msh::{
|
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_render::{LegacyIron3dEulerTransform, LegacyPipelineState};
|
||||||
use fparkan_terrain_format::LandMeshDocument;
|
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
|
/// Projects a standard `Node38` model using each selected node's decoded
|
||||||
/// fallback pose before its recovered `Iron3D` placement transform.
|
/// fallback pose before its recovered `Iron3D` placement transform.
|
||||||
///
|
///
|
||||||
/// This is deliberately not a skeleton evaluator: the source field at
|
/// The source link at `Node38 + 2` is a parent index: `0xFFFF` marks a root
|
||||||
/// `Node38 + 2` is still an unassigned link, so no parent transform is
|
/// and non-root indices are parent-before-child. The bridge composes the
|
||||||
/// inferred here. Each node's source fallback pose is nevertheless enough to
|
/// fallback poses through that hierarchy before applying the outer transform.
|
||||||
/// keep independently stored static components at their authored local pose.
|
/// Models without complete standard-node hierarchy data retain the established
|
||||||
/// Models without complete standard-node pose data retain the established
|
|
||||||
/// unposed static bridge.
|
/// unposed static bridge.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # 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() {
|
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);
|
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 vertices = Vec::new();
|
||||||
let mut indices = 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 {
|
let Some(slot) = selected_slot(model, node, Lod(0), Group(0)) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(pose) = node38_fallback_pose(model, node) else {
|
let pose = hierarchy.poses[node_index];
|
||||||
return project_msh_to_static_mesh_in_world_space_with_transform(
|
|
||||||
model, transform, scale,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
if !pose
|
if !pose
|
||||||
.translation
|
.translation
|
||||||
.iter()
|
.iter()
|
||||||
@@ -893,7 +891,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn world_space_msh_applies_each_node_fallback_pose_before_root_transform() {
|
fn world_space_msh_applies_each_node_fallback_pose_before_root_transform() {
|
||||||
let mut nodes = vec![0_u8; 76];
|
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[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 + 6..38 + 8].copy_from_slice(&1_u16.to_le_bytes());
|
||||||
nodes[38 + 8..38 + 10].copy_from_slice(&1_u16.to_le_bytes());
|
nodes[38 + 8..38 + 10].copy_from_slice(&1_u16.to_le_bytes());
|
||||||
let source = ModelAsset {
|
let source = ModelAsset {
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ use fparkan_assets::{
|
|||||||
decode_mission_payload, extend_graph_report_with_visual_dependencies, TmaProfile,
|
decode_mission_payload, extend_graph_report_with_visual_dependencies, TmaProfile,
|
||||||
};
|
};
|
||||||
use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions};
|
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_path::{normalize_relative, PathPolicy};
|
||||||
use fparkan_prototype::build_prototype_graph_report;
|
use fparkan_prototype::build_prototype_graph_report;
|
||||||
use fparkan_resource::{resource_name, CachedResourceRepository};
|
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_GRAPH_SCHEMA: &str = "fparkan-mission-graph-v1";
|
||||||
const MISSION_INSPECT_SCHEMA: &str = "fparkan-mission-inspect-v1";
|
const MISSION_INSPECT_SCHEMA: &str = "fparkan-mission-inspect-v1";
|
||||||
const TERRAIN_INSPECT_SCHEMA: &str = "fparkan-terrain-inspect-v1";
|
const TERRAIN_INSPECT_SCHEMA: &str = "fparkan-terrain-inspect-v1";
|
||||||
|
const MODEL_INSPECT_SCHEMA: &str = "fparkan-model-inspect-v1";
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct ArchiveInspectOutput<'a> {
|
struct ArchiveInspectOutput<'a> {
|
||||||
@@ -123,6 +127,34 @@ struct TerrainInspectOutput {
|
|||||||
max: [f32; 3],
|
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<usize>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
animation_frame_count: Option<u32>,
|
||||||
|
node38: Vec<ModelNodeInspectOutput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ModelNodeInspectOutput {
|
||||||
|
index: usize,
|
||||||
|
parent_or_link_raw: u16,
|
||||||
|
anim_map_start: u16,
|
||||||
|
fallback_key: u16,
|
||||||
|
has_lod0_group0: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct GraphFailureOutput {
|
struct GraphFailureOutput {
|
||||||
root_index: usize,
|
root_index: usize,
|
||||||
@@ -191,6 +223,10 @@ fn run(args: &[String]) -> Result<(), String> {
|
|||||||
let rest = strip_format_json(rest)?;
|
let rest = strip_format_json(rest)?;
|
||||||
inspect_terrain(&rest)
|
inspect_terrain(&rest)
|
||||||
}
|
}
|
||||||
|
[domain, command, rest @ ..] if domain == "model" && command == "inspect" => {
|
||||||
|
let rest = strip_format_json(rest)?;
|
||||||
|
inspect_model(&rest)
|
||||||
|
}
|
||||||
_ => Err(usage()),
|
_ => Err(usage()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -429,6 +465,47 @@ fn inspect_terrain(args: &[String]) -> Result<(), String> {
|
|||||||
Ok(())
|
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<String, String> {
|
||||||
|
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(
|
fn archive_inspect_json(
|
||||||
path: &str,
|
path: &str,
|
||||||
kind: &str,
|
kind: &str,
|
||||||
@@ -505,7 +582,7 @@ fn prototype_graph_requiredness_label(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn usage() -> String {
|
fn usage() -> String {
|
||||||
"usage: fparkan corpus discover|validate --root <path> [--format json] | archive inspect <file> [--format json] | terrain inspect <Land.msh> [--format json] | prototype inspect --root <path> --key <key> [--format json] | mission graph|inspect --root <path> --mission <path> [--format json]".to_string()
|
"usage: fparkan corpus discover|validate --root <path> [--format json] | archive inspect <file> [--format json] | terrain inspect <Land.msh> [--format json] | model inspect --root <path> --archive <archive> --resource <model.msh> [--format json] | prototype inspect --root <path> --key <key> [--format json] | mission graph|inspect --root <path> --mission <path> [--format json]".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -781,16 +781,29 @@ fn write_f32_bits(out: &mut Vec<u8>, value: f32) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn compose_pose(parent: Pose, child: Pose) -> Result<Pose, AnimationError> {
|
fn compose_pose(parent: Pose, child: Pose) -> Result<Pose, AnimationError> {
|
||||||
|
let translated = rotate_point(parent.rotation, child.translation);
|
||||||
Ok(Pose {
|
Ok(Pose {
|
||||||
translation: [
|
translation: [
|
||||||
parent.translation[0] + child.translation[0],
|
parent.translation[0] + translated[0],
|
||||||
parent.translation[1] + child.translation[1],
|
parent.translation[1] + translated[1],
|
||||||
parent.translation[2] + child.translation[2],
|
parent.translation[2] + translated[2],
|
||||||
],
|
],
|
||||||
rotation: normalize_quat(mul_quat(parent.rotation, child.rotation))?,
|
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] {
|
fn mul_quat(left: [f32; 4], right: [f32; 4]) -> [f32; 4] {
|
||||||
let [lx, ly, lz, lw] = left;
|
let [lx, ly, lz, lw] = left;
|
||||||
let [rx, ry, rz, rw] = right;
|
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]
|
#[test]
|
||||||
fn generated_valid_quaternions_remain_finite() {
|
fn generated_valid_quaternions_remain_finite() {
|
||||||
for index in 1..64_u16 {
|
for index in 1..64_u16 {
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ use fparkan_diagnostics::{
|
|||||||
diagnostic, render_human, Diagnostic, DiagnosticCode, DiagnosticContext, Phase, SourceSpan,
|
diagnostic, render_human, Diagnostic, DiagnosticCode, DiagnosticContext, Phase, SourceSpan,
|
||||||
};
|
};
|
||||||
use fparkan_material::{decode_wear, resolve_material, MaterialFallback};
|
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_nres::{decode as decode_nres, NresDocument, ReadProfile};
|
||||||
use fparkan_path::{normalize_relative, PathPolicy};
|
use fparkan_path::{normalize_relative, PathPolicy};
|
||||||
use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
|
use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
|
||||||
@@ -85,6 +87,29 @@ pub struct ModelInspection {
|
|||||||
pub indices: usize,
|
pub indices: usize,
|
||||||
/// Batch count.
|
/// Batch count.
|
||||||
pub batches: usize,
|
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<Node38Inspection>,
|
||||||
|
/// Number of decoded type-8 animation keys, when available.
|
||||||
|
pub animation_keys: Option<usize>,
|
||||||
|
/// Declared type-19 animation frame count, when available.
|
||||||
|
pub animation_frame_count: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// 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 msh = decode_msh(&document).map_err(|err| err.to_string())?;
|
||||||
let validated = validate_msh(&msh).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 {
|
Ok(ModelInspection {
|
||||||
streams: msh.streams().len(),
|
streams: msh.streams().len(),
|
||||||
nodes: validated.node_count,
|
nodes: validated.node_count,
|
||||||
@@ -319,6 +361,16 @@ pub fn inspect_model_from_root(
|
|||||||
positions: validated.positions.len(),
|
positions: validated.positions.len(),
|
||||||
indices: validated.indices.len(),
|
indices: validated.indices.len(),
|
||||||
batches: validated.batches.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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
//! Stage-3 MSH asset contract.
|
//! Stage-3 MSH asset contract.
|
||||||
|
|
||||||
use encoding_rs::WINDOWS_1251;
|
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};
|
use fparkan_nres::{EntryMeta, NresDocument, NresError};
|
||||||
|
|
||||||
/// Node table stream.
|
/// Node table stream.
|
||||||
@@ -450,6 +450,34 @@ pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
|
|||||||
.map(AnimKey24::sampling_pose)
|
.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<NodePoseBuffer> {
|
||||||
|
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.
|
/// Returns draw batches for a validated slot.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # 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]
|
#[test]
|
||||||
fn type2_header_and_slot_tail_framing_are_exact() {
|
fn type2_header_and_slot_tail_framing_are_exact() {
|
||||||
let too_small = decode_nested(&build_nres(&[
|
let too_small = decode_nested(&build_nres(&[
|
||||||
@@ -1496,6 +1565,13 @@ mod tests {
|
|||||||
let model = validate_msh(&msh).unwrap_or_else(|err| {
|
let model = validate_msh(&msh).unwrap_or_else(|err| {
|
||||||
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
|
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| {
|
let preserved = msh.preserved_streams().unwrap_or_else(|err| {
|
||||||
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
|
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
|
||||||
});
|
});
|
||||||
|
|||||||
+14
-10
@@ -45,18 +45,22 @@ struct Node38 {
|
|||||||
|
|
||||||
Validated `ModelAsset` также сохраняет decoded type 8 keys и type 19 map как
|
Validated `ModelAsset` также сохраняет decoded type 8 keys и type 19 map как
|
||||||
`ModelAnimation`. `node38_fallback_pose` возвращает pose по `fallback_key`,
|
`ModelAnimation`. `node38_fallback_pose` возвращает pose по `fallback_key`,
|
||||||
то есть доказанный static input. Значение `parent_or_link` сохраняется как raw
|
то есть доказанный static input. `parent_or_link == 0xFFFF` означает root;
|
||||||
field, пока runtime evidence не подтверждает, является ли оно parent index или
|
иначе это parent index, обязательно меньший индекса child. Этот контракт
|
||||||
другой link semantic.
|
подтверждён на licensed animation gates обеих частей и защищён fallback-ом:
|
||||||
|
модель с нарушенным порядком не получает придуманную hierarchy.
|
||||||
|
|
||||||
В legacy-camera static preview стандартный узел уже получает свой fallback pose
|
В legacy-camera static preview стандартный узел уже получает свой fallback pose
|
||||||
до внешнего TMA/Iron3D transform: quaternion поворачивает local vertex, затем
|
до внешнего TMA/Iron3D transform. Parent pose поворачивает child translation,
|
||||||
добавляется node translation, после чего применяется `Rz * Ry * Rx`, scale и
|
затем translation суммируется, а rotations умножаются; после полученной global
|
||||||
mission translation. Геометрия намеренно дублируется на draw-range узла, потому
|
pose применяется `Rz * Ry * Rx`, scale и mission translation. Геометрия
|
||||||
что один source vertex может быть нарисован разными node poses. Это local-pose
|
намеренно дублируется на draw-range узла, потому что один source vertex может
|
||||||
assembly, а не skeleton: `parent_or_link` по-прежнему не используется, поэтому
|
быть нарисован разными node poses. Это static fallback hierarchy, а не полная
|
||||||
вложенные parent transforms и dynamic frame-map sampling остаются отдельной
|
animation parity: dynamic type-19 frame-map sampling остаётся отдельной задачей.
|
||||||
задачей runtime recovery.
|
|
||||||
|
Для воспроизводимого исследования `fparkan-cli model inspect --root <game>
|
||||||
|
--archive <archive> --resource <model.msh>` выводит Node38 metadata, включая
|
||||||
|
parent index, fallback key и наличие LOD0/group0 geometry.
|
||||||
|
|
||||||
## Slot and batch
|
## Slot and batch
|
||||||
|
|
||||||
|
|||||||
+11
-10
@@ -1601,24 +1601,25 @@ parent hierarchy и animation-key/fallback semantics. Модели без layout
|
|||||||
Чтобы следующий шаг не перечитывал raw NRes streams в renderer, validated
|
Чтобы следующий шаг не перечитывал raw NRes streams в renderer, validated
|
||||||
`ModelAsset` теперь переносит `ModelAnimation` с decoded type-8 keys, type-19
|
`ModelAsset` теперь переносит `ModelAnimation` с decoded type-8 keys, type-19
|
||||||
map и declared frame count. `node38_fallback_pose` возвращает exact fallback
|
map и declared frame count. `node38_fallback_pose` возвращает exact fallback
|
||||||
key pose, а offset `+2` остаётся именованным только как `parent_or_link_raw`.
|
key pose. Licensed gates Part 1/Part 2 подтвердили 435/511 models, 157/200
|
||||||
Licensed gates Part 1/Part 2 повторно подтвердили 435/511 models, 157/200
|
animated models, 3,469/5,233 node samples и утверждённые captures.
|
||||||
animated models, 3,469/5,233 node samples и утверждённые captures. Это
|
|
||||||
доказывает production handoff animation data, не семантику parent links и не
|
|
||||||
готовую model assembly.
|
|
||||||
|
|
||||||
Следующий узкий шаг применяет этот уже декодированный `fallback_key` в
|
Следующий узкий шаг применяет этот уже декодированный `fallback_key` в
|
||||||
legacy-camera static preview. Для каждого выбранного `Node38` bridge поворачивает
|
legacy-camera static preview. Для каждого выбранного `Node38` bridge поворачивает
|
||||||
вершины normalized quaternion, прибавляет local translation pose и только затем
|
вершины normalized quaternion, прибавляет local translation pose и только затем
|
||||||
применяет доказанный TMA `Rz * Ry * Rx`, scale и mission translation. Вершины
|
применяет доказанный TMA `Rz * Ry * Rx`, scale и mission translation. Вершины
|
||||||
намеренно разворачиваются per node/draw range, чтобы один source vertex мог
|
намеренно разворачиваются per node/draw range, чтобы один source vertex мог
|
||||||
получить разные poses. `parent_or_link_raw` не читается как parent, frame map не
|
получить разные poses. `parent_or_link_raw == 0xFFFF` теперь доказан как root,
|
||||||
семплируется, поэтому это не заявляет восстановленную hierarchy или animation.
|
а любое меньшее 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
|
На той же GOG `MISSIONS/Autodemo.00/data.tma` и offline Ngi32 camera capture
|
||||||
full preview из 8 objects / 66 mesh components / 67 descriptors завершился за
|
full preview из 8 objects / 66 mesh components / 67 descriptors завершился за
|
||||||
один native Vulkan run с `clip_visible_vertices=2584`, `validation_warnings=0`,
|
один native Vulkan run с `clip_visible_vertices=3415`, `validation_warnings=0`,
|
||||||
`validation_errors=0` и readback hash `5769837558651835265`. Рост с 938
|
`validation_errors=0` и readback hash `1275533143935640133`. Рост с 2584
|
||||||
видимых вершин подтверждает, что local source poses реально вошли в geometry
|
видимых вершин подтверждает, что composed parent poses реально вошли в geometry
|
||||||
path; он не является оценкой сходства с original frame.
|
path; он не является оценкой сходства с original frame.
|
||||||
|
|
||||||
Параллельно bridge для legacy-camera path теперь переводит только высоту
|
Параллельно bridge для legacy-camera path теперь переводит только высоту
|
||||||
|
|||||||
Reference in New Issue
Block a user