aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-07-18 18:08:49 +0300
committerValentin Popov <valentin@popov.link>2026-07-18 18:08:49 +0300
commit5c1ccae393379758a35ea54a0b6b9ab3184ad442 (patch)
tree4a01e1dac9177cf10661dbdeb51d1b5d70ceabb6
parent3b6dd392a74ebd1096a9bcccf0a02c417370bf82 (diff)
downloadfparkan-5c1ccae393379758a35ea54a0b6b9ab3184ad442.tar.xz
fparkan-5c1ccae393379758a35ea54a0b6b9ab3184ad442.zip
feat(render): preview explicit animation frames
-rw-r--r--adapters/fparkan-render-vulkan/Cargo.toml4
-rw-r--r--adapters/fparkan-render-vulkan/src/asset_mesh.rs77
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi.rs4
-rw-r--r--apps/fparkan-game/src/main.rs115
-rw-r--r--crates/fparkan-msh/src/lib.rs142
-rw-r--r--docs/tomes/05-render.md22
6 files changed, 337 insertions, 27 deletions
diff --git a/adapters/fparkan-render-vulkan/Cargo.toml b/adapters/fparkan-render-vulkan/Cargo.toml
index 7086916..abd0928 100644
--- a/adapters/fparkan-render-vulkan/Cargo.toml
+++ b/adapters/fparkan-render-vulkan/Cargo.toml
@@ -9,6 +9,7 @@ build = "build.rs"
[dependencies]
ash = "0.38"
ash-window = "0.13"
+fparkan-animation = { path = "../../crates/fparkan-animation", version = "0.1.0" }
fparkan-binary = { path = "../../crates/fparkan-binary", version = "0.1.0" }
fparkan-msh = { path = "../../crates/fparkan-msh", version = "0.1.0" }
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
@@ -17,9 +18,6 @@ fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format", version
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
-[dev-dependencies]
-fparkan-animation = { path = "../../crates/fparkan-animation", version = "0.1.0" }
-
[lints.rust]
unsafe_code = "allow"
missing_docs = "warn"
diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs
index ba67437..79a42a4 100644
--- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs
+++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs
@@ -2,7 +2,8 @@
use crate::{VulkanStaticDrawRange, VulkanStaticMesh, VulkanStaticVertex};
use fparkan_msh::{
- draw_batches, node38_fallback_hierarchy, selected_slot, Group, Lod, ModelAsset, NodeId,
+ draw_batches, node38_fallback_hierarchy, node38_sampled_hierarchy, selected_slot, Group, Lod,
+ ModelAsset, NodeId,
};
use fparkan_render::{LegacyIron3dEulerTransform, LegacyPipelineState};
use fparkan_terrain_format::LandMeshDocument;
@@ -206,6 +207,39 @@ pub fn project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses(
Ok(mesh)
}
+/// Projects a standard `Node38` model at one explicit portable-reference
+/// animation frame into a diagnostic XY frame.
+///
+/// This is the diagnostic-camera counterpart of
+/// [`project_msh_to_static_mesh_in_world_space_with_node_sampled_poses`].
+///
+/// # Errors
+///
+/// Returns [`VulkanAssetMeshError`] when the source cannot be represented by
+/// the static bridge or a transformed coordinate is non-finite.
+pub fn project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses(
+ model: &ModelAsset,
+ frame: VulkanStaticXyFrame,
+ transform: LegacyIron3dEulerTransform,
+ scale: [f32; 3],
+ animation_frame: u16,
+) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
+ let mut mesh = project_msh_to_static_mesh_in_world_space_with_node_sampled_poses(
+ model,
+ transform,
+ scale,
+ animation_frame,
+ )?;
+ for vertex in &mut mesh.vertices {
+ if !vertex.position.iter().all(|value| value.is_finite()) {
+ return Err(VulkanAssetMeshError::NonFinitePosition);
+ }
+ let projected = frame.project(vertex.position);
+ vertex.position = [projected[0], projected[1], 0.0];
+ }
+ Ok(mesh)
+}
+
/// Projects MSH geometry into source world coordinates with known translation and scale.
///
/// Unlike [`project_msh_to_static_mesh_in_xy_frame`], this does not normalize
@@ -308,6 +342,45 @@ pub fn project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
transform: LegacyIron3dEulerTransform,
scale: [f32; 3],
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
+ project_msh_to_static_mesh_in_world_space_with_node_poses(
+ model,
+ transform,
+ scale,
+ node38_fallback_hierarchy(model),
+ )
+}
+
+/// Projects a standard `Node38` model at one explicit portable-reference
+/// animation frame.
+///
+/// This uses decoded type-8 keys and the type-19 map but deliberately does
+/// not infer the original engine's animation clock or x87 profile. Models
+/// without a complete sampled hierarchy retain the established unposed bridge.
+///
+/// # Errors
+///
+/// Returns [`VulkanAssetMeshError`] when source geometry or transforms cannot
+/// be represented by the current static Vulkan input contract.
+pub fn project_msh_to_static_mesh_in_world_space_with_node_sampled_poses(
+ model: &ModelAsset,
+ transform: LegacyIron3dEulerTransform,
+ scale: [f32; 3],
+ animation_frame: u16,
+) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
+ project_msh_to_static_mesh_in_world_space_with_node_poses(
+ model,
+ transform,
+ scale,
+ node38_sampled_hierarchy(model, animation_frame),
+ )
+}
+
+fn project_msh_to_static_mesh_in_world_space_with_node_poses(
+ model: &ModelAsset,
+ transform: LegacyIron3dEulerTransform,
+ scale: [f32; 3],
+ hierarchy: Option<fparkan_animation::NodePoseBuffer>,
+) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
if !transform
.translation
.iter()
@@ -320,7 +393,7 @@ 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 {
+ let Some(hierarchy) = hierarchy else {
return project_msh_to_static_mesh_in_world_space_with_transform(model, transform, scale);
};
diff --git a/adapters/fparkan-render-vulkan/src/ffi.rs b/adapters/fparkan-render-vulkan/src/ffi.rs
index 86d8b45..79ca919 100644
--- a/adapters/fparkan-render-vulkan/src/ffi.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi.rs
@@ -44,9 +44,11 @@ pub use self::asset_mesh::{
project_land_msh_to_static_mesh_in_world_space, project_land_msh_to_static_mesh_in_xy_frame,
project_msh_to_static_mesh, project_msh_to_static_mesh_in_world_space,
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses,
+ project_msh_to_static_mesh_in_world_space_with_node_sampled_poses,
project_msh_to_static_mesh_in_world_space_with_transform,
project_msh_to_static_mesh_in_xy_frame,
- project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses, VulkanAssetMeshError,
+ project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses,
+ project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses, VulkanAssetMeshError,
VulkanStaticXyFrame,
};
pub use self::capabilities::{
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs
index 92176e0..78ba3e4 100644
--- a/apps/fparkan-game/src/main.rs
+++ b/apps/fparkan-game/src/main.rs
@@ -35,7 +35,9 @@ use fparkan_render_vulkan::{
project_land_msh_to_static_mesh_in_legacy_world_space,
project_land_msh_to_static_mesh_in_xy_frame,
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses,
- project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses, VulkanPlanningBackend,
+ project_msh_to_static_mesh_in_world_space_with_node_sampled_poses,
+ project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses,
+ project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses, VulkanPlanningBackend,
VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
VulkanStaticCamera, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture,
VulkanStaticXyFrame,
@@ -110,6 +112,7 @@ fn run(args: &[String]) -> Result<String, String> {
terrain,
roots,
camera,
+ args.static_animation_frame,
&args.root,
&loaded.land_msh_path,
)?;
@@ -241,6 +244,7 @@ struct StaticPreviewScene {
clip_visible_vertices: usize,
mesh_components: usize,
terrain_components: usize,
+ animation_frame: Option<u16>,
}
/// Projects the mission terrain plus every MSH component of the explicitly
@@ -252,11 +256,13 @@ struct StaticPreviewScene {
/// retained on draw ranges but awaits a recovered blend equation; orientation,
/// later material phases, animation, lightmaps and gameplay visibility remain
/// outside this bridge.
+#[allow(clippy::too_many_lines)]
fn static_preview_mesh_and_materials(
assets: &MissionAssets,
terrain: &TerrainWorld,
roots: &[MissionObjectDraft],
legacy_camera: Option<VulkanStaticCamera>,
+ static_animation_frame: Option<u16>,
root: &std::path::Path,
land_msh_path: &str,
) -> Result<StaticPreviewScene, String> {
@@ -301,26 +307,43 @@ fn static_preview_mesh_and_materials(
let model = assets.model_by_id(model_id).ok_or_else(|| {
format!("static preview visual {visual_id:?} references unknown model {model_id:?}")
})?;
+ let transform = LegacyIron3dEulerTransform {
+ translation: root.position,
+ orientation_radians: root.orientation_raw,
+ };
let component = if legacy_camera.is_some() {
- project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
- &model.validated,
- LegacyIron3dEulerTransform {
- translation: root.position,
- orientation_radians: root.orientation_raw,
- },
- root.scale,
- )
+ if let Some(frame) = static_animation_frame {
+ project_msh_to_static_mesh_in_world_space_with_node_sampled_poses(
+ &model.validated,
+ transform,
+ root.scale,
+ frame,
+ )
+ } else {
+ project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
+ &model.validated,
+ transform,
+ root.scale,
+ )
+ }
} else {
let frame = xy_frame.ok_or_else(|| "missing diagnostic XY frame".to_string())?;
- project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses(
- &model.validated,
- frame,
- LegacyIron3dEulerTransform {
- translation: root.position,
- orientation_radians: root.orientation_raw,
- },
- root.scale,
- )
+ if let Some(animation_frame) = static_animation_frame {
+ project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses(
+ &model.validated,
+ frame,
+ transform,
+ root.scale,
+ animation_frame,
+ )
+ } else {
+ project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses(
+ &model.validated,
+ frame,
+ transform,
+ root.scale,
+ )
+ }
}
.map_err(|err| format!("project mission MSH for Vulkan: {err}"))?;
let selector_remap =
@@ -345,6 +368,7 @@ fn static_preview_mesh_and_materials(
},
mesh_components,
terrain_components: 1,
+ animation_frame: static_animation_frame,
})
}
@@ -678,6 +702,7 @@ struct StaticVulkanApp {
clip_visible_vertices: usize,
mesh_components: usize,
terrain_components: usize,
+ animation_frame: Option<u16>,
preview_roots: usize,
target_frames: u64,
mission: String,
@@ -708,6 +733,7 @@ impl StaticVulkanApp {
clip_visible_vertices: preview.clip_visible_vertices,
mesh_components: preview.mesh_components,
terrain_components: preview.terrain_components,
+ animation_frame: preview.animation_frame,
preview_roots,
target_frames,
mission: mission.to_string(),
@@ -776,12 +802,13 @@ impl StaticVulkanApp {
(None, _) => None,
};
self.output = Some(format!(
- "{{\"report_kind\":\"rendered-static-mission\",\"backend\":\"vulkan-static\",\"camera_mode\":{},\"window\":\"native\",\"mission\":{},\"objects\":{},\"frames\":{},\"preview_roots\":{},\"mesh_components\":{},\"terrain_components\":{},\"clip_visible_vertices\":{},\"material_descriptors\":{},\"swapchain\":[{},{}],\"swapchain_images\":{},\"validation_warnings\":{},\"validation_errors\":{},\"readback_format\":{},\"readback_bytes\":{},\"readback_hash\":{},\"readback_path\":{}}}",
+ "{{\"report_kind\":\"rendered-static-mission\",\"backend\":\"vulkan-static\",\"camera_mode\":{},\"window\":\"native\",\"mission\":{},\"objects\":{},\"frames\":{},\"preview_roots\":{},\"animation_frame\":{},\"mesh_components\":{},\"terrain_components\":{},\"clip_visible_vertices\":{},\"material_descriptors\":{},\"swapchain\":[{},{}],\"swapchain_images\":{},\"validation_warnings\":{},\"validation_errors\":{},\"readback_format\":{},\"readback_bytes\":{},\"readback_hash\":{},\"readback_path\":{}}}",
json_string(self.camera_mode),
json_string(&self.mission),
self.object_count,
self.frames_presented,
self.preview_roots,
+ self.animation_frame.map_or_else(|| "null".to_string(), |frame| frame.to_string()),
self.mesh_components,
self.terrain_components,
self.clip_visible_vertices,
@@ -1035,6 +1062,7 @@ struct Args {
readback_out: Option<PathBuf>,
preview_roots: NonZeroUsize,
legacy_camera_capture: Option<PathBuf>,
+ static_animation_frame: Option<u16>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -1044,6 +1072,7 @@ enum RenderBackendMode {
}
impl Args {
+ #[allow(clippy::too_many_lines)]
fn parse(args: &[String]) -> Result<Self, String> {
let mut root = None;
let mut mission = None;
@@ -1057,6 +1086,7 @@ impl Args {
// remains available for bounded diagnostic work.
let mut preview_roots = NonZeroUsize::MAX;
let mut legacy_camera_capture = None;
+ let mut static_animation_frame = None;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
@@ -1119,6 +1149,16 @@ impl Args {
"--legacy-camera-capture requires a path".to_string()
})?);
}
+ "--static-animation-frame" => {
+ static_animation_frame = Some(
+ iter.next()
+ .ok_or_else(|| "--static-animation-frame requires a value".to_string())?
+ .parse()
+ .map_err(|_| {
+ "--static-animation-frame must be a u16 integer".to_string()
+ })?,
+ );
+ }
_ => return Err(usage()),
}
}
@@ -1133,6 +1173,9 @@ impl Args {
if readback_out.is_some() && backend != RenderBackendMode::StaticVulkan {
return Err("--readback-out requires --backend static-vulkan".to_string());
}
+ if static_animation_frame.is_some() && backend != RenderBackendMode::StaticVulkan {
+ return Err("--static-animation-frame requires --backend static-vulkan".to_string());
+ }
Ok(Self {
root,
mission,
@@ -1142,6 +1185,7 @@ impl Args {
readback_out,
preview_roots,
legacy_camera_capture,
+ static_animation_frame,
})
}
}
@@ -1213,7 +1257,7 @@ fn json_hash(hash: &[u8; 32]) -> String {
}
fn usage() -> String {
- "usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--backend <planning|static-vulkan>] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--readback-out <path>] [--load-progress <path>]".to_string()
+ "usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--backend <planning|static-vulkan>] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--readback-out <path>] [--load-progress <path>]".to_string()
}
#[cfg(test)]
@@ -1281,6 +1325,7 @@ mod tests {
readback_out: None,
preview_roots: NonZeroUsize::MAX,
legacy_camera_capture: None,
+ static_animation_frame: None,
})
);
}
@@ -1305,6 +1350,7 @@ mod tests {
readback_out: None,
preview_roots: NonZeroUsize::MAX,
legacy_camera_capture: None,
+ static_animation_frame: None,
})
);
}
@@ -1357,6 +1403,34 @@ mod tests {
}
#[test]
+ fn static_animation_frame_requires_static_vulkan_and_is_retained() {
+ let common = [
+ "--root",
+ "testdata/IS",
+ "--mission",
+ "MISSIONS/Autodemo.00/data.tma",
+ "--static-animation-frame",
+ "12",
+ ];
+ assert_eq!(
+ Args::parse(&strings(&common)),
+ Err("--static-animation-frame requires --backend static-vulkan".to_string())
+ );
+ let parsed = Args::parse(&strings(&[
+ "--root",
+ "testdata/IS",
+ "--mission",
+ "MISSIONS/Autodemo.00/data.tma",
+ "--backend",
+ "static-vulkan",
+ "--static-animation-frame",
+ "12",
+ ]))
+ .expect("valid static animation frame");
+ assert_eq!(parsed.static_animation_frame, Some(12));
+ }
+
+ #[test]
fn rejects_zero_static_preview_root_count() {
let error = Args::parse(&strings(&[
"--root",
@@ -1392,6 +1466,7 @@ mod tests {
readback_out: None,
preview_roots: NonZeroUsize::MAX,
legacy_camera_capture: None,
+ static_animation_frame: None,
})
);
}
diff --git a/crates/fparkan-msh/src/lib.rs b/crates/fparkan-msh/src/lib.rs
index dfc4c02..fd222c6 100644
--- a/crates/fparkan-msh/src/lib.rs
+++ b/crates/fparkan-msh/src/lib.rs
@@ -21,7 +21,10 @@
//! Stage-3 MSH asset contract.
use encoding_rs::WINDOWS_1251;
-use fparkan_animation::{evaluate_hierarchy, AnimKey24, NodePoseBuffer, ParentIndex, Pose};
+use fparkan_animation::{
+ evaluate_hierarchy, AnimKey24, AnimationTime, NodePoseBuffer, ParentIndex, Pose, TimedPoseKey,
+ TimedPoseTrack,
+};
use fparkan_nres::{EntryMeta, NresDocument, NresError};
/// Node table stream.
@@ -478,6 +481,88 @@ pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
evaluate_hierarchy(&parents, &poses).ok()
}
+/// Evaluates the portable-reference pose hierarchy of a standard `Node38`
+/// model at one explicitly supplied logical frame.
+///
+/// A node without a usable type-19 map, or a requested frame outside the
+/// declared map length, retains its exact fallback key. Mapped keys are
+/// sampled against their immediate successor using the decoded key times.
+/// This is an offline asset-sampling contract; it does not claim ownership of
+/// the original runtime's animation clock or x87 numeric profile.
+///
+/// Returns `None` when the model lacks a complete standard-node animation
+/// representation or the map cannot be safely resolved.
+#[must_use]
+pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePoseBuffer> {
+ if model.node_stride != 38 || model.node_count == 0 {
+ return None;
+ }
+ let animation = model.animation.as_ref()?;
+ 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)))?
+ };
+ let fallback_index = usize::from(metadata.fallback_key);
+ let _fallback = animation.keys.get(fallback_index)?;
+ let key_index =
+ if metadata.anim_map_start == u16::MAX || u32::from(frame) >= animation.frame_count {
+ fallback_index
+ } else {
+ let mapped_index =
+ usize::from(*animation.frame_map.get(
+ usize::from(metadata.anim_map_start).checked_add(usize::from(frame))?,
+ )?);
+ if mapped_index < fallback_index {
+ mapped_index
+ } else {
+ fallback_index
+ }
+ };
+ let pose = sample_node38_key_pair(&animation.keys, key_index, fallback_index, frame)?;
+ parents.push(parent);
+ poses.push(pose);
+ }
+ evaluate_hierarchy(&parents, &poses).ok()
+}
+
+fn sample_node38_key_pair(
+ keys: &[AnimKey24],
+ key_index: usize,
+ fallback_index: usize,
+ frame: u16,
+) -> Option<Pose> {
+ let key = *keys.get(key_index)?;
+ if key_index == fallback_index {
+ return Some(key.sampling_pose());
+ }
+ let next = *keys.get(key_index.checked_add(1)?)?;
+ if next.time.0 <= key.time.0 {
+ return Some(key.sampling_pose());
+ }
+ let track = TimedPoseTrack::new(
+ key.sampling_pose(),
+ vec![
+ TimedPoseKey {
+ time: key.time,
+ pose: key.sampling_pose(),
+ },
+ TimedPoseKey {
+ time: next.time,
+ pose: next.sampling_pose(),
+ },
+ ],
+ )
+ .ok()?;
+ track.sample(AnimationTime(f32::from(frame))).ok()
+}
+
/// Returns draw batches for a validated slot.
///
/// # Errors
@@ -1274,6 +1359,61 @@ mod tests {
}
#[test]
+ fn standard_nodes_sample_mapped_key_before_fallback_key() {
+ let mut node = node38([u16::MAX; 15]);
+ node[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
+ node[4..6].copy_from_slice(&0_u16.to_le_bytes());
+ node[6..8].copy_from_slice(&2_u16.to_le_bytes());
+ let model = ModelAsset {
+ node_stride: 38,
+ node_count: 1,
+ nodes_raw: node,
+ slots: Vec::new(),
+ positions: Vec::new(),
+ normals: None,
+ uv0: None,
+ indices: Vec::new(),
+ batches: Vec::new(),
+ node_names: None,
+ animation: Some(ModelAnimation {
+ keys: vec![
+ AnimKey24 {
+ time: AnimationTime(0.0),
+ pose: Pose {
+ translation: [4.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ AnimKey24 {
+ time: AnimationTime(2.0),
+ pose: Pose {
+ translation: [8.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ AnimKey24 {
+ time: AnimationTime(9.0),
+ pose: Pose::default(),
+ },
+ ],
+ frame_map: vec![0, 0, 0],
+ frame_count: 3,
+ }),
+ };
+
+ let pose = node38_sampled_hierarchy(&model, 1)
+ .expect("mapped hierarchy")
+ .poses[0];
+ assert!((pose.translation[0] - 6.0).abs() < f32::EPSILON);
+ assert_eq!(
+ node38_sampled_hierarchy(&model, 9)
+ .expect("fallback hierarchy")
+ .poses[0],
+ Pose::default()
+ );
+ }
+
+ #[test]
fn type2_header_and_slot_tail_framing_are_exact() {
let too_small = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md
index a30b9df..5eef3ed 100644
--- a/docs/tomes/05-render.md
+++ b/docs/tomes/05-render.md
@@ -1841,3 +1841,25 @@ path; он не является оценкой сходства с original fra
`95.29412..288.23532` становятся `2.978..9.007`, что согласуется с TMA Z и
live camera height. Это доказанное coordinate conversion, но оно само по себе
не сделало вершины видимыми и не является заявлением о pixel parity.
+
+### Explicit offline Node38 animation-frame preview
+
+The static Vulkan path now accepts `--static-animation-frame <u16>`. For each
+standard `Node38` model it resolves the already-decoded type-19 map at that
+logical frame, selects its mapped type-8 key when it is valid before the
+node's fallback key, samples the selected key and its immediate successor with
+the portable reference sampler, then composes the resulting parent hierarchy.
+Nodes with no usable map, or a requested frame outside the declared map,
+retain their exact fallback pose. An invalid/incomplete hierarchy deliberately
+falls back to the existing unposed static geometry path instead of guessing a
+runtime state.
+
+This option is a reproducible asset-viewer probe, not a gameplay clock: it
+does not identify the original animation controller, x87 rounding profile,
+clip selection, LOD/group controller, blending or FX timing. The JSON report
+contains `animation_frame` (`null` for the established fallback preview) so
+readbacks from the two modes cannot be mistaken for each other. On 2026-07-18,
+the licensed GOG `MISSIONS/Autodemo.00/data.tma` run with all eight roots,
+`--static-animation-frame 1`, two frames and format `50` completed with 66
+mesh components, 75,543 clip-visible vertices, a 1280×720 / 3,686,400-byte
+readback hash `4638497144561211935`, and validation warnings/errors `0/0`.