use msh_core::{parse_model_payload, Model}; 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 { fn from(value: nres::error::Error) -> Self { Self::Nres(value) } } impl From for Error { fn from(value: msh_core::error::Error) -> Self { Self::Msh(value) } } 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; #[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() { if entry.meta.name.to_ascii_lowercase().ends_with(".msh") { msh_entries.push((entry.id, entry.meta.name.clone())); } } if msh_entries.is_empty() { return Err(Error::NoMshEntries); } let target_id = if let Some(name) = model_name { msh_entries .iter() .find(|(_, n)| n.eq_ignore_ascii_case(name)) .map(|(id, _)| *id) .ok_or_else(|| Error::ModelNotFound(name.to_string()))? } else { 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(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)] mod tests { use super::*; use std::fs; use std::path::{Path, PathBuf}; fn collect_files_recursive(root: &Path, out: &mut Vec) { let Ok(entries) = fs::read_dir(root) else { return; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { collect_files_recursive(&path, out); } else if path.is_file() { out.push(path); } } } fn archive_with_msh() -> Option { let root = Path::new(env!("CARGO_MANIFEST_DIR")) .join("..") .join("..") .join("testdata"); let mut files = Vec::new(); collect_files_recursive(&root, &mut files); files.sort(); for path in files { let Ok(bytes) = fs::read(&path) else { continue; }; if bytes.get(0..4) != Some(b"NRes") { continue; } let Ok(archive) = Archive::open_path(&path) else { continue; }; if archive .entries() .any(|entry| entry.meta.name.to_ascii_lowercase().ends_with(".msh")) { return Some(path); } } 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 { eprintln!("skipping load_model_from_real_archive: no .msh archives in testdata"); return; }; let model = load_model_from_archive(&path, None) .unwrap_or_else(|err| panic!("failed to load model from {}: {err:?}", path.display())); assert!(model.node_count > 0); 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()); } }