From 4ef08d0bf6366b0bc8ccb6357b794937411f74cc Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Thu, 19 Feb 2026 16:07:01 +0400 Subject: [PATCH] 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/Cargo.toml | 33 + crates/render-mission-demo/src/lib.rs | 881 +++++++++++++++++++++++ crates/render-mission-demo/src/main.rs | 924 +++++++++++++++++++++++++ crates/terrain-core/Cargo.toml | 10 + crates/terrain-core/src/lib.rs | 281 ++++++++ crates/tma/Cargo.toml | 10 + crates/tma/src/lib.rs | 485 +++++++++++++ crates/unitdat/Cargo.toml | 10 + crates/unitdat/src/lib.rs | 180 +++++ docs/specs/object-registry.md | 145 ++++ docs/specs/render.md | 13 + mkdocs.yml | 1 + 12 files changed, 2973 insertions(+) create mode 100644 crates/render-mission-demo/Cargo.toml create mode 100644 crates/render-mission-demo/src/lib.rs create mode 100644 crates/render-mission-demo/src/main.rs create mode 100644 crates/terrain-core/Cargo.toml create mode 100644 crates/terrain-core/src/lib.rs create mode 100644 crates/tma/Cargo.toml create mode 100644 crates/tma/src/lib.rs create mode 100644 crates/unitdat/Cargo.toml create mode 100644 crates/unitdat/src/lib.rs create mode 100644 docs/specs/object-registry.md diff --git a/crates/render-mission-demo/Cargo.toml b/crates/render-mission-demo/Cargo.toml new file mode 100644 index 0000000..d658212 --- /dev/null +++ b/crates/render-mission-demo/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "render-mission-demo" +version = "0.1.0" +edition = "2021" + +[features] +default = [] +demo = ["dep:sdl2", "dep:glow"] + +[dependencies] +encoding_rs = "0.8" +glow = { version = "0.16", optional = true } +nres = { path = "../nres" } +render-core = { path = "../render-core" } +render-demo = { path = "../render-demo" } +tma = { path = "../tma" } +terrain-core = { path = "../terrain-core" } +texm = { path = "../texm" } +unitdat = { path = "../unitdat" } + +[dev-dependencies] +common = { path = "../common" } + +[target.'cfg(target_os = "macos")'.dependencies] +sdl2 = { version = "0.37", optional = true, default-features = false, features = ["use-pkgconfig"] } + +[target.'cfg(not(target_os = "macos"))'.dependencies] +sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] } + +[[bin]] +name = "parkan-render-mission-demo" +path = "src/main.rs" +required-features = ["demo"] 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 + ); + } + } +} diff --git a/crates/render-mission-demo/src/main.rs b/crates/render-mission-demo/src/main.rs new file mode 100644 index 0000000..01b6e06 --- /dev/null +++ b/crates/render-mission-demo/src/main.rs @@ -0,0 +1,924 @@ +use glow::HasContext as _; +use render_mission_demo::{ + compute_scene_bounds, detect_game_root_from_mission_path, load_scene_with_options, LoadOptions, + MissionScene, ModelInstance, +}; +use std::io::Write as _; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +struct Args { + mission: PathBuf, + game_root: Option, + width: u32, + height: u32, + fov_deg: f32, + no_model_texture: bool, + no_terrain_texture: bool, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum GlBackend { + Gles2, + Core33, +} + +struct GpuTexture { + handle: glow::NativeTexture, +} + +struct GpuRenderable { + vbo: glow::NativeBuffer, + ebo: glow::NativeBuffer, + index_count: usize, + texture: Option, +} + +struct ModelRenderable { + gpu: GpuRenderable, + instances: Vec, +} + +#[derive(Copy, Clone, Debug)] +struct Camera { + position: [f32; 3], + yaw: f32, + pitch: f32, + move_speed: f32, + mouse_sensitivity: f32, +} + +fn parse_args() -> Result { + let mut mission = None; + let mut game_root = None; + let mut width = 1600u32; + let mut height = 900u32; + let mut fov_deg = 60.0f32; + let mut no_model_texture = false; + let mut no_terrain_texture = false; + + let mut it = std::env::args().skip(1); + while let Some(arg) = it.next() { + match arg.as_str() { + "--mission" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --mission"))?; + mission = Some(PathBuf::from(value)); + } + "--game-root" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --game-root"))?; + game_root = Some(PathBuf::from(value)); + } + "--width" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --width"))?; + width = value + .parse::() + .map_err(|_| String::from("invalid --width value"))?; + if width == 0 { + return Err(String::from("--width must be > 0")); + } + } + "--height" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --height"))?; + height = value + .parse::() + .map_err(|_| String::from("invalid --height value"))?; + if height == 0 { + return Err(String::from("--height must be > 0")); + } + } + "--fov" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --fov"))?; + fov_deg = value + .parse::() + .map_err(|_| String::from("invalid --fov value"))?; + if !(1.0..=179.0).contains(&fov_deg) { + return Err(String::from("--fov must be in range [1, 179]")); + } + } + "--no-model-texture" => { + no_model_texture = true; + } + "--no-terrain-texture" => { + no_terrain_texture = true; + } + "--help" | "-h" => { + print_help(); + std::process::exit(0); + } + other => { + return Err(format!("unknown argument: {other}")); + } + } + } + + let mission = mission.ok_or_else(|| String::from("missing required --mission"))?; + Ok(Args { + mission, + game_root, + width, + height, + fov_deg, + no_model_texture, + no_terrain_texture, + }) +} + +fn print_help() { + eprintln!("parkan-render-mission-demo --mission [--game-root ] [--width W] [--height H] [--fov DEG]"); + eprintln!(" [--no-model-texture] [--no-terrain-texture]"); + eprintln!("controls: arrows/WASD move, PageUp/PageDown vertical move, Right Mouse drag look, Shift speed-up, Esc exit"); +} + +fn main() { + let args = match parse_args() { + Ok(v) => v, + Err(err) => { + eprintln!("{err}"); + print_help(); + std::process::exit(2); + } + }; + + if let Err(err) = run(args) { + eprintln!("{err}"); + std::process::exit(1); + } +} + +fn run(args: Args) -> Result<(), String> { + let game_root = if let Some(path) = args.game_root.clone() { + path + } else { + detect_game_root_from_mission_path(&args.mission).ok_or_else(|| { + format!( + "failed to detect game root from mission path {} (use --game-root)", + args.mission.display() + ) + })? + }; + + let scene = load_scene_with_options( + &game_root, + &args.mission, + LoadOptions { + load_model_textures: !args.no_model_texture, + load_terrain_texture: !args.no_terrain_texture, + }, + ) + .map_err(|err| format!("failed to load mission scene: {err}"))?; + + let terrain_mesh = terrain_core::build_render_mesh(&scene.terrain) + .map_err(|err| format!("failed to build terrain render mesh: {err}"))?; + + let instance_count = scene + .models + .iter() + .map(|model| model.instances.len()) + .sum::(); + println!( + "mission loaded: map='{}', terrain_vertices={}, terrain_faces={}, models={}, instances={}, skipped={}", + scene.mission.footer.map_path, + scene.terrain.positions.len(), + scene.terrain.faces.len(), + scene.models.len(), + instance_count, + scene.skipped_objects + ); + + let sdl = sdl2::init().map_err(|err| format!("failed to init SDL2: {err}"))?; + let video = sdl + .video() + .map_err(|err| format!("failed to init SDL2 video: {err}"))?; + + let (mut window, _gl_ctx, gl_backend) = + create_window_and_context(&video, args.width, args.height)?; + let _ = video.gl_set_swap_interval(1); + + let gl = unsafe { + glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _) + }; + + let program = unsafe { create_program(&gl, gl_backend)? }; + let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") }; + let u_use_tex = unsafe { gl.get_uniform_location(program, "u_use_tex") }; + let u_tex = unsafe { gl.get_uniform_location(program, "u_tex") }; + let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") } + .ok_or_else(|| String::from("shader attribute a_pos is missing"))?; + let a_uv = unsafe { gl.get_attrib_location(program, "a_uv") } + .ok_or_else(|| String::from("shader attribute a_uv is missing"))?; + + let terrain_gpu = + unsafe { upload_terrain_renderable(&gl, &terrain_mesh, scene.terrain_texture.as_ref())? }; + + let mut model_gpus = Vec::new(); + for model in &scene.models { + let renderable = unsafe { upload_model_renderable(&gl, model)? }; + model_gpus.push(renderable); + } + + let (scene_center, scene_radius) = initial_scene_sphere(&scene); + let mut camera = Camera { + position: [ + scene_center[0], + scene_center[1] + scene_radius * 0.6, + scene_center[2] + scene_radius * 1.4, + ], + yaw: std::f32::consts::PI, + pitch: -0.28, + move_speed: (scene_radius * 0.55).max(60.0), + mouse_sensitivity: 0.005, + }; + + let mut events = sdl + .event_pump() + .map_err(|err| format!("failed to get SDL event pump: {err}"))?; + let mut last = Instant::now(); + let mut fps_window_start = Instant::now(); + let mut fps_frames = 0u32; + let mut fps_printed = false; + let mut mouse_look = false; + + 'main_loop: loop { + for event in events.poll_iter() { + match event { + sdl2::event::Event::Quit { .. } => break 'main_loop, + sdl2::event::Event::KeyDown { + keycode: Some(sdl2::keyboard::Keycode::Escape), + .. + } => break 'main_loop, + sdl2::event::Event::MouseButtonDown { + mouse_btn: sdl2::mouse::MouseButton::Right, + .. + } => { + mouse_look = true; + sdl.mouse().set_relative_mouse_mode(true); + } + sdl2::event::Event::MouseButtonUp { + mouse_btn: sdl2::mouse::MouseButton::Right, + .. + } => { + mouse_look = false; + sdl.mouse().set_relative_mouse_mode(false); + } + sdl2::event::Event::MouseMotion { xrel, yrel, .. } if mouse_look => { + camera.yaw += xrel as f32 * camera.mouse_sensitivity; + camera.pitch -= yrel as f32 * camera.mouse_sensitivity; + camera.pitch = camera.pitch.clamp(-1.54, 1.54); + } + _ => {} + } + } + + let now = Instant::now(); + let dt = (now - last).as_secs_f32().clamp(0.0, 0.05); + last = now; + + update_camera(&events, &mut camera, dt); + + let (w, h) = window.size(); + let proj = mat4_perspective( + args.fov_deg.to_radians(), + (w as f32 / h.max(1) as f32).max(0.01), + 0.1, + (scene_radius * 25.0).max(5000.0), + ); + let forward = camera_forward(camera.yaw, camera.pitch); + let view = mat4_look_at( + camera.position, + [ + camera.position[0] + forward[0], + camera.position[1] + forward[1], + camera.position[2] + forward[2], + ], + [0.0, 1.0, 0.0], + ); + + unsafe { + draw_frame_begin(&gl, w, h); + + let terrain_mvp = mat4_mul(&proj, &view); + draw_gpu_renderable( + &gl, + program, + u_mvp.as_ref(), + u_use_tex.as_ref(), + u_tex.as_ref(), + a_pos, + a_uv, + &terrain_gpu, + &terrain_mvp, + ); + + for model in &model_gpus { + for instance in &model.instances { + let model_m = model_matrix(instance.position, instance.yaw_rad, instance.scale); + let view_model = mat4_mul(&view, &model_m); + let mvp = mat4_mul(&proj, &view_model); + draw_gpu_renderable( + &gl, + program, + u_mvp.as_ref(), + u_use_tex.as_ref(), + u_tex.as_ref(), + a_pos, + a_uv, + &model.gpu, + &mvp, + ); + } + } + } + + window.gl_swap_window(); + + fps_frames = fps_frames.saturating_add(1); + let elapsed = fps_window_start.elapsed(); + if elapsed >= Duration::from_millis(500) { + let fps = fps_frames as f32 / elapsed.as_secs_f32().max(0.000_1); + let frame_time_ms = 1000.0 / fps.max(0.000_1); + let _ = window.set_title(&format!( + "Parkan Mission Demo | FPS: {fps:.1} ({frame_time_ms:.2} ms) | objects: {instance_count}" + )); + print!("\rFPS: {fps:.1} ({frame_time_ms:.2} ms)"); + let _ = std::io::stdout().flush(); + fps_printed = true; + fps_frames = 0; + fps_window_start = Instant::now(); + } + } + + if fps_printed { + println!(); + } + + unsafe { + cleanup_renderable(&gl, terrain_gpu); + for model in model_gpus { + cleanup_renderable(&gl, model.gpu); + } + gl.delete_program(program); + } + + Ok(()) +} + +fn initial_scene_sphere(scene: &MissionScene) -> ([f32; 3], f32) { + if let Some((min_v, max_v)) = compute_scene_bounds(scene) { + let center = [ + 0.5 * (min_v[0] + max_v[0]), + 0.5 * (min_v[1] + max_v[1]), + 0.5 * (min_v[2] + max_v[2]), + ]; + let extent = [ + max_v[0] - min_v[0], + max_v[1] - min_v[1], + max_v[2] - min_v[2], + ]; + let radius = ((extent[0] * extent[0]) + (extent[1] * extent[1]) + (extent[2] * extent[2])) + .sqrt() + .max(10.0) + * 0.5; + return (center, radius); + } + ([0.0, 0.0, 0.0], 100.0) +} + +fn update_camera(events: &sdl2::EventPump, camera: &mut Camera, dt: f32) { + use sdl2::keyboard::Scancode; + + let keys = events.keyboard_state(); + let mut move_dir = [0.0f32, 0.0f32, 0.0f32]; + + let forward = camera_forward(camera.yaw, camera.pitch); + let right = normalize3(cross3(forward, [0.0, 1.0, 0.0])); + + if keys.is_scancode_pressed(Scancode::Up) || keys.is_scancode_pressed(Scancode::W) { + move_dir[0] += forward[0]; + move_dir[1] += forward[1]; + move_dir[2] += forward[2]; + } + if keys.is_scancode_pressed(Scancode::Down) || keys.is_scancode_pressed(Scancode::S) { + move_dir[0] -= forward[0]; + move_dir[1] -= forward[1]; + move_dir[2] -= forward[2]; + } + if keys.is_scancode_pressed(Scancode::Left) || keys.is_scancode_pressed(Scancode::A) { + move_dir[0] -= right[0]; + move_dir[1] -= right[1]; + move_dir[2] -= right[2]; + } + if keys.is_scancode_pressed(Scancode::Right) || keys.is_scancode_pressed(Scancode::D) { + move_dir[0] += right[0]; + move_dir[1] += right[1]; + move_dir[2] += right[2]; + } + if keys.is_scancode_pressed(Scancode::PageUp) || keys.is_scancode_pressed(Scancode::E) { + move_dir[1] += 1.0; + } + if keys.is_scancode_pressed(Scancode::PageDown) || keys.is_scancode_pressed(Scancode::Q) { + move_dir[1] -= 1.0; + } + + let shift = + keys.is_scancode_pressed(Scancode::LShift) || keys.is_scancode_pressed(Scancode::RShift); + let speed_mul = if shift { 3.0 } else { 1.0 }; + + let norm = normalize3(move_dir); + camera.position[0] += norm[0] * camera.move_speed * speed_mul * dt; + camera.position[1] += norm[1] * camera.move_speed * speed_mul * dt; + camera.position[2] += norm[2] * camera.move_speed * speed_mul * dt; +} + +unsafe fn upload_model_renderable( + gl: &glow::Context, + model: &render_mission_demo::SceneModel, +) -> Result { + let mut vertex_data = Vec::with_capacity(model.mesh.vertices.len() * 5); + for vertex in &model.mesh.vertices { + vertex_data.push(vertex.position[0]); + vertex_data.push(vertex.position[1]); + vertex_data.push(vertex.position[2]); + vertex_data.push(vertex.uv0[0]); + vertex_data.push(vertex.uv0[1]); + } + + let gpu = upload_gpu_renderable( + gl, + &vertex_data, + &model.mesh.indices, + model.texture.as_ref(), + )?; + + Ok(ModelRenderable { + gpu, + instances: model.instances.clone(), + }) +} + +unsafe fn upload_terrain_renderable( + gl: &glow::Context, + mesh: &terrain_core::TerrainRenderMesh, + texture: Option<&render_demo::LoadedTexture>, +) -> Result { + let mut vertex_data = Vec::with_capacity(mesh.vertices.len() * 5); + for vertex in &mesh.vertices { + vertex_data.push(vertex.position[0]); + vertex_data.push(vertex.position[1]); + vertex_data.push(vertex.position[2]); + vertex_data.push(vertex.uv0[0]); + vertex_data.push(vertex.uv0[1]); + } + + upload_gpu_renderable(gl, &vertex_data, &mesh.indices, texture) +} + +unsafe fn upload_gpu_renderable( + gl: &glow::Context, + vertices: &[f32], + indices: &[u16], + texture: Option<&render_demo::LoadedTexture>, +) -> Result { + let vbo = gl.create_buffer().map_err(|e| e.to_string())?; + let ebo = gl.create_buffer().map_err(|e| e.to_string())?; + + let vertex_bytes = f32_slice_to_ne_bytes(vertices); + let index_bytes = u16_slice_to_ne_bytes(indices); + + gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); + gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, &vertex_bytes, glow::STATIC_DRAW); + gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo)); + gl.buffer_data_u8_slice(glow::ELEMENT_ARRAY_BUFFER, &index_bytes, glow::STATIC_DRAW); + gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None); + gl.bind_buffer(glow::ARRAY_BUFFER, None); + + let gpu_texture = if let Some(texture) = texture { + Some(create_texture(gl, texture)?) + } else { + None + }; + + Ok(GpuRenderable { + vbo, + ebo, + index_count: indices.len(), + texture: gpu_texture, + }) +} + +unsafe fn cleanup_renderable(gl: &glow::Context, renderable: GpuRenderable) { + if let Some(tex) = renderable.texture { + gl.delete_texture(tex.handle); + } + gl.delete_buffer(renderable.ebo); + gl.delete_buffer(renderable.vbo); +} + +unsafe fn draw_frame_begin(gl: &glow::Context, width: u32, height: u32) { + gl.viewport( + 0, + 0, + width.min(i32::MAX as u32) as i32, + height.min(i32::MAX as u32) as i32, + ); + gl.enable(glow::DEPTH_TEST); + gl.clear_color(0.06, 0.08, 0.12, 1.0); + gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT); +} + +unsafe fn draw_gpu_renderable( + gl: &glow::Context, + program: glow::NativeProgram, + u_mvp: Option<&glow::NativeUniformLocation>, + u_use_tex: Option<&glow::NativeUniformLocation>, + u_tex: Option<&glow::NativeUniformLocation>, + a_pos: u32, + a_uv: u32, + renderable: &GpuRenderable, + mvp: &[f32; 16], +) { + gl.use_program(Some(program)); + gl.uniform_matrix_4_f32_slice(u_mvp, false, mvp); + + let texture_enabled = renderable.texture.is_some(); + gl.uniform_1_f32(u_use_tex, if texture_enabled { 1.0 } else { 0.0 }); + + if let Some(tex) = &renderable.texture { + gl.active_texture(glow::TEXTURE0); + gl.bind_texture(glow::TEXTURE_2D, Some(tex.handle)); + gl.uniform_1_i32(u_tex, 0); + } else { + gl.bind_texture(glow::TEXTURE_2D, None); + } + + gl.bind_buffer(glow::ARRAY_BUFFER, Some(renderable.vbo)); + gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(renderable.ebo)); + gl.enable_vertex_attrib_array(a_pos); + gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0); + gl.enable_vertex_attrib_array(a_uv); + gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12); + + gl.draw_elements( + glow::TRIANGLES, + renderable.index_count.min(i32::MAX as usize) as i32, + glow::UNSIGNED_SHORT, + 0, + ); + + gl.disable_vertex_attrib_array(a_uv); + gl.disable_vertex_attrib_array(a_pos); + gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None); + gl.bind_buffer(glow::ARRAY_BUFFER, None); + gl.bind_texture(glow::TEXTURE_2D, None); + gl.use_program(None); +} + +fn create_window_and_context( + video: &sdl2::VideoSubsystem, + width: u32, + height: u32, +) -> Result<(sdl2::video::Window, sdl2::video::GLContext, GlBackend), String> { + let candidates = [ + (GlBackend::Gles2, sdl2::video::GLProfile::GLES, 2, 0), + (GlBackend::Core33, sdl2::video::GLProfile::Core, 3, 3), + ]; + let mut errors = Vec::new(); + + for (backend, profile, major, minor) in candidates { + { + let gl_attr = video.gl_attr(); + gl_attr.set_context_profile(profile); + gl_attr.set_context_version(major, minor); + gl_attr.set_depth_size(24); + gl_attr.set_double_buffer(true); + } + + let mut window_builder = video.window("Parkan Mission Demo", width, height); + window_builder.opengl().resizable(); + + let window = match window_builder.build() { + Ok(window) => window, + Err(err) => { + errors.push(format!( + "{profile:?} {major}.{minor}: window build failed ({err})" + )); + continue; + } + }; + + let gl_ctx = match window.gl_create_context() { + Ok(ctx) => ctx, + Err(err) => { + errors.push(format!( + "{profile:?} {major}.{minor}: context create failed ({err})" + )); + continue; + } + }; + + if let Err(err) = window.gl_make_current(&gl_ctx) { + errors.push(format!( + "{profile:?} {major}.{minor}: make current failed ({err})" + )); + continue; + } + + return Ok((window, gl_ctx, backend)); + } + + Err(format!( + "failed to create OpenGL context. Attempts: {}", + errors.join(" | ") + )) +} + +unsafe fn create_texture( + gl: &glow::Context, + texture: &render_demo::LoadedTexture, +) -> Result { + let handle = gl.create_texture().map_err(|e| e.to_string())?; + gl.bind_texture(glow::TEXTURE_2D, Some(handle)); + gl.tex_parameter_i32( + glow::TEXTURE_2D, + glow::TEXTURE_MIN_FILTER, + glow::LINEAR as i32, + ); + gl.tex_parameter_i32( + glow::TEXTURE_2D, + glow::TEXTURE_MAG_FILTER, + glow::LINEAR as i32, + ); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32); + gl.pixel_store_i32(glow::UNPACK_ALIGNMENT, 1); + gl.tex_image_2d( + glow::TEXTURE_2D, + 0, + glow::RGBA as i32, + texture.width.min(i32::MAX as u32) as i32, + texture.height.min(i32::MAX as u32) as i32, + 0, + glow::RGBA, + glow::UNSIGNED_BYTE, + glow::PixelUnpackData::Slice(Some(texture.rgba8.as_slice())), + ); + gl.bind_texture(glow::TEXTURE_2D, None); + Ok(GpuTexture { handle }) +} + +unsafe fn create_program( + gl: &glow::Context, + backend: GlBackend, +) -> Result { + let (vs_src, fs_src) = match backend { + GlBackend::Gles2 => ( + r#" +attribute vec3 a_pos; +attribute vec2 a_uv; +uniform mat4 u_mvp; +varying vec2 v_uv; +void main() { + v_uv = a_uv; + gl_Position = u_mvp * vec4(a_pos, 1.0); +} +"#, + r#" +precision mediump float; +uniform sampler2D u_tex; +uniform float u_use_tex; +varying vec2 v_uv; +void main() { + vec4 base = vec4(0.82, 0.87, 0.95, 1.0); + vec4 texColor = texture2D(u_tex, v_uv); + gl_FragColor = mix(base, texColor, u_use_tex); +} +"#, + ), + GlBackend::Core33 => ( + r#"#version 330 core +in vec3 a_pos; +in vec2 a_uv; +uniform mat4 u_mvp; +out vec2 v_uv; +void main() { + v_uv = a_uv; + gl_Position = u_mvp * vec4(a_pos, 1.0); +} +"#, + r#"#version 330 core +uniform sampler2D u_tex; +uniform float u_use_tex; +in vec2 v_uv; +out vec4 fragColor; +void main() { + vec4 base = vec4(0.82, 0.87, 0.95, 1.0); + vec4 texColor = texture(u_tex, v_uv); + fragColor = mix(base, texColor, u_use_tex); +} +"#, + ), + }; + + let program = gl.create_program().map_err(|e| e.to_string())?; + let vs = gl + .create_shader(glow::VERTEX_SHADER) + .map_err(|e| e.to_string())?; + let fs = gl + .create_shader(glow::FRAGMENT_SHADER) + .map_err(|e| e.to_string())?; + + gl.shader_source(vs, vs_src); + gl.compile_shader(vs); + if !gl.get_shader_compile_status(vs) { + let log = gl.get_shader_info_log(vs); + gl.delete_shader(vs); + gl.delete_shader(fs); + gl.delete_program(program); + return Err(format!("vertex shader compile failed: {log}")); + } + + gl.shader_source(fs, fs_src); + gl.compile_shader(fs); + if !gl.get_shader_compile_status(fs) { + let log = gl.get_shader_info_log(fs); + gl.delete_shader(vs); + gl.delete_shader(fs); + gl.delete_program(program); + return Err(format!("fragment shader compile failed: {log}")); + } + + gl.attach_shader(program, vs); + gl.attach_shader(program, fs); + gl.link_program(program); + + gl.detach_shader(program, vs); + gl.detach_shader(program, fs); + gl.delete_shader(vs); + gl.delete_shader(fs); + + if !gl.get_program_link_status(program) { + let log = gl.get_program_info_log(program); + gl.delete_program(program); + return Err(format!("program link failed: {log}")); + } + + Ok(program) +} + +fn model_matrix(position: [f32; 3], yaw: f32, scale: [f32; 3]) -> [f32; 16] { + let translation = mat4_translation(position[0], position[1], position[2]); + let rotation = mat4_rotation_y(yaw); + let scaling = mat4_scale(scale[0], scale[1], scale[2]); + let tr = mat4_mul(&translation, &rotation); + mat4_mul(&tr, &scaling) +} + +fn camera_forward(yaw: f32, pitch: f32) -> [f32; 3] { + let cp = pitch.cos(); + normalize3([yaw.sin() * cp, pitch.sin(), yaw.cos() * cp]) +} + +fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} + +fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn normalize3(v: [f32; 3]) -> [f32; 3] { + let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + if len <= 1e-6 { + [0.0, 0.0, 0.0] + } else { + [v[0] / len, v[1] / len, v[2] / len] + } +} + +fn mat4_identity() -> [f32; 16] { + [ + 1.0, 0.0, 0.0, 0.0, // + 0.0, 1.0, 0.0, 0.0, // + 0.0, 0.0, 1.0, 0.0, // + 0.0, 0.0, 0.0, 1.0, // + ] +} + +fn mat4_translation(x: f32, y: f32, z: f32) -> [f32; 16] { + let mut m = mat4_identity(); + m[12] = x; + m[13] = y; + m[14] = z; + m +} + +fn mat4_scale(x: f32, y: f32, z: f32) -> [f32; 16] { + [ + x, 0.0, 0.0, 0.0, // + 0.0, y, 0.0, 0.0, // + 0.0, 0.0, z, 0.0, // + 0.0, 0.0, 0.0, 1.0, // + ] +} + +fn mat4_rotation_y(rad: f32) -> [f32; 16] { + let c = rad.cos(); + let s = rad.sin(); + [ + c, 0.0, -s, 0.0, // + 0.0, 1.0, 0.0, 0.0, // + s, 0.0, c, 0.0, // + 0.0, 0.0, 0.0, 1.0, // + ] +} + +fn mat4_perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> [f32; 16] { + let f = 1.0 / (0.5 * fovy).tan(); + let nf = 1.0 / (near - far); + [ + f / aspect, + 0.0, + 0.0, + 0.0, + 0.0, + f, + 0.0, + 0.0, + 0.0, + 0.0, + (far + near) * nf, + -1.0, + 0.0, + 0.0, + (2.0 * far * near) * nf, + 0.0, + ] +} + +fn mat4_look_at(eye: [f32; 3], target: [f32; 3], up: [f32; 3]) -> [f32; 16] { + let f = normalize3([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]); + let s = normalize3(cross3(f, up)); + let u = cross3(s, f); + + [ + s[0], + u[0], + -f[0], + 0.0, + s[1], + u[1], + -f[1], + 0.0, + s[2], + u[2], + -f[2], + 0.0, + -dot3(s, eye), + -dot3(u, eye), + dot3(f, eye), + 1.0, + ] +} + +fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] { + let mut out = [0.0f32; 16]; + for c in 0..4 { + for r in 0..4 { + let mut acc = 0.0f32; + for k in 0..4 { + acc += a[k * 4 + r] * b[c * 4 + k]; + } + out[c * 4 + r] = acc; + } + } + out +} + +fn f32_slice_to_ne_bytes(slice: &[f32]) -> Vec { + let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::())); + for &value in slice { + out.extend_from_slice(&value.to_ne_bytes()); + } + out +} + +fn u16_slice_to_ne_bytes(slice: &[u16]) -> Vec { + let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::())); + for &value in slice { + out.extend_from_slice(&value.to_ne_bytes()); + } + out +} diff --git a/crates/terrain-core/Cargo.toml b/crates/terrain-core/Cargo.toml new file mode 100644 index 0000000..fd4380f --- /dev/null +++ b/crates/terrain-core/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "terrain-core" +version = "0.1.0" +edition = "2021" + +[dependencies] +nres = { path = "../nres" } + +[dev-dependencies] +common = { path = "../common" } diff --git a/crates/terrain-core/src/lib.rs b/crates/terrain-core/src/lib.rs new file mode 100644 index 0000000..36a3e42 --- /dev/null +++ b/crates/terrain-core/src/lib.rs @@ -0,0 +1,281 @@ +use nres::Archive; +use std::fmt; +use std::path::Path; + +pub const TERRAIN_UV_SCALE: f32 = 1024.0; + +pub type Result = core::result::Result; + +#[derive(Debug)] +pub enum Error { + Nres(nres::error::Error), + MissingChunk(&'static str), + InvalidChunkSize { + label: &'static str, + size: usize, + stride: usize, + }, + VertexCountOverflow { + count: usize, + }, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Nres(err) => write!(f, "{err}"), + Self::MissingChunk(label) => write!(f, "missing required terrain chunk: {label}"), + Self::InvalidChunkSize { + label, + size, + stride, + } => write!( + f, + "invalid chunk size for {label}: {size} (must be divisible by {stride})" + ), + Self::VertexCountOverflow { count } => { + write!(f, "terrain vertex count {count} exceeds u16 range") + } + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Nres(err) => Some(err), + _ => None, + } + } +} + +impl From for Error { + fn from(value: nres::error::Error) -> Self { + Self::Nres(value) + } +} + +#[derive(Clone, Debug)] +pub struct TerrainMesh { + pub positions: Vec<[f32; 3]>, + pub uv0: Vec<[f32; 2]>, + pub faces: Vec, +} + +#[derive(Copy, Clone, Debug)] +pub struct TerrainFace { + pub indices: [u16; 3], + pub flags: u32, + pub material_tag: u16, + pub aux_tag: u16, +} + +#[derive(Clone, Debug)] +pub struct TerrainRenderMesh { + pub vertices: Vec, + pub indices: Vec, + pub face_count_raw: usize, + pub face_count_kept: usize, + pub face_count_dropped_invalid: usize, +} + +#[derive(Copy, Clone, Debug)] +pub struct TerrainRenderVertex { + pub position: [f32; 3], + pub uv0: [f32; 2], +} + +pub fn load_land_mesh(path: impl AsRef) -> Result { + let archive = Archive::open_path(path.as_ref())?; + + let positions_entry = archive + .entries() + .find(|entry| entry.meta.kind == 3) + .ok_or(Error::MissingChunk("type=3 (positions)"))?; + let uv_entry = archive.entries().find(|entry| entry.meta.kind == 5); + let faces_entry = archive + .entries() + .find(|entry| entry.meta.kind == 21) + .ok_or(Error::MissingChunk("type=21 (faces)"))?; + + let positions_payload = archive.read(positions_entry.id)?.into_owned(); + if positions_payload.len() % 12 != 0 { + return Err(Error::InvalidChunkSize { + label: "type=3 (positions)", + size: positions_payload.len(), + stride: 12, + }); + } + + let mut positions = Vec::with_capacity(positions_payload.len() / 12); + for chunk in positions_payload.chunks_exact(12) { + let x = f32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4])); + let y = f32::from_le_bytes(chunk[4..8].try_into().unwrap_or([0; 4])); + let z = f32::from_le_bytes(chunk[8..12].try_into().unwrap_or([0; 4])); + positions.push([x, y, z]); + } + + let mut uv0 = vec![[0.0f32, 0.0f32]; positions.len()]; + if let Some(uv_entry) = uv_entry { + let uv_payload = archive.read(uv_entry.id)?.into_owned(); + if uv_payload.len() % 4 != 0 { + return Err(Error::InvalidChunkSize { + label: "type=5 (uv)", + size: uv_payload.len(), + stride: 4, + }); + } + let uv_count = uv_payload.len() / 4; + for idx in 0..uv_count.min(uv0.len()) { + let off = idx * 4; + let u = i16::from_le_bytes([uv_payload[off], uv_payload[off + 1]]) as f32; + let v = i16::from_le_bytes([uv_payload[off + 2], uv_payload[off + 3]]) as f32; + uv0[idx] = [u / TERRAIN_UV_SCALE, v / TERRAIN_UV_SCALE]; + } + } + + let face_payload = archive.read(faces_entry.id)?.into_owned(); + if face_payload.len() % 28 != 0 { + return Err(Error::InvalidChunkSize { + label: "type=21 (faces)", + size: face_payload.len(), + stride: 28, + }); + } + + let mut faces = Vec::with_capacity(face_payload.len() / 28); + for chunk in face_payload.chunks_exact(28) { + let flags = u32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4])); + let material_tag = u16::from_le_bytes(chunk[4..6].try_into().unwrap_or([0; 2])); + let aux_tag = u16::from_le_bytes(chunk[6..8].try_into().unwrap_or([0; 2])); + let i0 = u16::from_le_bytes(chunk[8..10].try_into().unwrap_or([0; 2])); + let i1 = u16::from_le_bytes(chunk[10..12].try_into().unwrap_or([0; 2])); + let i2 = u16::from_le_bytes(chunk[12..14].try_into().unwrap_or([0; 2])); + if usize::from(i0) >= positions.len() + || usize::from(i1) >= positions.len() + || usize::from(i2) >= positions.len() + { + continue; + } + faces.push(TerrainFace { + indices: [i0, i1, i2], + flags, + material_tag, + aux_tag, + }); + } + + Ok(TerrainMesh { + positions, + uv0, + faces, + }) +} + +pub fn build_render_mesh(mesh: &TerrainMesh) -> Result { + if mesh.positions.len() > usize::from(u16::MAX) + 1 { + return Err(Error::VertexCountOverflow { + count: mesh.positions.len(), + }); + } + + let vertices = mesh + .positions + .iter() + .enumerate() + .map(|(idx, &position)| TerrainRenderVertex { + position, + uv0: mesh.uv0.get(idx).copied().unwrap_or([0.0, 0.0]), + }) + .collect::>(); + + let mut indices = Vec::with_capacity(mesh.faces.len() * 3); + for face in &mesh.faces { + indices.extend_from_slice(&face.indices); + } + + Ok(TerrainRenderMesh { + vertices, + indices, + face_count_raw: mesh.faces.len(), + face_count_kept: mesh.faces.len(), + face_count_dropped_invalid: 0, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use common::collect_files_recursive; + use std::path::{Path, PathBuf}; + + 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 loads_known_land_mesh() { + let Some(root) = game_root() else { + eprintln!("skipping: game root missing"); + return; + }; + + let land = root + .join("DATA") + .join("MAPS") + .join("Tut_1") + .join("Land.msh"); + if !land.is_file() { + eprintln!("skipping missing sample {}", land.display()); + return; + } + + let mesh = load_land_mesh(&land) + .unwrap_or_else(|err| panic!("failed to parse {}: {err}", land.display())); + assert!(mesh.positions.len() > 1000); + assert!(mesh.faces.len() > 1000); + + let render = build_render_mesh(&mesh).expect("failed to build render mesh"); + assert_eq!(render.vertices.len(), mesh.positions.len()); + assert_eq!(render.indices.len(), mesh.faces.len() * 3); + } + + #[test] + fn loads_all_retail_land_meshes() { + let Some(root) = game_root() else { + eprintln!("skipping: game root missing"); + return; + }; + + let maps_root = root.join("DATA").join("MAPS"); + let mut files = Vec::new(); + collect_files_recursive(&maps_root, &mut files); + files.sort(); + + let mut parsed = 0usize; + for path in files { + if !path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.eq_ignore_ascii_case("Land.msh")) + { + continue; + } + let mesh = load_land_mesh(&path) + .unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display())); + assert!( + !mesh.positions.is_empty() && !mesh.faces.is_empty(), + "{} parsed but empty", + path.display() + ); + parsed += 1; + } + + assert!(parsed > 0, "no Land.msh files parsed"); + } +} diff --git a/crates/tma/Cargo.toml b/crates/tma/Cargo.toml new file mode 100644 index 0000000..99360c3 --- /dev/null +++ b/crates/tma/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "tma" +version = "0.1.0" +edition = "2021" + +[dependencies] +encoding_rs = "0.8" + +[dev-dependencies] +common = { path = "../common" } diff --git a/crates/tma/src/lib.rs b/crates/tma/src/lib.rs new file mode 100644 index 0000000..3b41bc4 --- /dev/null +++ b/crates/tma/src/lib.rs @@ -0,0 +1,485 @@ +use encoding_rs::WINDOWS_1251; +use std::fmt; +use std::fs; +use std::path::Path; + +const OBJECT_RECORD_FLAGS: u32 = 0x8000_0002; +const FOOTER_MAGIC: &[u8; 4] = b"MtPr"; +const MAP_PATH_TOKEN: &[u8; 10] = b"DATA\\MAPS\\"; + +pub type Result = core::result::Result; + +#[derive(Debug)] +pub enum Error { + Io(std::io::Error), + FooterNotFound, + FooterCorrupt(&'static str), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(err) => write!(f, "{err}"), + Self::FooterNotFound => write!(f, "footer magic 'MtPr' not found"), + Self::FooterCorrupt(reason) => write!(f, "corrupt mission footer: {reason}"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(err) => Some(err), + _ => None, + } + } +} + +impl From for Error { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +#[derive(Clone, Debug)] +pub struct MissionFile { + pub footer: MissionFooter, + pub objects: Vec, +} + +#[derive(Clone, Debug)] +pub struct MissionFooter { + pub map_path: String, + pub title: String, + pub version: u32, +} + +#[derive(Clone, Debug)] +pub struct MissionObject { + pub offset: usize, + pub group_id: u32, + pub flags: u32, + pub resource_name: String, + pub logical_id: i32, + pub clan_id: i32, + pub position: [f32; 3], + pub orientation: [f32; 3], + pub scale: [f32; 3], + pub alias: String, +} + +pub fn parse_path(path: impl AsRef) -> Result { + let bytes = fs::read(path.as_ref())?; + parse_bytes(&bytes) +} + +pub fn parse_bytes(bytes: &[u8]) -> Result { + let footer = parse_footer(bytes)?; + let objects = parse_objects(bytes); + Ok(MissionFile { footer, objects }) +} + +fn parse_footer(bytes: &[u8]) -> Result { + let map_positions = find_all_map_path_positions(bytes); + if map_positions.is_empty() { + return Err(Error::FooterNotFound); + } + + for map_start in map_positions.into_iter().rev() { + if map_start < 4 { + continue; + } + + let map_end = scan_path_end(bytes, map_start); + if map_end <= map_start { + continue; + } + let map_len = map_end - map_start; + let Some(declared_map_len) = read_u32(bytes, map_start - 4).map(|v| v as usize) else { + continue; + }; + if declared_map_len != map_len { + continue; + } + + let Some(zero_pad) = read_u32(bytes, map_end) else { + continue; + }; + if zero_pad != 0 { + continue; + } + + let title_len_off = map_end + 4; + let Some(title_len) = read_u32(bytes, title_len_off).map(|v| v as usize) else { + continue; + }; + if title_len == 0 || title_len > 256 { + continue; + } + let title_start = title_len_off + 4; + let Some(title_end) = title_start.checked_add(title_len) else { + continue; + }; + if title_end > bytes.len() { + continue; + } + + let map_path = decode_cp1251(&bytes[map_start..map_end]); + if !map_path.to_ascii_uppercase().contains("DATA\\MAPS\\") { + continue; + } + let title = decode_title(&bytes[title_start..title_end]); + let version = parse_footer_version(bytes, title_end)?; + + return Ok(MissionFooter { + map_path, + title, + version, + }); + } + + // Fallback for multiplayer/legacy variants where the footer tail differs, + // but map path is still present in clear text near EOF. + let Some(map_start) = bytes + .windows(MAP_PATH_TOKEN.len()) + .rposition(|window| window == MAP_PATH_TOKEN) + else { + return Err(Error::FooterCorrupt("failed to decode map/title envelope")); + }; + let map_end = scan_path_end(bytes, map_start); + if map_end <= map_start { + return Err(Error::FooterCorrupt("failed to decode map/title envelope")); + } + let map_path = decode_cp1251(&bytes[map_start..map_end]); + if !map_path.to_ascii_uppercase().contains("DATA\\MAPS\\") { + return Err(Error::FooterCorrupt("failed to decode map/title envelope")); + } + + let mut title = String::new(); + if let Some(title_len) = read_u32(bytes, map_end + 8).map(|v| v as usize) { + let title_start = map_end + 12; + let title_end = title_start.saturating_add(title_len); + if title_len > 0 && title_len <= 256 && title_end <= bytes.len() { + let raw = &bytes[title_start..title_end]; + if raw.iter().all(|b| b.is_ascii_graphic() || *b == b' ') { + title = decode_title(raw); + } + } + } + + let version = if let Some(magic_off) = bytes + .windows(FOOTER_MAGIC.len()) + .rposition(|window| window == FOOTER_MAGIC) + { + read_u32(bytes, magic_off + 4).unwrap_or(1) + } else { + read_u32(bytes, map_end).unwrap_or(1) + }; + + Ok(MissionFooter { + map_path, + title, + version, + }) +} + +fn parse_footer_version(bytes: &[u8], after_title_off: usize) -> Result { + if after_title_off + 8 <= bytes.len() + && &bytes[after_title_off..after_title_off + 4] == FOOTER_MAGIC + { + let version = read_u32(bytes, after_title_off + 4) + .ok_or(Error::FooterCorrupt("missing version after MtPr"))?; + return Ok(version); + } + + let version = read_u32(bytes, after_title_off) + .ok_or(Error::FooterCorrupt("missing version after title"))?; + Ok(version) +} + +fn find_all_map_path_positions(bytes: &[u8]) -> Vec { + bytes + .windows(MAP_PATH_TOKEN.len()) + .enumerate() + .filter_map(|(idx, window)| (window == MAP_PATH_TOKEN).then_some(idx)) + .collect() +} + +fn scan_path_end(bytes: &[u8], start: usize) -> usize { + let mut off = start; + while off < bytes.len() && is_path_byte(bytes[off]) { + off += 1; + } + off +} + +fn is_path_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'/' | b'\\' | b'-' | b' ' | b':') +} + +fn parse_objects(bytes: &[u8]) -> Vec { + let mut objects = Vec::new(); + let min_record_tail = 48usize; + + for offset in 0..bytes.len().saturating_sub(16) { + let Some(flags) = read_u32(bytes, offset + 4) else { + continue; + }; + if flags != OBJECT_RECORD_FLAGS { + continue; + } + + let Some(name_len) = read_u32(bytes, offset + 8).map(|v| v as usize) else { + continue; + }; + if !(3..=260).contains(&name_len) { + continue; + } + + let name_start = offset + 12; + let Some(name_end) = name_start.checked_add(name_len) else { + continue; + }; + if name_end + min_record_tail > bytes.len() { + continue; + } + + let name_raw = &bytes[name_start..name_end]; + if !is_object_name_bytes(name_raw) { + continue; + } + + let resource_name = decode_cp1251(name_raw); + if !looks_like_object_name(&resource_name) { + continue; + } + + let Some(group_id) = read_u32(bytes, offset) else { + continue; + }; + let Some(logical_id) = read_i32(bytes, name_end) else { + continue; + }; + let Some(clan_id) = read_i32(bytes, name_end + 4) else { + continue; + }; + let Some(position) = read_vec3(bytes, name_end + 8) else { + continue; + }; + let Some(orientation) = read_vec3(bytes, name_end + 20) else { + continue; + }; + let Some(scale) = read_vec3(bytes, name_end + 32) else { + continue; + }; + if !all_finite(&position) || !all_finite(&orientation) || !all_finite(&scale) { + continue; + } + + let alias = parse_alias(bytes, name_end + 44); + + objects.push(MissionObject { + offset, + group_id, + flags, + resource_name, + logical_id, + clan_id, + position, + orientation, + scale, + alias, + }); + } + + objects.sort_by_key(|obj| obj.offset); + objects.dedup_by_key(|obj| obj.offset); + objects +} + +fn parse_alias(bytes: &[u8], alias_len_off: usize) -> String { + let Some(alias_len) = read_u32(bytes, alias_len_off).map(|v| v as usize) else { + return String::new(); + }; + if alias_len == 0 || alias_len > 96 { + return String::new(); + } + let alias_start = alias_len_off + 4; + let Some(alias_end) = alias_start.checked_add(alias_len) else { + return String::new(); + }; + if alias_end > bytes.len() { + return String::new(); + } + let alias_raw = &bytes[alias_start..alias_end]; + if !alias_raw + .iter() + .all(|&b| b == b'_' || b == b'-' || b == b'.' || b.is_ascii_alphanumeric()) + { + return String::new(); + } + decode_cp1251(alias_raw) +} + +fn looks_like_object_name(name: &str) -> bool { + if name.ends_with(".dat") { + return true; + } + name.contains('_') +} + +fn is_object_name_bytes(bytes: &[u8]) -> bool { + bytes + .iter() + .all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'_' | b'.' | b'/' | b'\\' | b'-')) +} + +fn all_finite(v: &[f32; 3]) -> bool { + v.iter().all(|c| c.is_finite()) +} + +fn decode_cp1251(bytes: &[u8]) -> String { + let (decoded, _, _) = WINDOWS_1251.decode(bytes); + decoded.into_owned() +} + +fn decode_title(bytes: &[u8]) -> String { + let end = bytes + .iter() + .rposition(|b| *b != 0 && *b != 0xCD) + .map(|idx| idx + 1) + .unwrap_or(0); + decode_cp1251(&bytes[..end]).trim().to_string() +} + +fn read_u32(bytes: &[u8], offset: usize) -> Option { + let end = offset.checked_add(4)?; + let chunk = bytes.get(offset..end)?; + Some(u32::from_le_bytes(chunk.try_into().ok()?)) +} + +fn read_i32(bytes: &[u8], offset: usize) -> Option { + read_u32(bytes, offset).map(|v| v as i32) +} + +fn read_f32(bytes: &[u8], offset: usize) -> Option { + let end = offset.checked_add(4)?; + let chunk = bytes.get(offset..end)?; + Some(f32::from_le_bytes(chunk.try_into().ok()?)) +} + +fn read_vec3(bytes: &[u8], offset: usize) -> Option<[f32; 3]> { + Some([ + read_f32(bytes, offset)?, + read_f32(bytes, offset + 4)?, + read_f32(bytes, offset + 8)?, + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + use common::collect_files_recursive; + use std::path::{Path, PathBuf}; + + 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 parses_known_mission_footer_and_objects() { + let Some(root) = game_root() else { + eprintln!("skipping: game root is missing"); + return; + }; + + let path = root + .join("MISSIONS") + .join("CAMPAIGN") + .join("CAMPAIGN.00") + .join("Mission.01") + .join("data.tma"); + if !path.is_file() { + eprintln!("skipping: sample mission is missing ({})", path.display()); + return; + } + + let mission = parse_path(&path).expect("parse mission failed"); + assert_eq!(mission.footer.version, 1); + assert!( + mission + .footer + .map_path + .eq_ignore_ascii_case("DATA\\MAPS\\Tut_1\\land"), + "unexpected map path: {}", + mission.footer.map_path + ); + assert!(mission.objects.len() >= 20); + assert!(mission + .objects + .iter() + .any(|obj| obj.resource_name.eq_ignore_ascii_case("s_tree_04"))); + assert!(mission.objects.iter().any(|obj| { + obj.resource_name + .eq_ignore_ascii_case("UNITS\\UNITS\\HERO\\tut1_p.dat") + })); + } + + #[test] + fn parses_all_retail_missions() { + let Some(root) = game_root() else { + eprintln!("skipping: game root is missing"); + return; + }; + + let mission_root = root.join("MISSIONS"); + let mut files = Vec::new(); + collect_files_recursive(&mission_root, &mut files); + files.sort(); + + let mut mission_count = 0usize; + for path in files { + if !path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.eq_ignore_ascii_case("data.tma")) + { + continue; + } + + mission_count += 1; + let mission = parse_path(&path) + .unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display())); + assert!( + mission + .footer + .map_path + .to_ascii_uppercase() + .contains("DATA\\MAPS\\"), + "{}: invalid map path '{}'", + path.display(), + mission.footer.map_path + ); + assert!( + !mission.objects.is_empty(), + "{}: mission has no parsed object records", + path.display() + ); + assert!( + mission + .objects + .iter() + .all(|obj| obj.position.iter().all(|v| v.is_finite())), + "{}: mission has non-finite position", + path.display() + ); + } + + assert!(mission_count > 0, "no data.tma files found"); + } +} diff --git a/crates/unitdat/Cargo.toml b/crates/unitdat/Cargo.toml new file mode 100644 index 0000000..73df4df --- /dev/null +++ b/crates/unitdat/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "unitdat" +version = "0.1.0" +edition = "2021" + +[dependencies] +encoding_rs = "0.8" + +[dev-dependencies] +common = { path = "../common" } diff --git a/crates/unitdat/src/lib.rs b/crates/unitdat/src/lib.rs new file mode 100644 index 0000000..6414e66 --- /dev/null +++ b/crates/unitdat/src/lib.rs @@ -0,0 +1,180 @@ +use encoding_rs::WINDOWS_1251; +use std::fmt; +use std::fs; +use std::path::Path; + +const MIN_SIZE: usize = 0x48; +const MAGIC: u32 = 0x0000_F0F1; + +pub type Result = core::result::Result; + +#[derive(Debug)] +pub enum Error { + Io(std::io::Error), + TooSmall { got: usize }, + InvalidMagic { got: u32 }, + MissingArchiveName, + MissingModelKey, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(err) => write!(f, "{err}"), + Self::TooSmall { got } => write!(f, "unit .dat is too small: {got} bytes"), + Self::InvalidMagic { got } => write!(f, "invalid .dat magic: 0x{got:08X}"), + Self::MissingArchiveName => write!(f, "unit .dat has empty archive name"), + Self::MissingModelKey => write!(f, "unit .dat has empty model key"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(err) => Some(err), + _ => None, + } + } +} + +impl From for Error { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +#[derive(Clone, Debug)] +pub struct UnitDat { + pub magic: u32, + pub flags: u32, + pub archive_name: String, + pub model_key: String, +} + +pub fn parse_path(path: impl AsRef) -> Result { + let bytes = fs::read(path.as_ref())?; + parse_bytes(&bytes) +} + +pub fn parse_bytes(bytes: &[u8]) -> Result { + if bytes.len() < MIN_SIZE { + return Err(Error::TooSmall { got: bytes.len() }); + } + + let magic = read_u32(bytes, 0).ok_or(Error::TooSmall { got: bytes.len() })?; + if magic != MAGIC { + return Err(Error::InvalidMagic { got: magic }); + } + + let flags = read_u32(bytes, 4).ok_or(Error::TooSmall { got: bytes.len() })?; + let archive_name = decode_c_string_fixed(&bytes[0x08..0x28]); + if archive_name.is_empty() { + return Err(Error::MissingArchiveName); + } + + let model_key = decode_c_string_fixed(&bytes[0x28..0x48]); + if model_key.is_empty() { + return Err(Error::MissingModelKey); + } + + Ok(UnitDat { + magic, + flags, + archive_name, + model_key, + }) +} + +fn read_u32(bytes: &[u8], offset: usize) -> Option { + let end = offset.checked_add(4)?; + let chunk = bytes.get(offset..end)?; + Some(u32::from_le_bytes(chunk.try_into().ok()?)) +} + +fn decode_c_string_fixed(bytes: &[u8]) -> String { + let used = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); + let (decoded, _, _) = WINDOWS_1251.decode(&bytes[..used]); + decoded.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use common::collect_files_recursive; + use std::path::{Path, PathBuf}; + + 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 parses_known_dat_files() { + let Some(root) = game_root() else { + eprintln!("skipping: game root missing"); + return; + }; + + let samples = [ + root.join("UNITS/UNITS/HERO/tut1_p.dat"), + root.join("UNITS/UNITS/BATTLE/l_targ.dat"), + root.join("UNITS/BUILDS/BRIDGE/m_bridge.dat"), + ]; + + for path in samples { + if !path.is_file() { + eprintln!("skipping missing sample {}", path.display()); + continue; + } + let dat = parse_path(&path) + .unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display())); + assert_eq!(dat.magic, MAGIC); + assert!(dat.archive_name.to_ascii_lowercase().ends_with(".rlb")); + assert!(dat.model_key.contains('_')); + } + } + + #[test] + fn parses_retail_dat_corpus() { + let Some(root) = game_root() else { + eprintln!("skipping: game root missing"); + return; + }; + + let units_root = root.join("UNITS"); + let mut files = Vec::new(); + collect_files_recursive(&units_root, &mut files); + files.sort(); + + let mut parsed = 0usize; + for path in files { + if !path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("dat")) + { + continue; + } + let dat = parse_path(&path) + .unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display())); + assert!( + !dat.archive_name.is_empty(), + "{} empty archive", + path.display() + ); + assert!( + !dat.model_key.is_empty(), + "{} empty model key", + path.display() + ); + parsed += 1; + } + + assert!(parsed > 0, "no .dat files parsed"); + } +} diff --git a/docs/specs/object-registry.md b/docs/specs/object-registry.md new file mode 100644 index 0000000..0e6e2dd --- /dev/null +++ b/docs/specs/object-registry.md @@ -0,0 +1,145 @@ +# Object Registry (`objects.rlb`) + +`objects.rlb` - это не архив с готовыми мешами. +Это реестр игровых прототипов, который связывает логический идентификатор объекта (`r_h_01`, `s_tree_04`, `fr_m_brige`, ...) с набором реальных ресурсов в других архивах. + +Документ описывает формат и runtime-контракт на высоком уровне, без привязки к внутренним именам/адресам из дизассемблера. + +Связанные страницы: + +- [Missions](missions.md) +- [NRes](nres.md) +- [MSH core](msh-core.md) +- [Wear (`WEAR`)](wear.md) +- [Material (`MAT0`)](material.md) +- [Render pipeline](render.md) + +## 1. Роль в пайплайне + +При загрузке миссии движок работает так: + +1. Из `data.tma` получает `resource_name` объекта: + - либо прямой ключ (`s_tree_04`); + - либо путь к `*.dat` (например `UNITS\\UNITS\\HERO\\tut1_p.dat`). +2. Для `*.dat` читает заголовок и получает: + - `archive_name` (в retail-корпусе всегда `objects.rlb`); + - `model_key` (например `R_H_02`). +3. В `objects.rlb` по ключу (`model_key`/`resource_name`) ищет запись прототипа. +4. Из записи прототипа резолвит фактический `*.msh` и архив, где лежит геометрия. +5. Дальше запускается стандартная цепочка: + `MSH -> WEAR -> MAT0 -> Texm`. + +## 2. Контейнер + +`objects.rlb` сам является обычным `NRes`-архивом. + +Практические наблюдения на retail-корпусе: + +- формат заголовка/каталога полностью совпадает с `NRes`; +- payload каждой записи прототипа кратен `64` байтам; +- имя entry в каталоге - это логический ключ объекта (например `r_h_01`, `s_tree_04`). + +## 3. Формат payload записи прототипа + +Payload состоит из массива фиксированных записей: + +```c +struct ObjectRef64 { + char archive_name[32]; // C-строка (CP1251/ASCII) + char resource_name[32]; // C-строка (CP1251/ASCII) +} +``` + +Интерпретация: + +- `archive_name`: архив-источник (`bases.rlb`, `static.rlb`, `fortif.rlb`, `effects.rlb`, ...). +- `resource_name`: имя ресурса в этом архиве (`*.msh`, `*.wea`, `*.cpt`, `*.ctl`, `*.bas`, ...). + +Важно: + +- после первого `NUL` в 32-байтовом поле могут встречаться служебные байты; для runtime-резолва используется только C-строка до первого `NUL`; +- неизвестные хвостовые байты должны сохраняться 1:1 при writer/roundtrip-редактировании. + +## 4. Runtime-резолв геометрии + +Канонический порядок выбора меша: + +1. Найти запись прототипа по ключу в `objects.rlb`. +2. Прочитать список `ObjectRef64`. +3. Если есть ссылка на `*.msh`: + - взять первую валидную ссылку; + - открыть указанный архив; + - загрузить этот `*.msh`. +4. Если `*.msh` нет, но есть `*.bas`: + - взять stem от `*.bas` (`fr_m_brige.bas` -> `fr_m_brige`); + - искать `.msh` в том же архиве (`fortif.rlb`). +5. Если нет ни `*.msh`, ни `*.bas`, объект трактуется как не-геометрический (пример: солнечный/системный объект) и в 3D-проход не попадает. + +## 5. Типовые примеры + +`r_h_01`: + +- `bases.rlb :: r_h_01.msh` +- `bases.rlb :: r_h_01.wea` +- `bases.rlb :: r_h_01.cpt` +- ... + +`s_tree_04`: + +- `static.rlb :: s_tree_0_04.msh` +- `static.rlb :: s_tree_0_04.wea` +- ... + +`fr_m_brige`: + +- прямого `*.msh` в записи нет; +- есть `fortif.rlb :: fr_m_brige.bas`; +- меш резолвится как `fortif.rlb :: fr_m_brige.msh`. + +`sun_01`: + +- ссылки на `*.sun`/effect-ресурсы; +- 3D-меш отсутствует. + +## 6. Инварианты для reader/writer + +Reader: + +- payload записи прототипа должен быть кратен `64`; +- каждая запись читается как две независимые C-строки фиксированной длины; +- поиск в архивах должен быть case-insensitive по ASCII. + +Writer/editor: + +- сохранять порядок `ObjectRef64` без перестановок; +- сохранять неизвестные служебные байты полей 1:1; +- не нормализовать имена, если это не требуется задачей. + +## 7. Валидация + +Проверено на retail-корпусе `testdata/Parkan - Iron Strategy`: + +- все `590` записей `objects.rlb` имеют payload, кратный `64`; +- `554` записей имеют прямую ссылку на `*.msh`; +- `34` записи используют ветку через `*.bas`; +- `2` записи не содержат геометрии (системные/sun). + +Интеграционные тесты в Rust подтверждают резолв: + +- `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` + +## 8. Статус покрытия и что осталось до 100% + +Закрыто: + +1. Формат payload записи прототипа (`ObjectRef64`) и правила чтения. +2. Runtime-алгоритм выбора меша (`*.msh` напрямую и fallback через `*.bas`). +3. Корпусная проверка структуры и интеграционные тесты резолва. + +Осталось: + +1. Полная field-level семантика служебных байтов после `NUL` в `resource_name[32]`. +2. Формальная семантика всех категорий ссылок (`*.ctl`, `*.cpt`, `*.ndp`, `*.sun`) в терминах систем движка (не только render-пути). +3. Writer-спецификация уровня "authoring new prototype from scratch" с гарантией runtime-паритета. diff --git a/docs/specs/render.md b/docs/specs/render.md index 06feaef..ccc941b 100644 --- a/docs/specs/render.md +++ b/docs/specs/render.md @@ -167,3 +167,16 @@ void RenderFrame(Scene* scene, Camera* cam, float dt) { 1. Полный pixel-parity контур с эталонными кадрами оригинального рендера по набору моделей/сцен. 2. Формализация всех render-state деталей (точные blend/depth/cull/state transitions) для гарантии 1:1 в каждом draw-pass. 3. Полный coverage-пакет по динамическим веткам (FX-heavy кадры, сложные material-режимы, lightmap-комбинации). + +## 12. Object registry bridge (`objects.rlb`) + +Для миссионного/юнитного рендера критично учитывать промежуточный слой прототипов: + +1. `TMA`/`*.dat` обычно дают не прямой `*.msh`, а ключ прототипа. +2. Ключ резолвится через `objects.rlb` (реестр ссылок на реальные архивы ресурсов). +3. Только после этого выполняется стандартный путь: + `MSH -> WEAR -> MAT0 -> Texm`. + +Детальная спецификация этого шага вынесена в отдельную страницу: + +- [Object registry (`objects.rlb`)](object-registry.md) diff --git a/mkdocs.yml b/mkdocs.yml index c7bf965..5630470 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -34,6 +34,7 @@ nav: - Texture (Texm): specs/texture.md - Materials index: specs/materials-texm.md - Missions: specs/missions.md + - Object registry (objects.rlb): specs/object-registry.md - MSH animation: specs/msh-animation.md - MSH core: specs/msh-core.md - Network system: specs/network.md