From a281ffa32ea615670d369503692f057b2dc60e6f Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Thu, 19 Feb 2026 05:19:18 +0400 Subject: feat: Enhance model and texture loading with improved error handling and new features - Introduced `LoadedModel` and `LoadedTexture` structs for better encapsulation of model and texture data. - Added functions to load models and textures from archives, including support for resolving textures based on materials and wear entries. - Implemented error handling for missing textures, materials, and wear entries. - Updated the rendering pipeline to support texture loading and binding, including command-line arguments for texture customization. - Enhanced the `texm` crate with new decoding capabilities for various pixel formats, including indexed textures. - Added tests for texture decoding and loading to ensure reliability and correctness. - Updated documentation to reflect changes in the material and texture resolution process. --- crates/render-demo/src/lib.rs | 422 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 418 insertions(+), 4 deletions(-) (limited to 'crates/render-demo/src/lib.rs') diff --git a/crates/render-demo/src/lib.rs b/crates/render-demo/src/lib.rs index 4c73c09..c5c72b5 100644 --- a/crates/render-demo/src/lib.rs +++ b/crates/render-demo/src/lib.rs @@ -1,13 +1,25 @@ use msh_core::{parse_model_payload, Model}; -use nres::Archive; -use std::path::Path; +use nres::{Archive, EntryRef}; +use std::path::{Path, PathBuf}; +use texm::{decode_mip_rgba8, parse_texm}; + +const WEAR_KIND: u32 = 0x5241_4557; +const MAT0_KIND: u32 = 0x3054_414D; #[derive(Debug)] pub enum Error { Nres(nres::error::Error), Msh(msh_core::error::Error), + Texm(texm::error::Error), + Io(std::io::Error), NoMshEntries, ModelNotFound(String), + NoTexmEntries, + TextureNotFound(String), + MaterialNotFound(String), + WearNotFound(String), + InvalidWear(String), + InvalidMaterial(String), } impl From for Error { @@ -22,9 +34,38 @@ impl From for Error { } } +impl From for Error { + fn from(value: texm::error::Error) -> Self { + Self::Texm(value) + } +} + +impl From for Error { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + pub type Result = core::result::Result; -pub fn load_model_from_archive(path: &Path, model_name: Option<&str>) -> Result { +#[derive(Clone, Debug)] +pub struct LoadedModel { + pub name: String, + pub model: Model, +} + +#[derive(Clone, Debug)] +pub struct LoadedTexture { + pub name: String, + pub width: u32, + pub height: u32, + pub rgba8: Vec, +} + +pub fn load_model_with_name_from_archive( + path: &Path, + model_name: Option<&str>, +) -> Result { let archive = Archive::open_path(path)?; let mut msh_entries = Vec::new(); for entry in archive.entries() { @@ -46,8 +87,313 @@ pub fn load_model_from_archive(path: &Path, model_name: Option<&str>) -> Result< msh_entries[0].0 }; + let target_name = archive + .get(target_id) + .map(|entry| entry.meta.name.clone()) + .unwrap_or_else(|| String::from("")); let payload = archive.read(target_id)?; - Ok(parse_model_payload(payload.as_slice())?) + Ok(LoadedModel { + name: target_name, + model: parse_model_payload(payload.as_slice())?, + }) +} + +pub fn load_model_from_archive(path: &Path, model_name: Option<&str>) -> Result { + Ok(load_model_with_name_from_archive(path, model_name)?.model) +} + +pub fn load_texture_from_archive(path: &Path, texture_name: Option<&str>) -> Result { + let archive = Archive::open_path(path)?; + if let Some(name) = texture_name { + return load_texture_from_archive_by_name(&archive, name); + } + + let mut texm_entries = archive + .entries() + .filter(|entry| entry.meta.kind == texm::TEXM_MAGIC) + .collect::>(); + if texm_entries.is_empty() { + return Err(Error::NoTexmEntries); + } + texm_entries.sort_by(|a, b| { + a.meta + .name + .to_ascii_lowercase() + .cmp(&b.meta.name.to_ascii_lowercase()) + }); + let first = texm_entries[0]; + decode_texture_entry(&archive, first) +} + +pub fn resolve_texture_for_model( + model_archive_path: &Path, + model_entry_name: &str, + texture_name_override: Option<&str>, + textures_archive_override: Option<&Path>, + material_archive_override: Option<&Path>, + wear_entry_override: Option<&str>, +) -> Result> { + if let Some(name) = texture_name_override { + return load_texture_by_name_from_candidate_archives( + name, + candidate_texture_archives(model_archive_path, textures_archive_override), + ) + .map(Some); + } + + let wear_entry_name = if let Some(name) = wear_entry_override { + name.to_string() + } else { + derive_wear_entry_name(model_entry_name).ok_or_else(|| { + Error::WearNotFound(format!( + "cannot derive WEAR name from model '{model_entry_name}'" + )) + })? + }; + + let model_archive = Archive::open_path(model_archive_path)?; + let wear_materials = parse_wear_material_names( + read_entry_by_name_kind(&model_archive, &wear_entry_name, WEAR_KIND)? + .0 + .as_slice(), + )?; + let Some(primary_material) = wear_materials.first() else { + return Ok(None); + }; + + let material_path = if let Some(path) = material_archive_override { + path.to_path_buf() + } else { + sibling_archive_path(model_archive_path, "material.lib") + .ok_or_else(|| Error::MaterialNotFound(String::from("material.lib")))? + }; + let material_archive = Archive::open_path(&material_path)?; + let material_entry = find_material_entry_with_fallback(&material_archive, primary_material)?; + let material_payload = material_archive.read(material_entry.id)?.into_owned(); + let texture_name = + parse_primary_texture_name_from_mat0(&material_payload, material_entry.meta.attr2)?; + let Some(texture_name) = texture_name else { + return Ok(None); + }; + + let texture = load_texture_by_name_from_candidate_archives( + &texture_name, + candidate_texture_archives(model_archive_path, textures_archive_override), + )?; + Ok(Some(texture)) +} + +fn load_texture_by_name_from_candidate_archives( + texture_name: &str, + archives: Vec, +) -> Result { + let mut last_not_found = None; + for archive_path in archives { + if !archive_path.is_file() { + continue; + } + let archive = Archive::open_path(&archive_path)?; + match load_texture_from_archive_by_name(&archive, texture_name) { + Ok(texture) => return Ok(texture), + Err(Error::TextureNotFound(name)) => { + last_not_found = Some(name); + } + Err(other) => return Err(other), + } + } + + Err(Error::TextureNotFound( + last_not_found.unwrap_or_else(|| texture_name.to_string()), + )) +} + +fn candidate_texture_archives( + model_archive_path: &Path, + textures_archive_override: Option<&Path>, +) -> Vec { + if let Some(path) = textures_archive_override { + return vec![path.to_path_buf()]; + } + + let mut out = Vec::new(); + if let Some(path) = sibling_archive_path(model_archive_path, "textures.lib") { + out.push(path); + } + if let Some(path) = sibling_archive_path(model_archive_path, "lightmap.lib") { + out.push(path); + } + out +} + +fn sibling_archive_path(model_archive_path: &Path, name: &str) -> Option { + let parent = model_archive_path.parent()?; + Some(parent.join(name)) +} + +fn derive_wear_entry_name(model_entry_name: &str) -> Option { + let stem = model_entry_name.rsplit_once('.').map(|(left, _)| left)?; + Some(format!("{stem}.wea")) +} + +fn read_entry_by_name_kind( + archive: &Archive, + name: &str, + expected_kind: u32, +) -> Result<(Vec, String)> { + let Some(id) = archive.find(name) else { + return Err(Error::WearNotFound(name.to_string())); + }; + let Some(entry) = archive.get(id) else { + return Err(Error::WearNotFound(name.to_string())); + }; + if entry.meta.kind != expected_kind { + return Err(Error::WearNotFound(name.to_string())); + } + let payload = archive.read(id)?.into_owned(); + Ok((payload, entry.meta.name.clone())) +} + +fn find_material_entry_with_fallback<'a>( + archive: &'a Archive, + requested_name: &str, +) -> Result> { + if let Some(id) = archive.find(requested_name) { + if let Some(entry) = archive.get(id) { + if entry.meta.kind == MAT0_KIND { + return Ok(entry); + } + } + } + + if let Some(id) = archive.find("DEFAULT") { + if let Some(entry) = archive.get(id) { + if entry.meta.kind == MAT0_KIND { + return Ok(entry); + } + } + } + + let Some(entry) = archive.entries().find(|entry| entry.meta.kind == MAT0_KIND) else { + return Err(Error::MaterialNotFound(requested_name.to_string())); + }; + Ok(entry) +} + +fn parse_wear_material_names(payload: &[u8]) -> Result> { + let text = String::from_utf8_lossy(payload).replace('\r', ""); + let mut lines = text.lines(); + let Some(first) = lines.next() else { + return Err(Error::InvalidWear(String::from("WEAR payload is empty"))); + }; + let count = first + .trim() + .parse::() + .map_err(|_| Error::InvalidWear(format!("invalid wearCount line: '{first}'")))?; + if count == 0 { + return Err(Error::InvalidWear(String::from("wearCount must be > 0"))); + } + + let mut materials = Vec::with_capacity(count); + for idx in 0..count { + let Some(line) = lines.next() else { + return Err(Error::InvalidWear(format!( + "missing material line {idx} of {count}" + ))); + }; + let mut parts = line.split_whitespace(); + let _legacy = parts + .next() + .ok_or_else(|| Error::InvalidWear(format!("invalid material line {idx}: '{line}'")))?; + let name = parts + .next() + .ok_or_else(|| Error::InvalidWear(format!("invalid material line {idx}: '{line}'")))?; + materials.push(name.to_string()); + } + + Ok(materials) +} + +fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result> { + if payload.len() < 4 { + return Err(Error::InvalidMaterial(String::from( + "MAT0 payload is too small for header", + ))); + } + let phase_count = u16::from_le_bytes([payload[0], payload[1]]) as usize; + if phase_count == 0 { + return Ok(None); + } + + let mut offset = 4usize; + if attr2 >= 2 { + offset = offset + .checked_add(2) + .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?; + } + if attr2 >= 3 { + offset = offset + .checked_add(4) + .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?; + } + if attr2 >= 4 { + offset = offset + .checked_add(4) + .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?; + } + + for phase in 0..phase_count { + let phase_off = offset + .checked_add(phase.checked_mul(34).ok_or_else(|| { + Error::InvalidMaterial(String::from("MAT0 phase offset overflow")) + })?) + .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 phase offset overflow")))?; + let phase_end = phase_off + .checked_add(34) + .ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 phase offset overflow")))?; + let Some(rec) = payload.get(phase_off..phase_end) else { + return Err(Error::InvalidMaterial(format!( + "MAT0 phase {phase} is out of bounds" + ))); + }; + let name_raw = &rec[18..34]; + let name_end = name_raw + .iter() + .position(|&b| b == 0) + .unwrap_or(name_raw.len()); + let name = String::from_utf8_lossy(&name_raw[..name_end]) + .trim() + .to_string(); + if !name.is_empty() { + return Ok(Some(name)); + } + } + + Ok(None) +} + +fn load_texture_from_archive_by_name(archive: &Archive, name: &str) -> Result { + let Some(id) = archive.find(name) else { + return Err(Error::TextureNotFound(name.to_string())); + }; + let Some(entry) = archive.get(id) else { + return Err(Error::TextureNotFound(name.to_string())); + }; + if entry.meta.kind != texm::TEXM_MAGIC { + return Err(Error::TextureNotFound(name.to_string())); + } + decode_texture_entry(archive, entry) +} + +fn decode_texture_entry(archive: &Archive, entry: EntryRef<'_>) -> Result { + let payload = archive.read(entry.id)?.into_owned(); + let parsed = parse_texm(&payload)?; + let decoded = decode_mip_rgba8(&parsed, &payload, 0)?; + Ok(LoadedTexture { + name: entry.meta.name.clone(), + width: decoded.width, + height: decoded.height, + rgba8: decoded.rgba8, + }) } #[cfg(test)] @@ -98,6 +444,19 @@ mod tests { None } + fn game_root() -> Option { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("testdata") + .join("Parkan - Iron Strategy"); + if path.is_dir() { + Some(path) + } else { + None + } + } + #[test] fn load_model_from_real_archive() { let Some(path) = archive_with_msh() else { @@ -110,4 +469,59 @@ mod tests { assert!(!model.positions.is_empty()); assert!(!model.indices.is_empty()); } + + #[test] + fn resolve_texture_for_real_model_via_wear_and_material() { + let Some(root) = game_root() else { + eprintln!( + "skipping resolve_texture_for_real_model_via_wear_and_material: no game root" + ); + return; + }; + let archive = root.join("animals.rlb"); + if !archive.is_file() { + eprintln!("skipping resolve_texture_for_real_model_via_wear_and_material: missing animals.rlb"); + return; + } + + let loaded = load_model_with_name_from_archive(&archive, Some("A_L_01.msh")) + .unwrap_or_else(|err| { + panic!( + "failed to load model A_L_01.msh from {}: {err:?}", + archive.display() + ) + }); + let texture = resolve_texture_for_model(&archive, &loaded.name, None, None, None, None) + .unwrap_or_else(|err| panic!("failed to resolve texture for {}: {err:?}", loaded.name)) + .expect("texture must be resolved for A_L_01.msh"); + assert!(texture.width > 0 && texture.height > 0); + assert_eq!( + texture.rgba8.len(), + usize::try_from(texture.width) + .ok() + .and_then(|w| usize::try_from(texture.height).ok().map(|h| w * h * 4)) + .unwrap_or(0) + ); + } + + #[test] + fn load_first_texture_from_real_archive() { + let Some(root) = game_root() else { + eprintln!("skipping load_first_texture_from_real_archive: no game root"); + return; + }; + let archive = root.join("textures.lib"); + if !archive.is_file() { + eprintln!("skipping load_first_texture_from_real_archive: missing textures.lib"); + return; + } + let texture = load_texture_from_archive(&archive, None).unwrap_or_else(|err| { + panic!( + "failed to load first texture from {}: {err:?}", + archive.display() + ) + }); + assert!(texture.width > 0 && texture.height > 0); + assert!(!texture.rgba8.is_empty()); + } } -- cgit v1.2.3