aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock1
-rw-r--r--README.md2
-rw-r--r--adapters/fparkan-render-vulkan/Cargo.toml1
-rw-r--r--adapters/fparkan-render-vulkan/src/asset_mesh.rs145
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi.rs4
-rw-r--r--apps/fparkan-vulkan-smoke/src/main.rs55
-rw-r--r--crates/fparkan-inspection/src/lib.rs15
-rw-r--r--docs/tomes/05-render.md9
-rw-r--r--fixtures/acceptance/coverage.tsv2
9 files changed, 221 insertions, 13 deletions
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<VulkanStaticMesh, VulkanAssetMeshError> {
+ 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<u16>, batches: Vec<Batch>) -> 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())
+ }
}
}
@@ -1488,6 +1507,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(&[
"--out".to_string(),
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<MapInspectio
}
}
+/// Loads and validates a standalone `Land.msh` file.
+///
+/// # Errors
+///
+/// Returns a human-readable error if the file cannot be read, decoded as `NRes`,
+/// or decoded as the specialized terrain mesh format.
+pub fn load_land_msh_from_path(path: &Path) -> Result<LandMeshDocument, String> {
+ 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<MapInspection, String> {
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 <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH --texture-root <GOG_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 <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH --texture-root <GOG_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 <GOG_ROOT> --model-archive fortif.rlb --model-name FR_L_MTP.msh --wear-root <GOG_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 <GOG_ROOT>/DATA/MAPS/11/Land.msh --texture-root <GOG_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 <GOG_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