aboutsummaryrefslogtreecommitdiff
path: root/crates/render-demo/src/lib.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/render-demo/src/lib.rs')
-rw-r--r--crates/render-demo/src/lib.rs422
1 files changed, 418 insertions, 4 deletions
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<nres::error::Error> for Error {
@@ -22,9 +34,38 @@ impl From<msh_core::error::Error> for Error {
}
}
+impl From<texm::error::Error> for Error {
+ fn from(value: texm::error::Error) -> Self {
+ Self::Texm(value)
+ }
+}
+
+impl From<std::io::Error> for Error {
+ fn from(value: std::io::Error) -> Self {
+ Self::Io(value)
+ }
+}
+
pub type Result<T> = core::result::Result<T, Error>;
-pub fn load_model_from_archive(path: &Path, model_name: Option<&str>) -> Result<Model> {
+#[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<u8>,
+}
+
+pub fn load_model_with_name_from_archive(
+ path: &Path,
+ model_name: Option<&str>,
+) -> Result<LoadedModel> {
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("<unknown>"));
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<Model> {
+ Ok(load_model_with_name_from_archive(path, model_name)?.model)
+}
+
+pub fn load_texture_from_archive(path: &Path, texture_name: Option<&str>) -> Result<LoadedTexture> {
+ 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::<Vec<_>>();
+ 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<Option<LoadedTexture>> {
+ 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<PathBuf>,
+) -> Result<LoadedTexture> {
+ 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<PathBuf> {
+ 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<PathBuf> {
+ let parent = model_archive_path.parent()?;
+ Some(parent.join(name))
+}
+
+fn derive_wear_entry_name(model_entry_name: &str) -> Option<String> {
+ 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<u8>, 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<EntryRef<'a>> {
+ 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<Vec<String>> {
+ 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::<usize>()
+ .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<Option<String>> {
+ 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<LoadedTexture> {
+ 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<LoadedTexture> {
+ 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<PathBuf> {
+ 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());
+ }
}