diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs index 57ab249..ba67437 100644 --- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs +++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs @@ -174,6 +174,38 @@ pub fn project_msh_to_static_mesh_in_xy_frame( }) } +/// Projects a static MSH preview through the recovered placement transform and +/// then into a shared diagnostic XY frame. +/// +/// This is the diagnostic-camera counterpart of +/// [`project_msh_to_static_mesh_in_world_space_with_node_fallback_poses`]. It +/// deliberately keeps the diagnostic camera itself separate from the original +/// D3D7 camera, while ensuring that mission placement uses the same proven +/// `Rz(z) * Ry(y) * Rx(x)` convention in both preview paths. +/// +/// # Errors +/// +/// Returns [`VulkanAssetMeshError`] when the model 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_fallback_poses( + model: &ModelAsset, + frame: VulkanStaticXyFrame, + transform: LegacyIron3dEulerTransform, + scale: [f32; 3], +) -> Result { + let mut mesh = project_msh_to_static_mesh_in_world_space_with_node_fallback_poses( + model, transform, scale, + )?; + 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 @@ -906,6 +938,32 @@ mod tests { assert_eq!(mesh.vertices[2].position, [10.0, 22.0, 30.0]); } + #[test] + fn xy_preview_applies_recovered_iron3d_orientation_before_framing() { + let source = model( + vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]], + vec![0, 1, 2], + vec![batch(0, 3, 0)], + ); + let frame = + VulkanStaticXyFrame::from_bounds(0.0, 4.0, 0.0, 4.0).expect("valid diagnostic frame"); + + let mesh = project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses( + &source, + frame, + LegacyIron3dEulerTransform { + translation: [2.0, 1.0, 0.0], + orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2], + }, + [1.0; 3], + ) + .expect("diagnostic static mesh"); + + assert!(mesh.vertices[0].position[0].abs() < 0.0001); + assert!(mesh.vertices[0].position[1].abs() < 0.0001); + assert_eq!(mesh.vertices[0].position[2], 0.0); + } + #[test] fn world_space_msh_applies_each_node_fallback_pose_before_root_transform() { let mut nodes = vec![0_u8; 76]; diff --git a/adapters/fparkan-render-vulkan/src/ffi.rs b/adapters/fparkan-render-vulkan/src/ffi.rs index f5428e8..86d8b45 100644 --- a/adapters/fparkan-render-vulkan/src/ffi.rs +++ b/adapters/fparkan-render-vulkan/src/ffi.rs @@ -45,7 +45,9 @@ pub use self::asset_mesh::{ 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_transform, - project_msh_to_static_mesh_in_xy_frame, VulkanAssetMeshError, VulkanStaticXyFrame, + project_msh_to_static_mesh_in_xy_frame, + project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses, VulkanAssetMeshError, + VulkanStaticXyFrame, }; pub use self::capabilities::{ probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request, diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index b16cec1..b93eb39 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -35,9 +35,10 @@ 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, VulkanPlanningBackend, VulkanSmokeFrameOutcome, - VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticCamera, VulkanStaticMaterial, - VulkanStaticMesh, VulkanStaticTexture, VulkanStaticXyFrame, + project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses, VulkanPlanningBackend, + VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, + VulkanStaticCamera, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, + VulkanStaticXyFrame, }; use fparkan_runtime::{ create, frame, load_mission, load_mission_static_preview, load_mission_static_preview_roots, @@ -310,10 +311,13 @@ fn static_preview_mesh_and_materials( ) } else { let frame = xy_frame.ok_or_else(|| "missing diagnostic XY frame".to_string())?; - project_msh_to_static_mesh_in_xy_frame( + project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses( &model.validated, frame, - root.position, + LegacyIron3dEulerTransform { + translation: root.position, + orientation_radians: root.orientation_raw, + }, root.scale, ) } @@ -422,16 +426,16 @@ fn static_preview_xy_frame( format!("static preview visual {visual_id:?} references unknown model {model_id:?}") })?; for position in &model.validated.positions { + let position = LegacyIron3dEulerTransform { + translation: root.position, + orientation_radians: root.orientation_raw, + } + .try_transform_scaled_point(*position, root.scale) + .ok_or_else(|| { + "static preview contains a non-finite transformed XY position".to_string() + })?; extend_static_preview_xy_bounds( - [ - position[0] * root.scale[0] + root.position[0], - position[1] * root.scale[1] + root.position[1], - position[2] * root.scale[2] + root.position[2], - ], - &mut min_x, - &mut max_x, - &mut min_y, - &mut max_y, + position, &mut min_x, &mut max_x, &mut min_y, &mut max_y, )?; } } diff --git a/docs/rendering/renderer_truth_table.md b/docs/rendering/renderer_truth_table.md index cbbaa88..2687bd4 100644 --- a/docs/rendering/renderer_truth_table.md +++ b/docs/rendering/renderer_truth_table.md @@ -13,7 +13,7 @@ | `RecordingBackend` | No | No | Optional CPU-side IDs only | `covered-planning` | Stable command capture for backend-neutral tests | Native window, Vulkan, GPU resource lifetime, pixels | | `NullBackend` | No | No | Optional CPU-side IDs only | Usually `covered` for validation-only rows | Command stream framing and bounds validation | Capture stability, GPU execution, pixels | | `VulkanAssetRenderer` | Yes | Yes | Yes | `covered-gpu` | Static original asset rendering: MSH/Texm/WEAR/MAT0/terrain through Vulkan | Animation/FX parity unless explicitly wired | -| `fparkan-game --backend static-vulkan` | Yes, GOG `Autodemo.00` | Yes, full static AutoDemo preview | Eight mission objects, 66 MSH components, original diffuse TEXM and Land2 terrain base layer | `covered-gpu` for the bounded static-scene bridge | Captured legacy camera, placed translation/scale/Z Euler transform, fallback node hierarchy, source-world geometry, 32-bit GPU indices, diffuse descriptors, terrain base draws and synchronized teardown telemetry | Material phases, Land1 blend/microtexture/lightmaps, animated keys, FX, camera control and gameplay parity | +| `fparkan-game --backend static-vulkan` | Yes, GOG `Autodemo.00` | Yes, full static AutoDemo preview | Eight mission objects, 66 MSH components, original diffuse TEXM and Land2 terrain base layer | `covered-gpu` for the bounded static-scene bridge | Diagnostic XY framing with recovered translation/scale/Z Euler placement, fallback node hierarchy, optional captured legacy camera, 32-bit GPU indices, diffuse descriptors, terrain base draws and synchronized teardown telemetry | Material phases, Land1 blend/microtexture/lightmaps, animated keys, FX, camera control and gameplay parity | | Future rendered `fparkan-game` mode | Yes | Yes | Yes | `covered-gpu` plus original-evidence IDs | Mission-driven render snapshot execution and pixel capture | Original-runtime parity for animation/FX/x87 without dedicated captures | ## Rules @@ -36,10 +36,11 @@ synthetic window descriptor и `VulkanPlanningBackend`. Opt-in `--backend static-vulkan` создаёт native `winit` window. Для GOG `MISSIONS/Autodemo.00/data.tma` он уже рендерит весь статический набор из восьми mission objects и 66 MSH components с - captured legacy camera, доказанным `Rz * Ry * Rx` placement transform, + diagnostic XY camera (or an optional captured legacy camera), доказанным `Rz * Ry * Rx` placement transform, fallback node hierarchy и Land2 base terrain. Последний validation-clean - трёхкадровый запуск имел 67 material descriptors, `clip_visible_vertices=3415` - и readback hash `1275533143935640133`. Это `covered-gpu` для ограниченного + текущий трёхкадровый запуск после применения placement transform в XY path + имел 71 material descriptor, `clip_visible_vertices=75543` и readback hash + `5444013368935681345`. Это `covered-gpu` для ограниченного static-scene bridge, но не pixel parity и не доказательство material phase, Land1 blend, microtexture, lightmap, dynamic animation, FX, live camera selection или gameplay rendering. diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 56144c8..e23a182 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -985,8 +985,11 @@ The bounded native preview now retains the already validated `Land.msh` inside runtime boundary instead of decoding archive formats a second time. It merges one terrain component with the first TMA root's 14 reachable MSH components in one explicit top-down XY frame. The frame is computed from terrain positions -and the root models after applying only the decoded TMA `position` and `scale`. -Raw TMA orientation is deliberately excluded: its convention is not proven. +and the root models after applying decoded TMA placement: non-uniform `scale`, +then the recovered `Rz * Ry * Rx` orientation, then `position`. The XY camera +itself remains diagnostic; applying the established placement transform here +prevents differently oriented mission objects from being framed or rasterized +at a false location. Terrain is assigned an explicit 1x1 white diagnostic texture, rather than a guessed terrain material. `Land.msh` slot selection, `material_tag`, masks, @@ -1004,10 +1007,11 @@ geometry and descriptor set changed; it is not an original-frame comparison. ### Multiple static-preview roots The native bridge now combines every mesh-backed visual of each selected root, -using that root's preserved TMA `position` and `scale` in the shared diagnostic -XY frame. It reports the actual selected root count as `preview_roots`, rather -than implying that all mission objects are rendered. Raw orientation, original -camera/frustum and visibility remain unresolved. +using that root's recovered static TMA placement (`Rz * Ry * Rx` after scale) +in the shared diagnostic XY frame. It reports the actual selected root count as +`preview_roots`, rather than implying that all mission objects are rendered. +The original camera/frustum, dynamic orientation ownership and visibility +remain unresolved. Fresh canonical GOG `Autodemo.00` evidence with `--preview-roots 2` completed in 59.7 seconds: 25 submitted MSH components, one terrain component and 26 @@ -1430,13 +1434,14 @@ runtime wiring, not a claim of scene parity. The geometry side of that handoff is also explicit. The static mesh adapter now has source-world projections for `Land.msh` and MSH: terrain positions retain -all three decoded coordinates; model positions retain Z and apply only the -already decoded mission translation and scale. These APIs preserve triangle -order, batch ranges and packed UV decoding, but do not interpret the still -unproven raw object orientation. The current game preview deliberately stays -on its XY diagnostic projection until a runtime supplies a real legacy camera; -selecting source-world coordinates with identity camera would be a misleading -empty/off-screen viewer rather than a compatibility result. +all three decoded coordinates; model positions retain Z and apply the recovered +static mission placement. The diagnostic XY counterpart uses that same placement +before normalizing X/Y, so it no longer silently omits authored object rotation. +These APIs preserve triangle order, batch ranges and packed UV decoding. The +current game preview deliberately stays on its XY diagnostic projection until a +runtime supplies a real legacy camera; selecting source-world coordinates with +identity camera would be a misleading empty/off-screen viewer rather than a +compatibility result. Static analysis of GOG `iron3d.dll` recovers the placement primitive at RVA `0x36610`: it evaluates a row-major affine `Rz(z) * Ry(y) * Rx(x)` and writes