feat(assets): expose WEAR material texture selection
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m8s
Test / Test (push) Skipped
Test / Render parity (push) Skipped

This commit is contained in:
2026-07-18 07:46:01 +04:00
parent a436190059
commit 1d9d8a1d29
3 changed files with 118 additions and 2 deletions
Generated
+1
View File
@@ -469,6 +469,7 @@ name = "fparkan-inspection"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"fparkan-diagnostics", "fparkan-diagnostics",
"fparkan-material",
"fparkan-msh", "fparkan-msh",
"fparkan-nres", "fparkan-nres",
"fparkan-path", "fparkan-path",
+1
View File
@@ -8,6 +8,7 @@ repository.workspace = true
[dependencies] [dependencies]
fparkan-diagnostics = { path = "../fparkan-diagnostics", version = "0.1.0" } fparkan-diagnostics = { path = "../fparkan-diagnostics", version = "0.1.0" }
fparkan-msh = { path = "../fparkan-msh", 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-nres = { path = "../fparkan-nres", version = "0.1.0" }
fparkan-path = { path = "../fparkan-path", version = "0.1.0" } fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
fparkan-rsli = { path = "../fparkan-rsli", version = "0.1.0" } fparkan-rsli = { path = "../fparkan-rsli", version = "0.1.0" }
+116 -2
View File
@@ -23,6 +23,7 @@
use fparkan_diagnostics::{ use fparkan_diagnostics::{
diagnostic, render_human, Diagnostic, DiagnosticCode, DiagnosticContext, Phase, SourceSpan, 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_msh::{decode_msh, validate_msh, ModelAsset};
use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile}; use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile};
use fparkan_path::{normalize_relative, PathPolicy}; use fparkan_path::{normalize_relative, PathPolicy};
@@ -101,6 +102,25 @@ pub struct TextureInspection {
pub pages: usize, 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. /// Land map/msh inspection payload.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct MapInspection { 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()) 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<WearMaterialTexture, String> {
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. /// Inspects a terrain land file by path.
/// ///
/// # Errors /// # 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 { fn temp_dir(name: &str) -> PathBuf {
let base = PathBuf::from("/tmp") let base = PathBuf::from("/tmp")
.join("fparkan-inspection-tests") .join("fparkan-inspection-tests")
@@ -615,15 +706,24 @@ mod tests {
} }
fn build_single_entry_nres(name: &[u8], payload: &[u8]) -> Vec<u8> { fn build_single_entry_nres(name: &[u8], payload: &[u8]) -> Vec<u8> {
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<u8> {
let mut out = vec![0; TEST_NRES_HEADER_LEN]; let mut out = vec![0; TEST_NRES_HEADER_LEN];
let payload_offset = u32::try_from(out.len()).expect("payload offset"); let payload_offset = u32::try_from(out.len()).expect("payload offset");
out.extend_from_slice(payload); out.extend_from_slice(payload);
let padding = (8 - (out.len() % 8)) % 8; let padding = (8 - (out.len() % 8)) % 8;
out.resize(out.len() + padding, 0); out.resize(out.len() + padding, 0);
push_u32(&mut out, 1); push_u32(&mut out, type_id);
push_u32(&mut out, 0);
push_u32(&mut out, 0); 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, u32::try_from(payload.len()).expect("payload len"));
push_u32(&mut out, 0); push_u32(&mut out, 0);
let mut raw_name = [0; TEST_NRES_NAME_LEN]; let mut raw_name = [0; TEST_NRES_NAME_LEN];
@@ -643,4 +743,18 @@ mod tests {
fn push_u32(out: &mut Vec<u8>, value: u32) { fn push_u32(out: &mut Vec<u8>, value: u32) {
out.extend_from_slice(&value.to_le_bytes()); out.extend_from_slice(&value.to_le_bytes());
} }
fn texm_argb8888_pixel(pixel: [u8; 4]) -> Vec<u8> {
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
}
} }