diff --git a/Cargo.lock b/Cargo.lock index 12f99bc..34a652b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -447,6 +447,7 @@ name = "fparkan-game" version = "0.1.0" dependencies = [ "fparkan-assets", + "fparkan-inspection", "fparkan-platform", "fparkan-platform-winit", "fparkan-render", diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs index 959b51a..57ab249 100644 --- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs +++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs @@ -420,8 +420,10 @@ fn rotate_by_quaternion(position: [f32; 3], rotation: [f32; 4]) -> [f32; 3] { /// 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 +/// consumes its validated position and UV0 streams. Contiguous source faces are +/// retained as separate draw ranges keyed by their exact packed +/// `TerrainFace28.material_tag`, so a caller can resolve the map-local +/// material contract without changing topology. Slot selection, camera /// transforms, fog and surface-mask shading need their own evidence before /// they can be modeled as renderer state. /// @@ -592,16 +594,32 @@ fn static_terrain_indices(terrain: &LandMeshDocument) -> Result, Vulkan fn static_terrain_draw_ranges( terrain: &LandMeshDocument, ) -> Result, VulkanAssetMeshError> { - Ok(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, - }]) + let mut ranges = Vec::new(); + let mut start_face = 0usize; + while start_face < terrain.faces.len() { + let material_index = terrain.faces[start_face].material_tag; + let mut end_face = start_face + 1; + while end_face < terrain.faces.len() + && terrain.faces[end_face].material_tag == material_index + { + end_face += 1; + } + ranges.push(VulkanStaticDrawRange { + first_index: u32::try_from(start_face) + .map_err(|_| VulkanAssetMeshError::IndexOutOfRange)? + .checked_mul(3) + .ok_or(VulkanAssetMeshError::IndexOutOfRange)?, + index_count: u32::try_from(end_face - start_face) + .map_err(|_| VulkanAssetMeshError::IndexOutOfRange)? + .checked_mul(3) + .ok_or(VulkanAssetMeshError::IndexOutOfRange)?, + material_index, + pipeline_state: LegacyPipelineState::default(), + alpha_test_reference: 0, + }); + start_face = end_face; + } + Ok(ranges) } fn static_model_indices_and_ranges( @@ -1109,9 +1127,13 @@ mod tests { } fn terrain_face(vertices: [u16; 3]) -> TerrainFace28 { + terrain_face_with_tag(0, vertices) + } + + fn terrain_face_with_tag(material_tag: u16, vertices: [u16; 3]) -> TerrainFace28 { TerrainFace28 { flags: FullSurfaceMask(0), - material_tag: 0, + material_tag, aux_tag: 0, vertices, neighbors: [None; 3], @@ -1119,4 +1141,41 @@ mod tests { raw: [0; 28], } } + + #[test] + fn terrain_ranges_preserve_face_order_and_packed_material_tags() { + let terrain = LandMeshDocument { + streams: Vec::new(), + nodes_raw: Vec::new(), + slots: TerrainSlotTable { + header_raw: Vec::new(), + slots_raw: Vec::new(), + }, + positions: vec![[0.0, 0.0, 0.0]; 3], + normals: Vec::new(), + uv0: vec![[0, 0]; 3], + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces: vec![ + terrain_face_with_tag(0xff01, [0, 1, 2]), + terrain_face_with_tag(0xff01, [0, 1, 2]), + terrain_face_with_tag(0x0102, [0, 1, 2]), + terrain_face_with_tag(0xff01, [0, 1, 2]), + ], + }; + + let ranges = static_terrain_draw_ranges(&terrain).expect("representable terrain"); + + assert_eq!(ranges.len(), 3); + assert_eq!(ranges[0].first_index, 0); + assert_eq!(ranges[0].index_count, 6); + assert_eq!(ranges[0].material_index, 0xff01); + assert_eq!(ranges[1].first_index, 6); + assert_eq!(ranges[1].index_count, 3); + assert_eq!(ranges[1].material_index, 0x0102); + assert_eq!(ranges[2].first_index, 9); + assert_eq!(ranges[2].index_count, 3); + assert_eq!(ranges[2].material_index, 0xff01); + } } diff --git a/apps/fparkan-game/Cargo.toml b/apps/fparkan-game/Cargo.toml index 3feec67..f58d2a7 100644 --- a/apps/fparkan-game/Cargo.toml +++ b/apps/fparkan-game/Cargo.toml @@ -7,6 +7,7 @@ repository.workspace = true [dependencies] fparkan-assets = { path = "../../crates/fparkan-assets", version = "0.1.0" } +fparkan-inspection = { path = "../../crates/fparkan-inspection", 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-platform-winit = { path = "../../adapters/fparkan-platform-winit", version = "0.1.0" } diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index df92d7f..9a30c60 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -22,6 +22,7 @@ //! `FParkan` render-planning composition root. use fparkan_assets::{PreparedTextureUsage, PreparedVisual}; +use fparkan_inspection::load_standalone_wear_material_texture_mip0_rgba8_from_root; use fparkan_platform::WindowPort; use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan}; use fparkan_render::{ @@ -102,7 +103,14 @@ fn run(args: &[String]) -> Result { .as_deref() .map(load_legacy_camera_capture) .transpose()?; - let preview = static_preview_mesh_and_materials(mission_assets, terrain, roots, camera)?; + let preview = static_preview_mesh_and_materials( + mission_assets, + terrain, + roots, + camera, + &args.root, + &loaded.land_msh_path, + )?; return run_static_vulkan_mode( preview, roots.len(), @@ -236,14 +244,18 @@ struct StaticPreviewScene { /// 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; -/// terrain slot/material selection, orientation, camera, later material phases, -/// animation, lightmaps and gameplay visibility remain outside this bridge. +/// batch selector. Terrain maps its proven low-byte packed tag channel through +/// the map-local `Land2.wea` table. The high-byte `Land1.wea` channel is +/// retained on draw ranges but awaits a recovered blend equation; orientation, +/// later material phases, animation, lightmaps and gameplay visibility remain +/// outside this bridge. fn static_preview_mesh_and_materials( assets: &MissionAssets, terrain: &TerrainWorld, roots: &[MissionObjectDraft], legacy_camera: Option, + root: &std::path::Path, + land_msh_path: &str, ) -> Result { let terrain_mesh = terrain .source_mesh() @@ -257,14 +269,7 @@ fn static_preview_mesh_and_materials( indices: Vec::new(), draw_ranges: Vec::new(), }; - let mut materials = vec![VulkanStaticMaterial { - material_index: 0, - texture: VulkanStaticTexture { - width: 1, - height: 1, - rgba8: vec![255, 255, 255, 255], - }, - }]; + let mut materials = Vec::new(); let terrain_component = if legacy_camera.is_some() { project_land_msh_to_static_mesh_in_legacy_world_space(terrain_mesh) } else { @@ -272,7 +277,13 @@ fn static_preview_mesh_and_materials( 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 terrain_materials = static_preview_terrain_base_materials( + root, + land_msh_path, + &terrain_component, + &mut materials, + )?; + append_static_preview_component(&mut mesh, terrain_component, &terrain_materials)?; let mut mesh_components = 0; for (object_index, root) in roots.iter().enumerate() { for visual_id in assets.visuals_for_object(object_index) { @@ -445,6 +456,53 @@ fn extend_static_preview_xy_bounds( Ok(()) } +fn static_preview_terrain_base_materials( + root: &std::path::Path, + land_msh_path: &str, + mesh: &VulkanStaticMesh, + materials: &mut Vec, +) -> Result, String> { + let land_path = root.join(land_msh_path); + let wear_path = land_path + .parent() + .ok_or_else(|| format!("terrain mesh has no parent path: {}", land_path.display()))? + .join("Land2.wea"); + let mut packed_tags = mesh + .draw_ranges + .iter() + .map(|range| range.material_index) + .collect::>(); + packed_tags.sort_unstable(); + packed_tags.dedup(); + packed_tags + .into_iter() + .map(|packed_tag| { + let selector = packed_tag & 0x00ff; + let selected = load_standalone_wear_material_texture_mip0_rgba8_from_root( + root, &wear_path, selector, + ) + .map_err(|err| { + format!( + "resolve terrain base material tag 0x{packed_tag:04x} through {}: {err}", + wear_path.display() + ) + })?; + let preview_selector = u16::try_from(materials.len()).map_err(|_| { + "static preview exceeds the available 16-bit material selector space".to_string() + })?; + materials.push(VulkanStaticMaterial { + material_index: preview_selector, + texture: VulkanStaticTexture { + width: selected.image.width, + height: selected.image.height, + rgba8: selected.image.rgba8, + }, + }); + Ok((packed_tag, preview_selector)) + }) + .collect() +} + fn static_preview_component_materials( assets: &MissionAssets, visual: &PreparedVisual, diff --git a/crates/fparkan-inspection/src/lib.rs b/crates/fparkan-inspection/src/lib.rs index 7a6a2c7..e2b9068 100644 --- a/crates/fparkan-inspection/src/lib.rs +++ b/crates/fparkan-inspection/src/lib.rs @@ -484,6 +484,43 @@ pub fn load_wear_material_texture_mip0_rgba8_from_root( }) } +/// Resolves phase-zero diffuse TEXM through a standalone map-local WEAR file. +/// +/// Map terrain keeps its two WEAR tables adjacent to `Land.msh`, while their +/// MAT0 and TEXM resources remain in the game root's normal archives. This +/// preserves that split instead of treating the sidecar as an archive entry. +/// +/// # Errors +/// +/// Returns a string error if the standalone WEAR file, global material +/// resources, or selected diffuse TEXM cannot be decoded. +pub fn load_standalone_wear_material_texture_mip0_rgba8_from_root( + root: &Path, + wear_path: &Path, + material_index: u16, +) -> Result { + let wear_bytes = + fs::read(wear_path).map_err(|err| format!("{}: {err}", wear_path.display()))?; + let wear = decode_wear(&wear_bytes).map_err(|err| err.to_string())?; + let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root))); + let material = + resolve_material(&repository, &wear, material_index).map_err(|err| err.to_string())?; + let texture = material.document.primary_texture().ok_or_else(|| { + "MAT0 phase zero declares an intentionally untextured material".to_string() + })?; + let texture_name = String::from_utf8_lossy(&texture.0).into_owned(); + let image = load_texture_mip0_rgba8_from_root(root, "Textures.lib", &texture_name)?; + Ok(WearMaterialTexture { + wear_archive: "".to_string(), + wear_resource: wear_path.display().to_string(), + material_index, + material_name: String::from_utf8_lossy(&material.name.0).into_owned(), + material_fallback: material.fallback, + texture_name, + image, + }) +} + /// Inspects a terrain land file by path. /// /// # Errors @@ -813,6 +850,35 @@ mod tests { assert_eq!(selected.image.rgba8, vec![0x11, 0x22, 0x33, 0x40]); } + #[test] + fn standalone_wear_material_texture_loader_keeps_map_sidecar_separate() { + let dir = temp_dir("standalone-wear-material-texture"); + let wear_path = dir.join("Land2.wea"); + fs::write(&wear_path, b"1\n0 MAT\n").expect("standalone wear"); + let mut mat0 = vec![0; 4 + 34]; + mat0[0..2].copy_from_slice(&1_u16.to_le_bytes()); + mat0[22..25].copy_from_slice(b"TEX"); + fs::write( + dir.join("material.lib"), + build_single_entry_nres_with_meta(b"MAT", fparkan_material::MAT0_KIND, 0, &mat0), + ) + .expect("material archive"); + fs::write( + dir.join("Textures.lib"), + build_single_entry_nres(b"TEX", &texm_argb8888_pixel([0x40, 0x11, 0x22, 0x33])), + ) + .expect("texture archive"); + + let selected = + load_standalone_wear_material_texture_mip0_rgba8_from_root(&dir, &wear_path, 0) + .expect("resolved material texture"); + + assert_eq!(selected.wear_archive, ""); + assert_eq!(selected.wear_resource, wear_path.display().to_string()); + assert_eq!(selected.texture_name, "TEX"); + assert_eq!(selected.image.rgba8, vec![0x11, 0x22, 0x33, 0x40]); + } + #[test] fn land_mesh_bounds_preserve_each_source_axis() { let mesh = LandMeshDocument { diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index d9bae22..d1b584f 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1550,6 +1550,18 @@ This is evidence for a two-layer terrain-material contract, but not yet for its blend equation, so the Vulkan bridge intentionally remains texture-agnostic until the base/overlay composition is recovered. +The static Vulkan bridge now carries each contiguous face run as a separate +draw range keyed by the original packed `material_tag`; it neither reorders +triangles nor collapses the high byte. For the first usable visual layer, the +low byte resolves positionally through the adjacent standalone `Land2.wea`, +then the existing `MAT0 -> Textures.lib -> TEXM` chain supplies the RGBA8 +descriptor. `Land1.wea` remains preserved as the high byte/overlay channel and +is deliberately not drawn until its blend equation is recovered. A canonical +three-frame AutoDemo run now uses 71 descriptors (66 MSH plus five terrain +tags), produces readback hash `17034026600547661445`, and stays at zero Vulkan +validation warnings/errors. This is original terrain texture upload, not +terrain visual parity. + ```powershell cargo run -q -p fparkan-cli -- terrain inspect ` 'C:\GOG Games\Parkan - Iron Strategy\DATA\MAPS\AutoMAP\Land.msh' --format json