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 { + 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 @@ -307,6 +341,45 @@ pub fn project_msh_to_static_mesh_in_world_space_with_node_fallback_poses( model: &ModelAsset, transform: LegacyIron3dEulerTransform, scale: [f32; 3], +) -> Result { + 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 { + 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, ) -> Result { if !transform .translation @@ -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 { 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, } /// 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, + static_animation_frame: Option, root: &std::path::Path, land_msh_path: &str, ) -> Result { @@ -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, 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, preview_roots: NonZeroUsize, legacy_camera_capture: Option, + static_animation_frame: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -1044,6 +1072,7 @@ enum RenderBackendMode { } impl Args { + #[allow(clippy::too_many_lines)] fn parse(args: &[String]) -> Result { 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 --mission [--frames ] [--backend ] [--preview-roots ] [--legacy-camera-capture ] [--readback-out ] [--load-progress ]".to_string() + "usage: fparkan-game --root --mission [--frames ] [--backend ] [--preview-roots ] [--legacy-camera-capture ] [--static-animation-frame ] [--readback-out ] [--load-progress ]".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, }) ); } @@ -1356,6 +1402,34 @@ mod tests { assert_eq!(parsed.readback_out, Some(PathBuf::from("target/frame.raw"))); } + #[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(&[ @@ -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 { 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 { + 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 { + 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 @@ -1273,6 +1358,61 @@ mod tests { assert!((hierarchy.poses[1].translation[1] - 2.0).abs() < 0.001); } + #[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(&[ 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 `. 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`.