diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs index 28d8567..69de34f 100644 --- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs +++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs @@ -18,20 +18,20 @@ pub enum VulkanAssetMeshError { IndexOutOfRange, } -/// Shared XZ frame for a deliberately top-down diagnostic static scene. +/// Shared XY frame for a deliberately top-down diagnostic static scene. /// /// It is a CPU-side viewer transform, not evidence of the original camera or /// object transform convention. Keeping it explicit prevents separately /// normalized terrain and model components from being incorrectly overlaid. #[derive(Clone, Copy, Debug, PartialEq)] -pub struct VulkanStaticXzFrame { +pub struct VulkanStaticXyFrame { center_x: f32, - center_z: f32, + center_y: f32, extent: f32, } -impl VulkanStaticXzFrame { - /// Builds a frame that maps the supplied XZ bounds into the static viewer. +impl VulkanStaticXyFrame { + /// Builds a frame that maps the supplied XY bounds into the static viewer. /// /// # Errors /// @@ -40,16 +40,16 @@ impl VulkanStaticXzFrame { pub fn from_bounds( min_x: f32, max_x: f32, - min_z: f32, - max_z: f32, + min_y: f32, + max_y: f32, ) -> Result { - let extent = (max_x - min_x).max(max_z - min_z); + let extent = (max_x - min_x).max(max_y - min_y); if !extent.is_finite() || extent <= f32::EPSILON { return Err(VulkanAssetMeshError::DegenerateViewExtent); } Ok(Self { center_x: (min_x + max_x) * 0.5, - center_z: (min_z + max_z) * 0.5, + center_y: (min_y + max_y) * 0.5, extent, }) } @@ -58,14 +58,14 @@ impl VulkanStaticXzFrame { let scale = 1.6 / self.extent; [ (position[0] - self.center_x) * scale, - (position[2] - self.center_z) * scale, + (position[1] - self.center_y) * scale, ] } fn planar_uv(self, position: [f32; 3]) -> [f32; 2] { [ (position[0] - self.center_x) / self.extent + 0.5, - (position[2] - self.center_z) / self.extent + 0.5, + (position[1] - self.center_y) / self.extent + 0.5, ] } } @@ -75,7 +75,7 @@ impl std::fmt::Display for VulkanAssetMeshError { match self { Self::EmptyGeometry => write!(f, "MSH contains no triangle geometry"), Self::NonFinitePosition => write!(f, "MSH contains a non-finite position"), - Self::DegenerateViewExtent => write!(f, "MSH has a degenerate XZ view extent"), + Self::DegenerateViewExtent => write!(f, "MSH has a degenerate XY view extent"), Self::IndexOutOfRange => write!(f, "MSH index exceeds the static Vulkan u16 contract"), } } @@ -88,7 +88,7 @@ impl std::error::Error for VulkanAssetMeshError {} /// The original engine's node transforms, camera and material pipeline are not /// substituted here. This bridge is intentionally a static asset-viewer step: /// it preserves every batch's `index_start`, `index_count` and `base_vertex`, -/// projects the conventional `Iron3D` XZ ground plane into the current XY shader +/// projects the conventional `Iron3D` XY ground plane into the current XY shader /// input, and scales the used bounds uniformly into the visible clip rectangle. /// /// # Errors @@ -99,12 +99,12 @@ pub fn project_msh_to_static_mesh( model: &ModelAsset, ) -> Result { let (indices, _) = static_model_indices_and_ranges(model)?; - let (min_x, max_x, min_z, max_z) = static_mesh_xz_bounds(&model.positions, &indices)?; - let frame = VulkanStaticXzFrame::from_bounds(min_x, max_x, min_z, max_z)?; - project_msh_to_static_mesh_in_xz_frame(model, frame, [0.0; 3], [1.0; 3]) + let (min_x, max_x, min_y, max_y) = static_mesh_xy_bounds(&model.positions, &indices)?; + let frame = VulkanStaticXyFrame::from_bounds(min_x, max_x, min_y, max_y)?; + project_msh_to_static_mesh_in_xy_frame(model, frame, [0.0; 3], [1.0; 3]) } -/// Projects MSH geometry with a known translation/scale into a shared XZ frame. +/// Projects MSH geometry with a known translation/scale into a shared XY frame. /// /// The caller supplies only transforms already decoded from mission data. Raw /// orientation is intentionally not interpreted until its original convention @@ -114,9 +114,9 @@ pub fn project_msh_to_static_mesh( /// /// Returns [`VulkanAssetMeshError`] when geometry, transform values or the /// static Vulkan input contract cannot represent the source model. -pub fn project_msh_to_static_mesh_in_xz_frame( +pub fn project_msh_to_static_mesh_in_xy_frame( model: &ModelAsset, - frame: VulkanStaticXzFrame, + frame: VulkanStaticXyFrame, translation: [f32; 3], scale: [f32; 3], ) -> Result { @@ -146,7 +146,7 @@ pub fn project_msh_to_static_mesh_in_xz_frame( color: [0.82, 0.72, 0.31], // Iron3D stores Res5 UV0 as signed fixed point with 1/1024 units. // Models that omit this optional stream retain the static viewer's - // XZ planar fallback instead of receiving fabricated raw UV values. + // XY planar fallback instead of receiving fabricated raw UV values. uv: model .uv0 .as_ref() @@ -176,7 +176,7 @@ pub fn project_msh_to_static_mesh_in_xz_frame( /// # Errors /// /// Returns [`VulkanAssetMeshError`] if the terrain has no triangles, contains -/// non-finite positions, has no usable XZ extent, or references data outside +/// non-finite positions, has no usable XY extent, or references data outside /// the current static Vulkan input contract. pub fn project_land_msh_to_static_mesh( terrain: &LandMeshDocument, @@ -195,20 +195,20 @@ pub fn project_land_msh_to_static_mesh( return Err(VulkanAssetMeshError::EmptyGeometry); } - let (min_x, max_x, min_z, max_z) = static_mesh_xz_bounds(&terrain.positions, &indices)?; - let frame = VulkanStaticXzFrame::from_bounds(min_x, max_x, min_z, max_z)?; - project_land_msh_to_static_mesh_in_xz_frame(terrain, frame) + let (min_x, max_x, min_y, max_y) = static_mesh_xy_bounds(&terrain.positions, &indices)?; + let frame = VulkanStaticXyFrame::from_bounds(min_x, max_x, min_y, max_y)?; + project_land_msh_to_static_mesh_in_xy_frame(terrain, frame) } -/// Projects `Land.msh` terrain geometry into a shared diagnostic XZ frame. +/// Projects `Land.msh` terrain geometry into a shared diagnostic XY frame. /// /// # Errors /// /// Returns [`VulkanAssetMeshError`] when terrain geometry or the static Vulkan /// input contract cannot represent the source mesh. -pub fn project_land_msh_to_static_mesh_in_xz_frame( +pub fn project_land_msh_to_static_mesh_in_xy_frame( terrain: &LandMeshDocument, - frame: VulkanStaticXzFrame, + frame: VulkanStaticXyFrame, ) -> Result { let mut indices = Vec::with_capacity( terrain @@ -298,14 +298,14 @@ fn static_model_indices_and_ranges( Ok((indices, draw_ranges)) } -fn static_mesh_xz_bounds( +fn static_mesh_xy_bounds( positions: &[[f32; 3]], indices: &[u16], ) -> Result<(f32, f32, f32, f32), VulkanAssetMeshError> { let mut min_x = f32::INFINITY; let mut max_x = f32::NEG_INFINITY; - let mut min_z = f32::INFINITY; - let mut max_z = f32::NEG_INFINITY; + let mut min_y = f32::INFINITY; + let mut max_y = f32::NEG_INFINITY; for &index in indices { let position = positions .get(usize::from(index)) @@ -315,10 +315,10 @@ fn static_mesh_xz_bounds( } min_x = min_x.min(position[0]); max_x = max_x.max(position[0]); - min_z = min_z.min(position[2]); - max_z = max_z.max(position[2]); + min_y = min_y.min(position[1]); + max_y = max_y.max(position[1]); } - Ok((min_x, max_x, min_z, max_z)) + Ok((min_x, max_x, min_y, max_y)) } #[cfg(test)] @@ -356,13 +356,13 @@ mod tests { } #[test] - fn projects_xz_geometry_and_applies_base_vertex() { + fn projects_xy_geometry_and_applies_base_vertex() { let mesh = project_msh_to_static_mesh(&model( vec![ - [99.0, 0.0, 99.0], - [-2.0, 4.0, -1.0], - [2.0, 8.0, -1.0], - [-2.0, 1.0, 3.0], + [99.0, 99.0, 0.0], + [-2.0, -1.0, 4.0], + [2.0, -1.0, 8.0], + [-2.0, 3.0, 1.0], ], vec![0, 1, 2], vec![batch(0, 3, 1)], @@ -448,10 +448,10 @@ mod tests { slots_raw: Vec::new(), }, positions: vec![ - [-2.0, 5.0, -1.0], - [2.0, 3.0, -1.0], - [-2.0, 9.0, 3.0], - [2.0, 1.0, 3.0], + [-2.0, -1.0, 5.0], + [2.0, -1.0, 3.0], + [-2.0, 3.0, 9.0], + [2.0, 3.0, 1.0], ], normals: Vec::new(), uv0: vec![[1024, -512], [0, 2048], [-1024, 512], [512, 0]], diff --git a/adapters/fparkan-render-vulkan/src/ffi.rs b/adapters/fparkan-render-vulkan/src/ffi.rs index 4e6a79f..260ff03 100644 --- a/adapters/fparkan-render-vulkan/src/ffi.rs +++ b/adapters/fparkan-render-vulkan/src/ffi.rs @@ -40,9 +40,9 @@ mod swapchain_resources; mod validation; pub use self::asset_mesh::{ - project_land_msh_to_static_mesh, project_land_msh_to_static_mesh_in_xz_frame, - project_msh_to_static_mesh, project_msh_to_static_mesh_in_xz_frame, VulkanAssetMeshError, - VulkanStaticXzFrame, + project_land_msh_to_static_mesh, project_land_msh_to_static_mesh_in_xy_frame, + project_msh_to_static_mesh, project_msh_to_static_mesh_in_xy_frame, VulkanAssetMeshError, + VulkanStaticXyFrame, }; pub use self::capabilities::{ probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request, diff --git a/apps/fparkan-cli/src/main.rs b/apps/fparkan-cli/src/main.rs index 6d5fb3c..e118b2e 100644 --- a/apps/fparkan-cli/src/main.rs +++ b/apps/fparkan-cli/src/main.rs @@ -25,8 +25,7 @@ use fparkan_assets::{ decode_mission_payload, extend_graph_report_with_visual_dependencies, TmaProfile, }; use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions}; -use fparkan_inspection::inspect_archive_file; -use fparkan_inspection::ArchiveInspection; +use fparkan_inspection::{inspect_archive_file, inspect_land_msh_bounds_file, ArchiveInspection}; use fparkan_path::{normalize_relative, PathPolicy}; use fparkan_prototype::build_prototype_graph_report; use fparkan_resource::{resource_name, CachedResourceRepository}; @@ -42,6 +41,7 @@ const ARCHIVE_INSPECT_SCHEMA: &str = "fparkan-archive-inspect-v1"; const PROTOTYPE_INSPECT_SCHEMA: &str = "fparkan-prototype-inspect-v1"; const MISSION_GRAPH_SCHEMA: &str = "fparkan-mission-graph-v1"; const MISSION_INSPECT_SCHEMA: &str = "fparkan-mission-inspect-v1"; +const TERRAIN_INSPECT_SCHEMA: &str = "fparkan-terrain-inspect-v1"; #[derive(Serialize)] struct ArchiveInspectOutput<'a> { @@ -114,6 +114,15 @@ struct MissionObjectInspectOutput { scale: [f32; 3], } +#[derive(Serialize)] +struct TerrainInspectOutput { + schema_version: &'static str, + path: String, + positions: usize, + min: [f32; 3], + max: [f32; 3], +} + #[derive(Serialize)] struct GraphFailureOutput { root_index: usize, @@ -178,6 +187,10 @@ fn run(args: &[String]) -> Result<(), String> { let rest = strip_format_json(rest)?; inspect_mission(&rest) } + [domain, command, rest @ ..] if domain == "terrain" && command == "inspect" => { + let rest = strip_format_json(rest)?; + inspect_terrain(&rest) + } _ => Err(usage()), } } @@ -400,6 +413,22 @@ fn inspect_archive(args: &[String]) -> Result<(), String> { } } +fn inspect_terrain(args: &[String]) -> Result<(), String> { + let path = parse_file_path(args, "terrain inspect")?; + let bounds = inspect_land_msh_bounds_file(&path)?; + println!( + "{}", + serialize_json(&TerrainInspectOutput { + schema_version: TERRAIN_INSPECT_SCHEMA, + path: path.display().to_string(), + positions: bounds.positions, + min: bounds.min, + max: bounds.max, + })? + ); + Ok(()) +} + fn archive_inspect_json( path: &str, kind: &str, @@ -416,10 +445,14 @@ fn archive_inspect_json( } fn parse_archive_path(args: &[String]) -> Result { + parse_file_path(args, "archive inspect") +} + +fn parse_file_path(args: &[String], command: &str) -> Result { match args { [path] => Ok(PathBuf::from(path)), [flag, path] if flag == "--file" => Ok(PathBuf::from(path)), - _ => Err("archive inspect requires or --file ".to_string()), + _ => Err(format!("{command} requires or --file ")), } } @@ -472,7 +505,7 @@ fn prototype_graph_requiredness_label( } fn usage() -> String { - "usage: fparkan corpus discover|validate --root [--format json] | archive inspect [--format json] | prototype inspect --root --key [--format json] | mission graph|inspect --root --mission [--format json]".to_string() + "usage: fparkan corpus discover|validate --root [--format json] | archive inspect [--format json] | terrain inspect [--format json] | prototype inspect --root --key [--format json] | mission graph|inspect --root --mission [--format json]".to_string() } #[cfg(test)] @@ -584,4 +617,21 @@ mod tests { "{\"schema_version\":\"fparkan-mission-inspect-v1\",\"mission\":\"MISSIONS/test/data.tma\",\"objects\":[{\"index\":1,\"resource\":\"unit.dat\",\"position\":[1.0,2.0,3.0],\"orientation_raw\":[4.0,5.0,6.0],\"scale\":[7.0,8.0,9.0]}]}" ); } + + #[test] + fn terrain_inspect_output_retains_axis_bounds() { + let json = serialize_json(&TerrainInspectOutput { + schema_version: TERRAIN_INSPECT_SCHEMA, + path: "DATA/MAPS/AutoMAP/Land.msh".to_string(), + positions: 3, + min: [-1.0, -2.0, -3.0], + max: [4.0, 5.0, 6.0], + }) + .expect("serialize terrain inspection"); + + assert_eq!( + json, + "{\"schema_version\":\"fparkan-terrain-inspect-v1\",\"path\":\"DATA/MAPS/AutoMAP/Land.msh\",\"positions\":3,\"min\":[-1.0,-2.0,-3.0],\"max\":[4.0,5.0,6.0]}" + ); + } } diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index b5106ea..86891d7 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -30,10 +30,10 @@ use fparkan_render::{ RenderSnapshotDraw, }; use fparkan_render_vulkan::{ - project_land_msh_to_static_mesh_in_xz_frame, project_msh_to_static_mesh_in_xz_frame, + project_land_msh_to_static_mesh_in_xy_frame, project_msh_to_static_mesh_in_xy_frame, VulkanPlanningBackend, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, - VulkanStaticXzFrame, + VulkanStaticXyFrame, }; use fparkan_runtime::{ create, frame, load_mission, load_mission_static_preview, load_mission_static_preview_roots, @@ -206,7 +206,7 @@ struct StaticPreviewScene { } /// Projects the mission terrain plus every MSH component of the explicitly -/// bounded static-preview root into one diagnostic XZ frame. +/// bounded static-preview root into one diagnostic XY frame. /// /// This intentionally takes only the first MAT0 texture request for each MSH /// batch selector. The terrain uses an explicit white diagnostic texture; @@ -220,7 +220,7 @@ fn static_preview_mesh_and_materials( let terrain_mesh = terrain .source_mesh() .ok_or_else(|| "runtime terrain does not retain its validated source mesh".to_string())?; - let frame = static_preview_xz_frame(assets, terrain, roots)?; + let frame = static_preview_xy_frame(assets, terrain, roots)?; let mut mesh = VulkanStaticMesh { vertices: Vec::new(), indices: Vec::new(), @@ -234,7 +234,7 @@ fn static_preview_mesh_and_materials( rgba8: vec![255, 255, 255, 255], }, }]; - let terrain_component = project_land_msh_to_static_mesh_in_xz_frame(terrain_mesh, frame) + let terrain_component = project_land_msh_to_static_mesh_in_xy_frame(terrain_mesh, frame) .map_err(|err| format!("project mission terrain for Vulkan: {err}"))?; append_static_preview_component(&mut mesh, terrain_component, &[(0, 0)])?; let mut mesh_components = 0; @@ -251,7 +251,7 @@ 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 component = project_msh_to_static_mesh_in_xz_frame( + let component = project_msh_to_static_mesh_in_xy_frame( &model.validated, frame, root.position, @@ -275,20 +275,20 @@ fn static_preview_mesh_and_materials( }) } -fn static_preview_xz_frame( +fn static_preview_xy_frame( assets: &MissionAssets, terrain: &TerrainWorld, roots: &[MissionObjectDraft], -) -> Result { +) -> Result { let mut min_x = f32::INFINITY; let mut max_x = f32::NEG_INFINITY; - let mut min_z = f32::INFINITY; - let mut max_z = f32::NEG_INFINITY; + let mut min_y = f32::INFINITY; + let mut max_y = f32::NEG_INFINITY; let terrain_positions = terrain .source_positions() .ok_or_else(|| "runtime terrain does not retain source positions".to_string())?; for position in terrain_positions { - extend_static_preview_xz_bounds(*position, &mut min_x, &mut max_x, &mut min_z, &mut max_z)?; + extend_static_preview_xy_bounds(*position, &mut min_x, &mut max_x, &mut min_y, &mut max_y)?; } for (object_index, root) in roots.iter().enumerate() { if !root @@ -314,7 +314,7 @@ fn static_preview_xz_frame( format!("static preview visual {visual_id:?} references unknown model {model_id:?}") })?; for position in &model.validated.positions { - extend_static_preview_xz_bounds( + extend_static_preview_xy_bounds( [ position[0] * root.scale[0] + root.position[0], position[1] * root.scale[1] + root.position[1], @@ -322,30 +322,30 @@ fn static_preview_xz_frame( ], &mut min_x, &mut max_x, - &mut min_z, - &mut max_z, + &mut min_y, + &mut max_y, )?; } } } - VulkanStaticXzFrame::from_bounds(min_x, max_x, min_z, max_z) - .map_err(|err| format!("build static preview XZ frame: {err}")) + VulkanStaticXyFrame::from_bounds(min_x, max_x, min_y, max_y) + .map_err(|err| format!("build static preview XY frame: {err}")) } -fn extend_static_preview_xz_bounds( +fn extend_static_preview_xy_bounds( position: [f32; 3], min_x: &mut f32, max_x: &mut f32, - min_z: &mut f32, - max_z: &mut f32, + min_y: &mut f32, + max_y: &mut f32, ) -> Result<(), String> { if !position.iter().all(|value| value.is_finite()) { - return Err("static preview contains a non-finite XZ position".to_string()); + return Err("static preview contains a non-finite XY position".to_string()); } *min_x = min_x.min(position[0]); *max_x = max_x.max(position[0]); - *min_z = min_z.min(position[2]); - *max_z = max_z.max(position[2]); + *min_y = min_y.min(position[1]); + *max_y = max_y.max(position[1]); Ok(()) } diff --git a/crates/fparkan-inspection/src/lib.rs b/crates/fparkan-inspection/src/lib.rs index ff573d6..744670a 100644 --- a/crates/fparkan-inspection/src/lib.rs +++ b/crates/fparkan-inspection/src/lib.rs @@ -142,6 +142,17 @@ pub struct MapInspection { pub grid_height: u32, } +/// Axis-aligned position bounds of a decoded `Land.msh`. +#[derive(Clone, Debug, PartialEq)] +pub struct LandMeshBoundsInspection { + /// Number of source positions covered by the bounds. + pub positions: usize, + /// Per-axis inclusive minimum position. + pub min: [f32; 3], + /// Per-axis inclusive maximum position. + pub max: [f32; 3], +} + /// Supported land file kinds. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum LandFileKind { @@ -450,6 +461,46 @@ pub fn load_land_msh_from_path(path: &Path) -> Result decode_land_msh(&document).map_err(|err| err.to_string()) } +/// Inspects the source-coordinate bounds of a standalone `Land.msh` file. +/// +/// # Errors +/// +/// Returns a string error when the file cannot be read, decoded, or contains +/// no finite source positions. +pub fn inspect_land_msh_bounds_file(path: &Path) -> Result { + let mesh = load_land_msh_from_path(path)?; + inspect_land_msh_bounds(&mesh) +} + +/// Computes source-coordinate bounds for an already validated `Land.msh`. +/// +/// # Errors +/// +/// Returns a string error when the mesh has no finite source positions. +pub fn inspect_land_msh_bounds( + mesh: &LandMeshDocument, +) -> Result { + let mut min = [f32::INFINITY; 3]; + let mut max = [f32::NEG_INFINITY; 3]; + for position in &mesh.positions { + if !position.iter().all(|value| value.is_finite()) { + return Err("Land.msh contains a non-finite source position".to_string()); + } + for axis in 0..3 { + min[axis] = min[axis].min(position[axis]); + max[axis] = max[axis].max(position[axis]); + } + } + if mesh.positions.is_empty() { + return Err("Land.msh contains no source positions".to_string()); + } + Ok(LandMeshBoundsInspection { + positions: mesh.positions.len(), + min, + max, + }) +} + fn inspect_land_msh(document: &NresDocument) -> Result { let land_msh = decode_land_msh(document).map_err(|err| err.to_string())?; Ok(MapInspection { @@ -599,6 +650,7 @@ fn resource_parse_diagnostic( #[cfg(test)] mod tests { use super::*; + use fparkan_terrain_format::TerrainSlotTable; use std::io::Write as _; use std::path::PathBuf; @@ -709,6 +761,31 @@ mod tests { assert_eq!(selected.image.rgba8, vec![0x11, 0x22, 0x33, 0x40]); } + #[test] + fn land_mesh_bounds_preserve_each_source_axis() { + let mesh = LandMeshDocument { + streams: Vec::new(), + nodes_raw: Vec::new(), + slots: TerrainSlotTable { + header_raw: Vec::new(), + slots_raw: Vec::new(), + }, + positions: vec![[4.0, -2.0, 8.0], [-3.0, 6.0, 1.0]], + normals: Vec::new(), + uv0: Vec::new(), + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces: Vec::new(), + }; + + let bounds = inspect_land_msh_bounds(&mesh).expect("bounds"); + + assert_eq!(bounds.positions, 2); + assert_eq!(bounds.min, [-3.0, -2.0, 1.0]); + assert_eq!(bounds.max, [4.0, 6.0, 8.0]); + } + fn temp_dir(name: &str) -> PathBuf { let base = PathBuf::from("/tmp") .join("fparkan-inspection-tests") diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 231f15f..744fa88 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -938,7 +938,7 @@ from a runtime slot or draw order. `fparkan-game --backend static-vulkan` is an explicit native-window experiment, not the default planning path. It visits only the first mission root, selects -every prepared MSH component of that root, merges their static XZ clip-space +every prepared MSH component of that root, merges their static XY clip-space geometry, and renders a requested number of frames through `VulkanSmokeRenderer`; teardown rejects validation warnings/errors and reports swapchain/readback telemetry. For each used `Batch20.material_index`, it resolves @@ -984,7 +984,7 @@ The bounded native preview now retains the already validated `Land.msh` inside `TerrainWorld`; this lets the application consume source geometry through the 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 XZ frame. The frame is computed from terrain positions +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. @@ -1005,7 +1005,7 @@ geometry and descriptor set changed; it is not an original-frame comparison. 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 -XZ frame. It reports the actual selected root count as `preview_roots`, rather +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. @@ -1014,8 +1014,8 @@ in 59.7 seconds: 25 submitted MSH components, one terrain component and 26 descriptors on the native 1280x720 two-image swapchain, with validation warnings/errors `0/0`. The readback FNV-1a remained `10739087367165646439`, equal to the one-root terrain/root run. That equality -does **not** prove the second root is visible or equivalent: it is recorded as -a camera/frustum/overlap investigation target, not hidden as a parity result. +does **not** prove the second root is visible or equivalent: it was recorded as +an investigation target, not hidden as a parity result. The reproducible `fparkan-cli mission inspect` probe closes two tempting but incorrect explanations for that equality. GOG `Autodemo.00` root 0 is @@ -1023,11 +1023,20 @@ incorrect explanations for that equality. GOG `Autodemo.00` root 0 is `w_s_wlk1.dat` at `(479.12396, 795.95337, 1.6228507)`; therefore the TMA data is neither duplicate nor co-located. Separately, the Vulkan static submit loop calls `cmd_draw_indexed` for every prepared draw range and its depth comparison -is `LESS_OR_EQUAL`. The unresolved equality is consequently evidence about the -diagnostic camera/projection or visibility framing, not grounds to patch a -missing draw or a `LESS` depth bug. The inspector intentionally reports raw -orientation as well as position and scale so later camera/transform work can -be checked against the original mission bytes. +is `LESS_OR_EQUAL`. A later direct `Land.msh` bounds probe identified the actual +static-viewer defect: GOG `AutoMAP/Land.msh` spans X/Y +`0..1190.6976` but Z only `0..94.50981`; the same TMA objects use X/Y for their +world placement and a small Z height. The old XZ CPU frame discarded the second +horizontal TMA component and treated height as the screen axis. The renderer now +uses a shared XY CPU frame and retains Z as geometry height. This proves only +the source-axis convention needed by the diagnostic bridge; it does not recover +the original camera, orientation order, culling or projection matrix. + +The new `fparkan-cli terrain inspect ` exposes these three-axis source +bounds without a renderer. The first XY GPU rechecks were intentionally bounded +to 120 seconds but remained at the existing `Graph` loading checkpoint and were +terminated without a native frame/report; therefore no new readback hash or GPU +acceptance result is claimed for the corrected projection yet. ### Camera ownership boundary from the GOG renderer @@ -1051,7 +1060,7 @@ This establishes that original camera creation/selection is Terrain-owned and not a field that can safely be inferred from a TMA transform. It does **not** yet establish world-to-view matrix layout, FOV, near/far values, projection handedness, or initial mission camera selection. The Vulkan path must therefore -continue to label its XZ projection as diagnostic until a dynamic capture or +continue to label its XY projection as diagnostic until a dynamic capture or further Terrain disassembly proves these contracts. The public `World3D.dll!stdSetCurrentCamera` is a one-pointer `__stdcall` @@ -1070,7 +1079,7 @@ matrix convention; they must not be treated as a usable renderer ABI yet. `Land.msh` использует отдельный geometry-only bridge: validated `TerrainFace28` сохраняет source triangle order, а его positions и packed UV0 попадают в тот же -static vertex/index upload path. Для текущего диагностического viewer XZ bounds +static vertex/index upload path. Для текущего диагностического viewer XY bounds нормализуются в clip-space, packed UV0 декодируется как signed `int16 / 1024`. Один static draw range намеренно не трактует `material_tag`, surface mask, slots или auxiliary streams как material/pipeline state: их связь с original terrain