From 1d9d8a1d29d6e1c20c96c4a5f664b15ecdd94715 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 07:46:01 +0400 Subject: [PATCH] feat(assets): expose WEAR material texture selection --- Cargo.lock | 1 + crates/fparkan-inspection/Cargo.toml | 1 + crates/fparkan-inspection/src/lib.rs | 118 ++++++++++++++++++++++++++- 3 files changed, 118 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a8b06f3..3f1e21c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -469,6 +469,7 @@ name = "fparkan-inspection" version = "0.1.0" dependencies = [ "fparkan-diagnostics", + "fparkan-material", "fparkan-msh", "fparkan-nres", "fparkan-path", diff --git a/crates/fparkan-inspection/Cargo.toml b/crates/fparkan-inspection/Cargo.toml index 9185bdb..240f34d 100644 --- a/crates/fparkan-inspection/Cargo.toml +++ b/crates/fparkan-inspection/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true [dependencies] fparkan-diagnostics = { path = "../fparkan-diagnostics", version = "0.1.0" } fparkan-msh = { path = "../fparkan-msh", version = "0.1.0" } +fparkan-material = { path = "../fparkan-material", version = "0.1.0" } fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" } fparkan-path = { path = "../fparkan-path", version = "0.1.0" } fparkan-rsli = { path = "../fparkan-rsli", version = "0.1.0" } diff --git a/crates/fparkan-inspection/src/lib.rs b/crates/fparkan-inspection/src/lib.rs index ccef1b7..a23baac 100644 --- a/crates/fparkan-inspection/src/lib.rs +++ b/crates/fparkan-inspection/src/lib.rs @@ -23,6 +23,7 @@ use fparkan_diagnostics::{ diagnostic, render_human, Diagnostic, DiagnosticCode, DiagnosticContext, Phase, SourceSpan, }; +use fparkan_material::{decode_wear, resolve_material, MaterialFallback}; use fparkan_msh::{decode_msh, validate_msh, ModelAsset}; use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile}; use fparkan_path::{normalize_relative, PathPolicy}; @@ -101,6 +102,25 @@ pub struct TextureInspection { pub pages: usize, } +/// Diffuse TEXM selected through an original WEAR and MAT0 material chain. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WearMaterialTexture { + /// Source WEAR archive. + pub wear_archive: String, + /// Source WEAR resource. + pub wear_resource: String, + /// Positional WEAR selector used by an MSH batch. + pub material_index: u16, + /// Resolved MAT0 resource name after the original fallback chain. + pub material_name: String, + /// Fallback route used while resolving the MAT0 resource. + pub material_fallback: MaterialFallback, + /// Texture name selected from phase zero of the resolved MAT0 document. + pub texture_name: String, + /// Decoded RGBA8 mip zero suitable for the Vulkan upload boundary. + pub image: fparkan_texm::RgbaImage, +} + /// Land map/msh inspection payload. #[derive(Clone, Debug, Eq, PartialEq)] pub struct MapInspection { @@ -362,6 +382,45 @@ pub fn load_texture_mip0_rgba8_from_root( fparkan_texm::decode_mip_rgba8(&document, 0).map_err(|err| err.to_string()) } +/// Resolves phase-zero diffuse TEXM through `WEAR → MAT0 → Textures.lib`. +/// +/// The selector is the positional `Batch20.material_index`, not WEAR's legacy +/// text id. MAT0 fallback remains owned by `fparkan-material`: exact requested +/// entry, then `DEFAULT`, then the first material entry. +/// +/// # Errors +/// +/// Returns a string error if WEAR/MAT0 resolution, phase selection, or TEXM +/// decoding fails. An empty phase-zero texture name is an intentional +/// untextured material and is reported rather than substituted with a texture. +pub fn load_wear_material_texture_mip0_rgba8_from_root( + root: &Path, + wear_archive: &str, + wear_resource: &str, + material_index: u16, +) -> Result { + let wear_bytes = read_resource_bytes_diagnostic(root, wear_archive, wear_resource) + .map_err(|err| render_human(&err))?; + 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: wear_archive.to_string(), + wear_resource: wear_resource.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 @@ -605,6 +664,38 @@ mod tests { ); } + #[test] + fn wear_material_texture_loader_preserves_original_selection_provenance() { + let dir = temp_dir("wear-material-texture"); + fs::write( + dir.join("wear.rlb"), + build_single_entry_nres(b"MODEL.WEA", b"1\n0 MAT\n"), + ) + .expect("wear archive"); + 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_wear_material_texture_mip0_rgba8_from_root(&dir, "wear.rlb", "MODEL.WEA", 0) + .expect("resolved material texture"); + + assert_eq!(selected.material_name, "MAT"); + assert_eq!(selected.material_fallback, MaterialFallback::Exact); + assert_eq!(selected.texture_name, "TEX"); + assert_eq!(selected.image.rgba8, vec![0x11, 0x22, 0x33, 0x40]); + } + fn temp_dir(name: &str) -> PathBuf { let base = PathBuf::from("/tmp") .join("fparkan-inspection-tests") @@ -615,15 +706,24 @@ mod tests { } fn build_single_entry_nres(name: &[u8], payload: &[u8]) -> Vec { + build_single_entry_nres_with_meta(name, 1, 0, payload) + } + + fn build_single_entry_nres_with_meta( + name: &[u8], + type_id: u32, + attr2: u32, + payload: &[u8], + ) -> Vec { let mut out = vec![0; TEST_NRES_HEADER_LEN]; let payload_offset = u32::try_from(out.len()).expect("payload offset"); out.extend_from_slice(payload); let padding = (8 - (out.len() % 8)) % 8; out.resize(out.len() + padding, 0); - push_u32(&mut out, 1); - push_u32(&mut out, 0); + push_u32(&mut out, type_id); push_u32(&mut out, 0); + push_u32(&mut out, attr2); push_u32(&mut out, u32::try_from(payload.len()).expect("payload len")); push_u32(&mut out, 0); let mut raw_name = [0; TEST_NRES_NAME_LEN]; @@ -643,4 +743,18 @@ mod tests { fn push_u32(out: &mut Vec, value: u32) { out.extend_from_slice(&value.to_le_bytes()); } + + fn texm_argb8888_pixel(pixel: [u8; 4]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&0x6D78_6554_u32.to_le_bytes()); + out.extend_from_slice(&1_u32.to_le_bytes()); + out.extend_from_slice(&1_u32.to_le_bytes()); + out.extend_from_slice(&1_u32.to_le_bytes()); + out.extend_from_slice(&0_u32.to_le_bytes()); + out.extend_from_slice(&0_u32.to_le_bytes()); + out.extend_from_slice(&0_u32.to_le_bytes()); + out.extend_from_slice(&8888_u32.to_le_bytes()); + out.extend_from_slice(&pixel); + out + } }