feat(animation): recover Node38 fallback hierarchy

This commit is contained in:
2026-07-18 16:25:20 +04:00
parent 858dcd0d1d
commit 1a8e1eaed1
7 changed files with 281 additions and 38 deletions
@@ -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 {
+79 -2
View File
@@ -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)]
+36 -3
View File
@@ -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 {
+53 -1
View File
@@ -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),
}) })
} }
+77 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 теперь переводит только высоту