diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 9a30c60..b16cec1 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -45,6 +45,7 @@ use fparkan_runtime::{ loaded_mission_assets, loaded_mission_object_drafts, loaded_terrain, EngineConfig, EngineMode, EngineServices, MissionAssets, MissionLoadPhase, MissionObjectDraft, MissionRequest, }; +use fparkan_terrain::TerrainMaterialLayers; use fparkan_terrain::TerrainWorld; use fparkan_vfs::DirectoryVfs; #[cfg(test)] @@ -477,9 +478,11 @@ fn static_preview_terrain_base_materials( packed_tags .into_iter() .map(|packed_tag| { - let selector = packed_tag & 0x00ff; + let selection = TerrainMaterialLayers::from_packed_tag(packed_tag).land2_selection(); let selected = load_standalone_wear_material_texture_mip0_rgba8_from_root( - root, &wear_path, selector, + root, + &wear_path, + selection.material_index, ) .map_err(|err| { format!( diff --git a/crates/fparkan-terrain-format/src/lib.rs b/crates/fparkan-terrain-format/src/lib.rs index ddb439d..6e018c0 100644 --- a/crates/fparkan-terrain-format/src/lib.rs +++ b/crates/fparkan-terrain-format/src/lib.rs @@ -74,6 +74,57 @@ pub struct TerrainMaterialLayers { pub land2_selector: u8, } +/// One `World3D` material-manager lookup key. +/// +/// The original manager selects a WEAR table in the upper 16 bits and its +/// positional material row in the lower 16 bits. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TerrainMaterialSelection { + /// Zero-based material-manager table index. + pub table_index: u16, + /// Positional material row in that WEAR table. + pub material_index: u16, +} + +impl TerrainMaterialSelection { + /// Encodes the original material-manager phase lookup key. + #[must_use] + pub fn phase_key(self) -> u32 { + (u32::from(self.table_index) << 16) | u32::from(self.material_index) + } +} + +impl TerrainMaterialLayers { + /// Constructs the decoded pair from its on-disk packed tag. + #[must_use] + pub const fn from_packed_tag(material_tag: u16) -> Self { + let [land2_selector, land1] = material_tag.to_le_bytes(); + Self { + land1_selector: if land1 == u8::MAX { None } else { Some(land1) }, + land2_selector, + } + } + + /// Returns the table-zero `Land1.wea` selection when it exists. + #[must_use] + pub fn land1_selection(self) -> Option { + self.land1_selector + .map(|material_index| TerrainMaterialSelection { + table_index: 0, + material_index: u16::from(material_index), + }) + } + + /// Returns the table-one `Land2.wea` selection. + #[must_use] + pub fn land2_selection(self) -> TerrainMaterialSelection { + TerrainMaterialSelection { + table_index: 1, + material_index: u16::from(self.land2_selector), + } + } +} + /// Terrain face with 28-byte source layout. #[derive(Clone, Debug, Eq, PartialEq)] pub struct TerrainFace28 { @@ -97,11 +148,7 @@ impl TerrainFace28 { /// Decodes the proven two-table selector layout of [`Self::material_tag`]. #[must_use] pub const fn material_layers(&self) -> TerrainMaterialLayers { - let [land2_selector, land1] = self.material_tag.to_le_bytes(); - TerrainMaterialLayers { - land1_selector: if land1 == u8::MAX { None } else { Some(land1) }, - land2_selector, - } + TerrainMaterialLayers::from_packed_tag(self.material_tag) } } @@ -1427,6 +1474,20 @@ mod tests { land2_selector: 2, } ); + assert_eq!( + document.faces[0].material_layers().land1_selection(), + Some(TerrainMaterialSelection { + table_index: 0, + material_index: 1, + }) + ); + assert_eq!( + document.faces[0] + .material_layers() + .land2_selection() + .phase_key(), + 0x0001_0002 + ); raw_face[4..6].copy_from_slice(&0xff03_u16.to_le_bytes()); let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres"); @@ -1438,6 +1499,14 @@ mod tests { land2_selector: 3, } ); + assert_eq!(document.faces[0].material_layers().land1_selection(), None); + assert_eq!( + document.faces[0].material_layers().land2_selection(), + TerrainMaterialSelection { + table_index: 1, + material_index: 3, + } + ); } #[test] diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs index 65146b9..4eef871 100644 --- a/crates/fparkan-terrain/src/lib.rs +++ b/crates/fparkan-terrain/src/lib.rs @@ -23,6 +23,12 @@ use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument}; use std::collections::VecDeque; +/// Terrain material-selector contract preserved from the decoded map mesh. +/// +/// Applications access this semantic terrain API through this terrain crate +/// rather than taking a direct dependency on the binary-format parser. +pub use fparkan_terrain_format::{TerrainMaterialLayers, TerrainMaterialSelection}; + /// Terrain world. #[derive(Clone, Debug, Default)] pub struct TerrainWorld { diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index e75712c..469fbe1 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1559,7 +1559,11 @@ two WEAR selectors must not be collapsed into an invented one-layer material, and the type-18 `aux18` stream is now documented as microtexture mapping data. `TerrainFace28::material_layers` exposes the evidenced high-byte `Land1` selector (with `0xff` absent sentinel) and low-byte `Land2` selector. The -manager's actual blend/pass operation remains unrecovered. +manager's actual blend/pass operation remains unrecovered. `World3D.dll` +supplies the manager ABI: its load slot appends `Land1` as table 0 and `Land2` +as table 1, while its material-phase lookup receives the exact 32-bit selector +`(table_index << 16) | material_index`. `TerrainMaterialSelection` preserves +that key for the renderer without assuming how the two phases are blended. 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