diff --git a/Cargo.lock b/Cargo.lock index 3f1e21c..d433c53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -565,6 +565,7 @@ dependencies = [ "fparkan-msh", "fparkan-platform", "fparkan-render", + "fparkan-terrain-format", "serde", "serde_json", ] diff --git a/README.md b/README.md index 1b63d68..029bec5 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Open source проект с реализацией компонентов игр - `fparkan-vulkan-smoke` доказывает живой Stage 0 Vulkan triangle path с native window, swapchain и validation telemetry. - `VulkanPlanningBackend` и `fparkan-game` подтверждают только deterministic command planning/capture, а не draw пикселей. -- `fparkan-viewer` пока является инспектором ассетов. Stage 3 GPU vertical slice для оригинального `MSH`/`Texm`/`WEAR`/`MAT0`/terrain еще не закрыт. +- `fparkan-viewer` пока является инспектором ассетов. `fparkan-vulkan-smoke` имеет live Stage 3 bridge для original `MSH`/`Texm`/`WEAR`/`MAT0` и geometry-only `Land.msh`; полноценный viewer, исходные terrain-material states, camera и pixel parity ещё не закрыты. - Truth table и evidence-артефакты вынесены в [`docs/rendering/renderer_truth_table.md`](docs/rendering/renderer_truth_table.md) и [`docs/evidence/`](docs/evidence). ## Тестирование diff --git a/adapters/fparkan-render-vulkan/Cargo.toml b/adapters/fparkan-render-vulkan/Cargo.toml index 909ac1f..485249f 100644 --- a/adapters/fparkan-render-vulkan/Cargo.toml +++ b/adapters/fparkan-render-vulkan/Cargo.toml @@ -13,6 +13,7 @@ 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" } fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" } +fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format", version = "0.1.0" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs index d861301..e7bfff4 100644 --- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs +++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs @@ -3,6 +3,7 @@ use crate::{VulkanStaticDrawRange, VulkanStaticMesh, VulkanStaticVertex}; use fparkan_msh::ModelAsset; use fparkan_render::LegacyPipelineState; +use fparkan_terrain_format::LandMeshDocument; /// Error returned when a validated MSH cannot enter the current static GPU path. #[derive(Clone, Debug, Eq, PartialEq)] @@ -132,10 +133,108 @@ pub fn project_msh_to_static_mesh( }) } +/// Projects validated `Land.msh` terrain geometry into the static Vulkan path. +/// +/// This bridge preserves the source triangle order from `TerrainFace28` and +/// consumes its validated position and UV0 streams. It is deliberately a +/// geometry-only terrain slice: source slot/material selection, camera +/// transforms, fog and surface-mask shading need their own evidence before +/// they can be modeled as renderer state. +/// +/// # Errors +/// +/// Returns [`VulkanAssetMeshError`] if the terrain has no triangles, contains +/// non-finite positions, has no usable XZ extent, or references data outside +/// the current static Vulkan input contract. +pub fn project_land_msh_to_static_mesh( + terrain: &LandMeshDocument, +) -> Result { + let mut indices = Vec::with_capacity( + terrain + .faces + .len() + .checked_mul(3) + .ok_or(VulkanAssetMeshError::IndexOutOfRange)?, + ); + for face in &terrain.faces { + indices.extend(face.vertices); + } + if indices.is_empty() { + return Err(VulkanAssetMeshError::EmptyGeometry); + } + + let (min_x, max_x, min_z, max_z) = static_mesh_xz_bounds(&terrain.positions, &indices)?; + let extent = (max_x - min_x).max(max_z - min_z); + if !extent.is_finite() || extent <= f32::EPSILON { + return Err(VulkanAssetMeshError::DegenerateViewExtent); + } + let center_x = (min_x + max_x) * 0.5; + let center_z = (min_z + max_z) * 0.5; + let scale = 1.6 / extent; + let vertices = terrain + .positions + .iter() + .enumerate() + .map(|(index, position)| VulkanStaticVertex { + position: [ + (position[0] - center_x) * scale, + (position[2] - center_z) * scale, + ], + color: [0.31, 0.58, 0.27], + uv: terrain.uv0.get(index).map_or( + [ + (position[0] - center_x) / extent + 0.5, + (position[2] - center_z) / extent + 0.5, + ], + |uv| [f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0], + ), + }) + .collect(); + + Ok(VulkanStaticMesh { + vertices, + indices, + draw_ranges: vec![VulkanStaticDrawRange { + first_index: 0, + index_count: u32::try_from(terrain.faces.len()) + .map_err(|_| VulkanAssetMeshError::IndexOutOfRange)? + .checked_mul(3) + .ok_or(VulkanAssetMeshError::IndexOutOfRange)?, + material_index: 0, + pipeline_state: LegacyPipelineState::default(), + alpha_test_reference: 0, + }], + }) +} + +fn static_mesh_xz_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; + for &index in indices { + let position = positions + .get(usize::from(index)) + .ok_or(VulkanAssetMeshError::IndexOutOfRange)?; + if !position.iter().all(|value| value.is_finite()) { + return Err(VulkanAssetMeshError::NonFinitePosition); + } + 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]); + } + Ok((min_x, max_x, min_z, max_z)) +} + #[cfg(test)] mod tests { use super::*; use fparkan_msh::{Batch, ModelAsset}; + use fparkan_terrain_format::{FullSurfaceMask, TerrainFace28, TerrainSlotTable}; fn model(positions: Vec<[f32; 3]>, indices: Vec, batches: Vec) -> ModelAsset { ModelAsset { @@ -247,4 +346,50 @@ mod tests { ] ); } + + #[test] + fn projects_land_faces_in_source_order_with_packed_uv0() { + let terrain = LandMeshDocument { + streams: Vec::new(), + nodes_raw: Vec::new(), + slots: TerrainSlotTable { + header_raw: Vec::new(), + 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], + ], + normals: Vec::new(), + uv0: vec![[1024, -512], [0, 2048], [-1024, 512], [512, 0]], + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces: vec![terrain_face([0, 1, 2]), terrain_face([1, 3, 2])], + }; + + let mesh = project_land_msh_to_static_mesh(&terrain).expect("representable terrain"); + + assert_eq!(mesh.indices, vec![0, 1, 2, 1, 3, 2]); + assert_eq!(mesh.draw_ranges.len(), 1); + assert_eq!(mesh.draw_ranges[0].index_count, 6); + assert_eq!(mesh.vertices[0].position, [-0.8, -0.8]); + assert_eq!(mesh.vertices[3].position, [0.8, 0.8]); + assert_eq!(mesh.vertices[0].uv, [1.0, -0.5]); + assert_eq!(mesh.vertices[2].uv, [-1.0, 0.5]); + } + + fn terrain_face(vertices: [u16; 3]) -> TerrainFace28 { + TerrainFace28 { + flags: FullSurfaceMask(0), + material_tag: 0, + aux_tag: 0, + vertices, + neighbors: [None; 3], + tail_raw: [0; 8], + raw: [0; 28], + } + } } diff --git a/adapters/fparkan-render-vulkan/src/ffi.rs b/adapters/fparkan-render-vulkan/src/ffi.rs index 04103b4..610401a 100644 --- a/adapters/fparkan-render-vulkan/src/ffi.rs +++ b/adapters/fparkan-render-vulkan/src/ffi.rs @@ -39,7 +39,9 @@ mod swapchain; mod swapchain_resources; mod validation; -pub use self::asset_mesh::{project_msh_to_static_mesh, VulkanAssetMeshError}; +pub use self::asset_mesh::{ + project_land_msh_to_static_mesh, project_msh_to_static_mesh, VulkanAssetMeshError, +}; pub use self::capabilities::{ probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request, VulkanRuntimeCapabilityError, VulkanRuntimeCapabilityProbe, diff --git a/apps/fparkan-vulkan-smoke/src/main.rs b/apps/fparkan-vulkan-smoke/src/main.rs index 48bd766..2b7e00b 100644 --- a/apps/fparkan-vulkan-smoke/src/main.rs +++ b/apps/fparkan-vulkan-smoke/src/main.rs @@ -14,10 +14,10 @@ use fparkan_platform::RenderRequest; use fparkan_platform_winit::{window_native_handles, WinitWindowPlan}; use fparkan_render_vulkan::{ - project_msh_to_static_mesh, VulkanSmokeBootstrapProgress, VulkanSmokeFrameOutcome, - VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererReport, - VulkanSmokeShutdownReport, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, - VulkanValidationReport, + project_land_msh_to_static_mesh, project_msh_to_static_mesh, VulkanSmokeBootstrapProgress, + VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, + VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanStaticMaterial, VulkanStaticMesh, + VulkanStaticTexture, VulkanValidationReport, }; use serde::Serialize; use std::path::PathBuf; @@ -179,6 +179,9 @@ enum MeshInput { archive: String, name: String, }, + LandMsh { + path: PathBuf, + }, } impl MeshInput { @@ -186,13 +189,14 @@ impl MeshInput { match self { Self::SmokeTriangle => "synthetic-smoke-triangle", Self::Msh { .. } => "original-msh", + Self::LandMsh { .. } => "original-land-msh", } } fn archive(&self) -> &str { match self { - Self::SmokeTriangle => "", Self::Msh { archive, .. } => archive, + Self::SmokeTriangle | Self::LandMsh { .. } => "", } } @@ -200,6 +204,7 @@ impl MeshInput { match self { Self::SmokeTriangle => "", Self::Msh { name, .. } => name, + Self::LandMsh { .. } => "Land.msh", } } } @@ -214,6 +219,7 @@ impl SmokeOptions { let mut model_root = None; let mut model_archive = None; let mut model_name = None; + let mut terrain_path = None; let mut texture_root = None; let mut texture_archive = None; let mut texture_name = None; @@ -273,6 +279,13 @@ impl SmokeOptions { .ok_or_else(|| "--model-name requires a value".to_string())?, ); } + "--terrain-path" => { + terrain_path = Some( + iter.next() + .map(PathBuf::from) + .ok_or_else(|| "--terrain-path requires a path".to_string())?, + ); + } "--texture-root" => { texture_root = Some( iter.next() @@ -335,19 +348,21 @@ impl SmokeOptions { if timeout_seconds == 0 { return Err("native smoke requires --timeout-seconds >= 1".to_string()); } - let mesh_input = match (model_root, model_archive, model_name) { - (None, None, None) => MeshInput::SmokeTriangle, - (Some(root), Some(archive), Some(name)) => MeshInput::Msh { + let mesh_input = match (terrain_path, model_root, model_archive, model_name) { + (Some(path), None, None, None) => MeshInput::LandMsh { path }, + (None, None, None, None) => MeshInput::SmokeTriangle, + (None, Some(root), Some(archive), Some(name)) => MeshInput::Msh { root, archive, name, }, - _ => { + (None, _, _, _) => { return Err( "--model-root, --model-archive and --model-name must be supplied together" .to_string(), ); } + _ => return Err("--terrain-path cannot be combined with --model-*".to_string()), }; let texture_input = match (texture_root, texture_archive, texture_name) { (None, None, None) => None, @@ -414,6 +429,10 @@ impl SmokeOptions { let model = fparkan_inspection::load_model_from_root(root, archive, name)?; project_msh_to_static_mesh(&model).map_err(|err| err.to_string()) } + MeshInput::LandMsh { path } => { + let terrain = fparkan_inspection::load_land_msh_from_path(path)?; + project_land_msh_to_static_mesh(&terrain).map_err(|err| err.to_string()) + } } } @@ -1487,6 +1506,24 @@ mod tests { )); } + #[test] + fn parses_original_land_msh_as_direct_terrain_input() { + let parsed = SmokeOptions::parse(&[ + "--out".to_string(), + "target/report.json".to_string(), + "--terrain-path".to_string(), + "C:/GOG/DATA/MAPS/11/Land.msh".to_string(), + ]); + + assert!(matches!( + parsed, + Ok(SmokeOptions { + mesh_input: MeshInput::LandMsh { path }, + .. + }) if path == std::path::Path::new("C:/GOG/DATA/MAPS/11/Land.msh") + )); + } + #[test] fn rejects_partial_original_msh_input() { let parsed = SmokeOptions::parse(&[ diff --git a/crates/fparkan-inspection/src/lib.rs b/crates/fparkan-inspection/src/lib.rs index a23baac..ff573d6 100644 --- a/crates/fparkan-inspection/src/lib.rs +++ b/crates/fparkan-inspection/src/lib.rs @@ -29,7 +29,7 @@ use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile}; use fparkan_path::{normalize_relative, PathPolicy}; use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository}; use fparkan_rsli::decode as decode_rsli; -use fparkan_terrain_format::{decode_land_map, decode_land_msh}; +use fparkan_terrain_format::{decode_land_map, decode_land_msh, LandMeshDocument}; use fparkan_texm::decode_texm; use fparkan_vfs::{DirectoryVfs, Vfs}; use std::fs; @@ -437,6 +437,19 @@ pub fn inspect_land_file(path: &Path, kind: LandFileKind) -> Result Result { + let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?; + let document = decode_nres(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible) + .map_err(|err| err.to_string())?; + decode_land_msh(&document).map_err(|err| err.to_string()) +} + fn inspect_land_msh(document: &NresDocument) -> Result { let land_msh = decode_land_msh(document).map_err(|err| err.to_string())?; Ok(MapInspection { diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 50fe0e4..fbbc928 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -859,6 +859,15 @@ dynamic material data и не входит в `PipelineKey`. Также не у дополнительного дизассемблирования. Это частично реализованная compatibility boundary, а не заявление о готовой parity fixed-function state. +`Land.msh` использует отдельный geometry-only bridge: validated `TerrainFace28` +сохраняет source triangle order, а его positions и packed UV0 попадают в тот же +static vertex/index upload path. Для текущего диагностического viewer XZ bounds +нормализуются в clip-space, packed UV0 декодируется как signed `int16 / 1024`. +Один static draw range намеренно не трактует `material_tag`, surface mask, slots +или auxiliary streams как material/pipeline state: их связь с original terrain +renderer пока не доказана. GOG `DATA/MAPS/11/Land.msh` дал 6 458 vertices и +24 672 indices в 300-frame native Vulkan run без validation warning/error. + Выбор depth/stencil attachment отделён от renderer lifetime: `select_depth_stencil_attachment_format` применяет тот же фиксированный порядок форматов, что и capability gate, к фактически поддерживаемому списку GPU. diff --git a/fixtures/acceptance/coverage.tsv b/fixtures/acceptance/coverage.tsv index 42d5590..74d681c 100644 --- a/fixtures/acceptance/coverage.tsv +++ b/fixtures/acceptance/coverage.tsv @@ -299,7 +299,7 @@ S3-VK-TEXM-UPLOAD-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release -- S3-VK-DESCRIPTOR-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-texm-sampled.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root --model-archive system.rlb --model-name MTCHECK.MSH --texture-root --texture-archive Textures.lib --texture-name DEFAULT.0; original TEXM is bound as set=0,binding=0 combined image sampler and sampled in fragment shader; validation_warning_count=0 and validation_error_count=0 S3-VK-PIPELINE-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-alpha-test-smoke.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root --model-archive system.rlb --model-name MTCHECK.MSH --texture-root --texture-archive Textures.lib --texture-name DEFAULT.0; PipelineKey selects a deduplicated live Vulkan pipeline per static draw range; capability-selected depth attachment supports TestWrite/TestReadOnly; fragment alpha test receives per-range dynamic cutoff through push constants; baseline key is live-proven, while Batch20/MAT0 state/reference mapping remains an evidence gap S3-VK-DRAW-MODEL-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-fr-l-mtp-per-batch-materials.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root --model-archive fortif.rlb --model-name FR_L_MTP.msh --wear-root --wear-archive fortif.rlb --wear-name FR_L_MTP.WEA; live Win32 Vulkan issues 237 source-preserving indexed batch draws and binds the deduplicated WEAR→MAT0 material descriptor selected by each Batch20.material_index (this corpus model has selector 0 only) -S3-VK-DRAW-TERRAIN-001 blocked awaits Stage 3 terrain Vulkan draw path +S3-VK-DRAW-TERRAIN-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-land-11-smoke.json --frames 300 --resize-frame 120 --timeout-seconds 90 --terrain-path /DATA/MAPS/11/Land.msh --texture-root --texture-archive Textures.lib --texture-name DEFAULT.0; validated Land.msh TerrainFace28 order becomes one live indexed Vulkan terrain draw (6458 vertices, 24672 indices); terrain material/slot/camera semantics remain an evidence gap S3-VK-PIXEL-CAPTURE-001 blocked awaits Stage 3 fixed-camera pixel capture approval flow S3-VK-VALIDATION-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-mtcheck-msh.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root --model-archive system.rlb --model-name MTCHECK.MSH; validation_warning_count=0 and validation_error_count=0 S3-GL-001 omitted legacy OpenGL adapters are outside the Windows-only Vulkan renderer scope