From 4ef08d0bf6366b0bc8ccb6357b794937411f74cc Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Thu, 19 Feb 2026 16:07:01 +0400 Subject: feat: add terrain-core, tma, and unitdat crates with parsing functionality - Introduced `terrain-core` crate for loading and processing terrain mesh data. - Added `tma` crate for parsing mission files, including footer and object records. - Created `unitdat` crate for reading unit data files with validation of structure. - Implemented error handling and tests for all new crates. - Documented object registry format and rendering pipeline in specifications. --- crates/render-mission-demo/src/lib.rs | 881 ++++++++++++++++++++++++++++++++++ 1 file changed, 881 insertions(+) create mode 100644 crates/render-mission-demo/src/lib.rs (limited to 'crates/render-mission-demo/src/lib.rs') diff --git a/crates/render-mission-demo/src/lib.rs b/crates/render-mission-demo/src/lib.rs new file mode 100644 index 0000000..9732f39 --- /dev/null +++ b/crates/render-mission-demo/src/lib.rs @@ -0,0 +1,881 @@ +use encoding_rs::WINDOWS_1251; +use nres::Archive; +use render_core::{build_render_mesh, RenderMesh}; +use render_demo::{load_model_with_name_from_archive, resolve_texture_for_model, LoadedTexture}; +use std::collections::HashMap; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; +use terrain_core::TerrainMesh; +use tma::MissionFile; + +const MAT0_KIND: u32 = 0x3054_414D; +const MESH_KIND: u32 = 0x4853_454D; +const OBJECT_REF_STRIDE: usize = 64; +const OBJECT_REF_ARCHIVE_BYTES: usize = 32; + +pub type Result = core::result::Result; + +#[derive(Debug)] +pub enum Error { + Io(std::io::Error), + Mission(tma::Error), + Terrain(terrain_core::Error), + UnitDat(unitdat::Error), + RenderDemo(render_demo::Error), + Nres(nres::error::Error), + Texm(texm::error::Error), + InvalidMapPath(String), + GameRootNotFound(PathBuf), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(err) => write!(f, "{err}"), + Self::Mission(err) => write!(f, "{err}"), + Self::Terrain(err) => write!(f, "{err}"), + Self::UnitDat(err) => write!(f, "{err}"), + Self::RenderDemo(err) => write!(f, "{err}"), + Self::Nres(err) => write!(f, "{err}"), + Self::Texm(err) => write!(f, "{err}"), + Self::InvalidMapPath(path) => write!(f, "invalid mission map path: {path}"), + Self::GameRootNotFound(path) => { + write!( + f, + "failed to detect game root from mission path {}", + path.display() + ) + } + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(err) => Some(err), + Self::Mission(err) => Some(err), + Self::Terrain(err) => Some(err), + Self::UnitDat(err) => Some(err), + Self::RenderDemo(err) => Some(err), + Self::Nres(err) => Some(err), + Self::Texm(err) => Some(err), + Self::InvalidMapPath(_) | Self::GameRootNotFound(_) => None, + } + } +} + +impl From for Error { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl From for Error { + fn from(value: tma::Error) -> Self { + Self::Mission(value) + } +} + +impl From for Error { + fn from(value: terrain_core::Error) -> Self { + Self::Terrain(value) + } +} + +impl From for Error { + fn from(value: unitdat::Error) -> Self { + Self::UnitDat(value) + } +} + +impl From for Error { + fn from(value: render_demo::Error) -> Self { + Self::RenderDemo(value) + } +} + +impl From for Error { + fn from(value: nres::error::Error) -> Self { + Self::Nres(value) + } +} + +impl From for Error { + fn from(value: texm::error::Error) -> Self { + Self::Texm(value) + } +} + +#[derive(Copy, Clone, Debug)] +pub struct LoadOptions { + pub load_model_textures: bool, + pub load_terrain_texture: bool, +} + +impl Default for LoadOptions { + fn default() -> Self { + Self { + load_model_textures: true, + load_terrain_texture: true, + } + } +} + +#[derive(Clone, Debug)] +pub struct MissionScene { + pub game_root: PathBuf, + pub mission_path: PathBuf, + pub mission: MissionFile, + pub map_folder_rel: PathBuf, + pub land_msh_path: PathBuf, + pub terrain: TerrainMesh, + pub terrain_texture: Option, + pub models: Vec, + pub skipped_objects: usize, +} + +#[derive(Clone, Debug)] +pub struct SceneModel { + pub archive_path: PathBuf, + pub model_name: String, + pub mesh: RenderMesh, + pub texture: Option, + pub instances: Vec, +} + +#[derive(Copy, Clone, Debug)] +pub struct ModelInstance { + pub position: [f32; 3], + pub yaw_rad: f32, + pub scale: [f32; 3], +} + +#[derive(Clone, Debug)] +struct ObjectPrototype { + archive_path: PathBuf, + model_name: String, +} + +#[derive(Clone, Debug)] +struct ObjectRef { + archive_name: String, + resource_name: String, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +struct ModelKey { + archive_path: PathBuf, + model_name: String, +} + +pub fn detect_game_root_from_mission_path(mission_path: &Path) -> Option { + let mut cursor = mission_path.parent(); + while let Some(dir) = cursor { + if dir.join("DATA").is_dir() && dir.join("objects.rlb").is_file() { + return Some(dir.to_path_buf()); + } + cursor = dir.parent(); + } + None +} + +pub fn load_scene( + game_root: impl AsRef, + mission_path: impl AsRef, +) -> Result { + load_scene_with_options(game_root, mission_path, LoadOptions::default()) +} + +pub fn load_scene_with_options( + game_root: impl AsRef, + mission_path: impl AsRef, + options: LoadOptions, +) -> Result { + let game_root = game_root.as_ref().to_path_buf(); + let mission_path = mission_path.as_ref().to_path_buf(); + + let mission = tma::parse_path(&mission_path)?; + let map_folder_rel = map_folder_from_footer(&mission.footer.map_path)?; + let land_msh_path = game_root.join(&map_folder_rel).join("Land.msh"); + let terrain = terrain_core::load_land_mesh(&land_msh_path)?; + let terrain_texture = if options.load_terrain_texture { + resolve_terrain_texture(&game_root, &map_folder_rel)? + } else { + None + }; + + let mut grouped_instances: HashMap> = HashMap::new(); + let mut prototype_cache: HashMap> = HashMap::new(); + let mut skipped = 0usize; + + for object in &mission.objects { + let cache_key = object.resource_name.to_ascii_lowercase(); + let proto = if let Some(cached) = prototype_cache.get(&cache_key) { + cached.clone() + } else { + let resolved = resolve_object_prototype(&game_root, object)?; + prototype_cache.insert(cache_key, resolved.clone()); + resolved + }; + + let Some(proto) = proto else { + skipped += 1; + continue; + }; + + let instance = ModelInstance { + position: object.position, + yaw_rad: object.orientation[2], + scale: normalize_scale(object.scale), + }; + + grouped_instances + .entry(ModelKey { + archive_path: proto.archive_path, + model_name: proto.model_name, + }) + .or_default() + .push(instance); + } + + let mut models = Vec::new(); + for (key, instances) in grouped_instances { + let loaded = + match load_model_with_name_from_archive(&key.archive_path, Some(&key.model_name)) { + Ok(v) => v, + Err(_) => { + skipped += instances.len(); + continue; + } + }; + + let mesh = build_render_mesh(&loaded.model, 0, 0); + if mesh.indices.is_empty() { + skipped += instances.len(); + continue; + } + + let texture = if options.load_model_textures { + resolve_texture_for_model(&key.archive_path, &loaded.name, None, None, None, None) + .ok() + .flatten() + } else { + None + }; + + models.push(SceneModel { + archive_path: key.archive_path, + model_name: loaded.name, + mesh, + texture, + instances, + }); + } + + models.sort_by(|a, b| a.model_name.cmp(&b.model_name)); + + Ok(MissionScene { + game_root, + mission_path, + mission, + map_folder_rel, + land_msh_path, + terrain, + terrain_texture, + models, + skipped_objects: skipped, + }) +} + +pub fn compute_scene_bounds(scene: &MissionScene) -> Option<([f32; 3], [f32; 3])> { + let mut min_v = [f32::INFINITY; 3]; + let mut max_v = [f32::NEG_INFINITY; 3]; + let mut any = false; + + for pos in &scene.terrain.positions { + merge_bounds(&mut min_v, &mut max_v, *pos); + any = true; + } + + for model in &scene.models { + for instance in &model.instances { + merge_bounds(&mut min_v, &mut max_v, instance.position); + any = true; + } + } + + any.then_some((min_v, max_v)) +} + +fn merge_bounds(min_v: &mut [f32; 3], max_v: &mut [f32; 3], p: [f32; 3]) { + for i in 0..3 { + if p[i] < min_v[i] { + min_v[i] = p[i]; + } + if p[i] > max_v[i] { + max_v[i] = p[i]; + } + } +} + +fn normalize_scale(scale: [f32; 3]) -> [f32; 3] { + let mut out = scale; + for item in &mut out { + if !item.is_finite() || item.abs() < 0.000_1 { + *item = 1.0; + } + } + out +} + +fn map_folder_from_footer(map_path: &str) -> Result { + let mut parts = split_relative_path(map_path); + if parts.len() < 2 { + return Err(Error::InvalidMapPath(map_path.to_string())); + } + parts.pop(); // remove 'land' + + let mut out = PathBuf::new(); + for part in parts { + out.push(part); + } + Ok(out) +} + +fn resolve_object_prototype( + game_root: &Path, + object: &tma::MissionObject, +) -> Result> { + if object.resource_name.to_ascii_lowercase().ends_with(".dat") { + let dat_path = game_root.join(pathbuf_from_rel(&object.resource_name)); + if !dat_path.is_file() { + return Ok(None); + } + + let parsed = unitdat::parse_path(&dat_path)?; + let archive_path = game_root.join(pathbuf_from_rel(&parsed.archive_name)); + if !archive_path.is_file() { + return Ok(None); + } + return resolve_archive_model(game_root, &archive_path, &parsed.model_key); + } + + let archive_path = game_root.join("objects.rlb"); + if !archive_path.is_file() { + return Ok(None); + } + resolve_archive_model(game_root, &archive_path, &object.resource_name) +} + +fn resolve_archive_model( + game_root: &Path, + archive_path: &Path, + model_key: &str, +) -> Result> { + if !archive_path.is_file() { + return Ok(None); + } + + if is_objects_registry_archive(archive_path) { + if let Some(proto) = resolve_objects_registry_model(game_root, archive_path, model_key)? { + return Ok(Some(proto)); + } + } + + let model_name = ensure_msh_suffix(model_key); + if !archive_has_mesh_entry(archive_path, &model_name)? { + return Ok(None); + } + + Ok(Some(ObjectPrototype { + archive_path: archive_path.to_path_buf(), + model_name: model_name.to_ascii_lowercase(), + })) +} + +fn is_objects_registry_archive(archive_path: &Path) -> bool { + archive_path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("objects.rlb")) +} + +fn resolve_objects_registry_model( + game_root: &Path, + registry_archive_path: &Path, + object_key: &str, +) -> Result> { + let archive = Archive::open_path(registry_archive_path)?; + let Some(entry_id) = find_registry_entry_id(&archive, object_key) else { + return Ok(None); + }; + + let payload = archive.read(entry_id)?.into_owned(); + let refs = parse_object_refs(&payload); + if refs.is_empty() { + return Ok(None); + } + + for item in refs + .iter() + .filter(|item| has_extension(&item.resource_name, "msh")) + { + if let Some(proto) = resolve_object_ref_model(game_root, item, &item.resource_name)? { + return Ok(Some(proto)); + } + } + + for item in refs + .iter() + .filter(|item| has_extension(&item.resource_name, "bas")) + { + let Some(stem) = Path::new(&item.resource_name) + .file_stem() + .and_then(|stem| stem.to_str()) + else { + continue; + }; + if stem.is_empty() { + continue; + } + let candidate = format!("{stem}.msh"); + if let Some(proto) = resolve_object_ref_model(game_root, item, &candidate)? { + return Ok(Some(proto)); + } + } + + Ok(None) +} + +fn find_registry_entry_id(archive: &Archive, object_key: &str) -> Option { + mesh_name_candidates(object_key) + .into_iter() + .find_map(|candidate| archive.find(&candidate)) +} + +fn resolve_object_ref_model( + game_root: &Path, + item: &ObjectRef, + model_name: &str, +) -> Result> { + let archive_path = game_root.join(pathbuf_from_rel(&item.archive_name)); + if !archive_path.is_file() { + return Ok(None); + } + if !archive_has_mesh_entry(&archive_path, model_name)? { + return Ok(None); + } + + Ok(Some(ObjectPrototype { + archive_path, + model_name: model_name.to_ascii_lowercase(), + })) +} + +fn parse_object_refs(payload: &[u8]) -> Vec { + if !payload.len().is_multiple_of(OBJECT_REF_STRIDE) { + return Vec::new(); + } + + let mut refs = Vec::with_capacity(payload.len() / OBJECT_REF_STRIDE); + for chunk in payload.chunks_exact(OBJECT_REF_STRIDE) { + let archive_name = decode_cp1251_cstr(&chunk[..OBJECT_REF_ARCHIVE_BYTES]); + let resource_name = decode_cp1251_cstr(&chunk[OBJECT_REF_ARCHIVE_BYTES..]); + if archive_name.is_empty() || resource_name.is_empty() { + continue; + } + refs.push(ObjectRef { + archive_name, + resource_name, + }); + } + refs +} + +fn archive_has_mesh_entry(archive_path: &Path, requested_name: &str) -> Result { + let archive = Archive::open_path(archive_path)?; + Ok(find_mesh_entry_id(&archive, requested_name).is_some()) +} + +fn find_mesh_entry_id(archive: &Archive, requested_name: &str) -> Option { + for candidate in mesh_name_candidates(requested_name) { + let Some(id) = archive.find(&candidate) else { + continue; + }; + let Some(entry) = archive.get(id) else { + continue; + }; + if entry.meta.kind == MESH_KIND || has_extension(&entry.meta.name, "msh") { + return Some(id); + } + } + None +} + +fn mesh_name_candidates(name: &str) -> Vec { + let mut out = Vec::new(); + let trimmed = name.trim(); + if trimmed.is_empty() { + return out; + } + + push_unique_string(&mut out, trimmed.to_string()); + if let Some(stem) = trimmed + .strip_suffix(".msh") + .or_else(|| trimmed.strip_suffix(".MSH")) + { + if !stem.is_empty() { + push_unique_string(&mut out, stem.to_string()); + } + } else { + push_unique_string(&mut out, format!("{trimmed}.msh")); + } + + out +} + +fn push_unique_string(items: &mut Vec, value: String) { + if !items.iter().any(|item| item.eq_ignore_ascii_case(&value)) { + items.push(value); + } +} + +fn ensure_msh_suffix(name: &str) -> String { + let trimmed = name.trim(); + if trimmed.to_ascii_lowercase().ends_with(".msh") { + trimmed.to_string() + } else { + format!("{trimmed}.msh") + } +} + +fn has_extension(name: &str, ext: &str) -> bool { + Path::new(name) + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case(ext)) +} + +fn resolve_terrain_texture( + game_root: &Path, + map_folder_rel: &Path, +) -> Result> { + let material_archive_path = game_root.join("material.lib"); + let texture_archive_path = game_root.join("textures.lib"); + if !material_archive_path.is_file() || !texture_archive_path.is_file() { + return Ok(None); + } + + for wear_name in ["Land1.wea", "Land2.wea"] { + let wear_path = game_root.join(map_folder_rel).join(wear_name); + if !wear_path.is_file() { + continue; + } + let wear_payload = fs::read(&wear_path)?; + let Some(material_name) = parse_primary_material_from_wear(&wear_payload) else { + continue; + }; + let Some(texture_name) = + resolve_texture_name_from_material_archive(&material_archive_path, &material_name)? + else { + continue; + }; + if let Some(texture) = load_texm_by_name(&texture_archive_path, &texture_name)? { + return Ok(Some(texture)); + } + } + + Ok(None) +} + +fn parse_primary_material_from_wear(bytes: &[u8]) -> Option { + let text = decode_cp1251(bytes).replace('\r', ""); + let mut lines = text.lines(); + let count = lines.next()?.trim().parse::().ok()?; + if count == 0 { + return None; + } + + for line in lines.take(count) { + let mut parts = line.split_whitespace(); + let _legacy = parts.next()?; + let name = parts.next()?; + if !name.is_empty() { + return Some(name.to_string()); + } + } + None +} + +fn resolve_texture_name_from_material_archive( + archive_path: &Path, + material_name: &str, +) -> Result> { + let archive = Archive::open_path(archive_path)?; + + let entry = if let Some(id) = archive.find(material_name) { + archive + .get(id) + .filter(|entry| entry.meta.kind == MAT0_KIND) + .or_else(|| { + archive + .find("DEFAULT") + .and_then(|id| archive.get(id)) + .filter(|entry| entry.meta.kind == MAT0_KIND) + }) + } else { + archive + .find("DEFAULT") + .and_then(|id| archive.get(id)) + .filter(|entry| entry.meta.kind == MAT0_KIND) + } + .or_else(|| archive.entries().find(|entry| entry.meta.kind == MAT0_KIND)); + + let Some(entry) = entry else { + return Ok(None); + }; + + let payload = archive.read(entry.id)?.into_owned(); + parse_primary_texture_name_from_mat0(&payload, entry.meta.attr2) +} + +fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result> { + if payload.len() < 4 { + return Ok(None); + } + + 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.saturating_add(2); + } + if attr2 >= 3 { + offset = offset.saturating_add(4); + } + if attr2 >= 4 { + offset = offset.saturating_add(4); + } + + for phase in 0..phase_count { + let phase_off = offset.saturating_add(phase.saturating_mul(34)); + let Some(rec) = payload.get(phase_off..phase_off + 34) else { + break; + }; + let name_raw = &rec[18..34]; + let end = name_raw + .iter() + .position(|&b| b == 0) + .unwrap_or(name_raw.len()); + let name = decode_cp1251(&name_raw[..end]).trim().to_string(); + if !name.is_empty() { + return Ok(Some(name)); + } + } + + Ok(None) +} + +fn load_texm_by_name(archive_path: &Path, texture_name: &str) -> Result> { + let archive = Archive::open_path(archive_path)?; + let Some(id) = archive.find(texture_name) else { + return Ok(None); + }; + let Some(entry) = archive.get(id) else { + return Ok(None); + }; + if entry.meta.kind != texm::TEXM_MAGIC { + return Ok(None); + } + + let payload = archive.read(id)?.into_owned(); + let parsed = texm::parse_texm(&payload)?; + let decoded = texm::decode_mip_rgba8(&parsed, &payload, 0)?; + + Ok(Some(LoadedTexture { + name: entry.meta.name.clone(), + width: decoded.width, + height: decoded.height, + rgba8: decoded.rgba8, + })) +} + +fn split_relative_path(path: &str) -> Vec<&str> { + path.split(['\\', '/']) + .filter(|part| !part.is_empty()) + .collect() +} + +fn pathbuf_from_rel(path: &str) -> PathBuf { + let mut out = PathBuf::new(); + for part in split_relative_path(path) { + out.push(part); + } + out +} + +fn decode_cp1251_cstr(bytes: &[u8]) -> String { + let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); + let (decoded, _, _) = WINDOWS_1251.decode(&bytes[..end]); + decoded.trim().to_string() +} + +fn decode_cp1251(bytes: &[u8]) -> String { + let (decoded, _, _) = WINDOWS_1251.decode(bytes); + decoded.into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn game_root() -> Option { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("testdata") + .join("Parkan - Iron Strategy"); + root.is_dir().then_some(root) + } + + #[test] + fn detects_game_root_from_mission_path() { + let Some(root) = game_root() else { + eprintln!("skipping: game root missing"); + return; + }; + + let mission = root + .join("MISSIONS") + .join("CAMPAIGN") + .join("CAMPAIGN.00") + .join("Mission.01") + .join("data.tma"); + if !mission.is_file() { + eprintln!("skipping missing mission sample"); + return; + } + + let detected = detect_game_root_from_mission_path(&mission) + .expect("failed to detect game root from mission path"); + assert_eq!(detected, root); + } + + #[test] + fn loads_scene_cpu_without_textures() { + let Some(root) = game_root() else { + eprintln!("skipping: game root missing"); + return; + }; + + let mission = root + .join("MISSIONS") + .join("CAMPAIGN") + .join("CAMPAIGN.00") + .join("Mission.01") + .join("data.tma"); + if !mission.is_file() { + eprintln!("skipping missing mission sample"); + return; + } + + let scene = load_scene_with_options( + &root, + &mission, + LoadOptions { + load_model_textures: false, + load_terrain_texture: false, + }, + ) + .unwrap_or_else(|err| panic!("failed to load scene {}: {err}", mission.display())); + + assert!(!scene.terrain.positions.is_empty()); + assert!(!scene.terrain.faces.is_empty()); + assert!(!scene.models.is_empty()); + + let instance_count = scene + .models + .iter() + .map(|model| model.instances.len()) + .sum::(); + assert!(instance_count >= 10); + + let bounds = compute_scene_bounds(&scene).expect("scene bounds should exist"); + assert!(bounds.0[0] <= bounds.1[0]); + assert!(bounds.0[1] <= bounds.1[1]); + assert!(bounds.0[2] <= bounds.1[2]); + } + + #[test] + fn loads_scene_with_textures() { + let Some(root) = game_root() else { + eprintln!("skipping: game root missing"); + return; + }; + + let mission = root + .join("MISSIONS") + .join("CAMPAIGN") + .join("CAMPAIGN.00") + .join("Mission.01") + .join("data.tma"); + if !mission.is_file() { + eprintln!("skipping missing mission sample"); + return; + } + + let scene = load_scene_with_options(&root, &mission, LoadOptions::default()) + .unwrap_or_else(|err| panic!("failed to load textured scene {}: {err}", mission.display())); + + assert!(!scene.models.is_empty()); + let textured_models = scene.models.iter().filter(|model| model.texture.is_some()).count(); + assert!(textured_models > 0, "no model textures resolved"); + assert!(scene.terrain_texture.is_some(), "terrain texture was not resolved"); + } + + #[test] + fn resolves_objects_registry_models() { + let Some(root) = game_root() else { + eprintln!("skipping: game root missing"); + return; + }; + + let registry = root.join("objects.rlb"); + if !registry.is_file() { + eprintln!("skipping missing objects.rlb"); + return; + } + + let cases = [ + ("r_h_01", "bases.rlb", "r_h_01.msh"), + ("s_tree_04", "static.rlb", "s_tree_0_04.msh"), + ("fr_m_brige", "fortif.rlb", "fr_m_brige.msh"), + ]; + + for (key, archive_name, model_name) in cases { + let proto = resolve_objects_registry_model(&root, ®istry, key) + .unwrap_or_else(|err| panic!("failed to resolve '{key}' from objects.rlb: {err}")) + .unwrap_or_else(|| panic!("missing model resolution for '{key}'")); + + let got_archive = proto + .archive_path + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.to_ascii_lowercase()) + .unwrap_or_default(); + assert_eq!(got_archive, archive_name.to_ascii_lowercase()); + assert!( + proto.model_name.eq_ignore_ascii_case(model_name), + "unexpected model for key '{key}': got '{}', expected '{}'", + proto.model_name, + model_name + ); + } + } +} -- cgit v1.2.3