aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--crates/render-mission-demo/Cargo.toml33
-rw-r--r--crates/render-mission-demo/src/lib.rs881
-rw-r--r--crates/render-mission-demo/src/main.rs924
-rw-r--r--crates/terrain-core/Cargo.toml10
-rw-r--r--crates/terrain-core/src/lib.rs281
-rw-r--r--crates/tma/Cargo.toml10
-rw-r--r--crates/tma/src/lib.rs485
-rw-r--r--crates/unitdat/Cargo.toml10
-rw-r--r--crates/unitdat/src/lib.rs180
-rw-r--r--docs/specs/object-registry.md145
-rw-r--r--docs/specs/render.md13
-rw-r--r--mkdocs.yml1
12 files changed, 2973 insertions, 0 deletions
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<T> = core::result::Result<T, Error>;
+
+#[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<std::io::Error> for Error {
+ fn from(value: std::io::Error) -> Self {
+ Self::Io(value)
+ }
+}
+
+impl From<tma::Error> for Error {
+ fn from(value: tma::Error) -> Self {
+ Self::Mission(value)
+ }
+}
+
+impl From<terrain_core::Error> for Error {
+ fn from(value: terrain_core::Error) -> Self {
+ Self::Terrain(value)
+ }
+}
+
+impl From<unitdat::Error> for Error {
+ fn from(value: unitdat::Error) -> Self {
+ Self::UnitDat(value)
+ }
+}
+
+impl From<render_demo::Error> for Error {
+ fn from(value: render_demo::Error) -> Self {
+ Self::RenderDemo(value)
+ }
+}
+
+impl From<nres::error::Error> for Error {
+ fn from(value: nres::error::Error) -> Self {
+ Self::Nres(value)
+ }
+}
+
+impl From<texm::error::Error> 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<LoadedTexture>,
+ pub models: Vec<SceneModel>,
+ pub skipped_objects: usize,
+}
+
+#[derive(Clone, Debug)]
+pub struct SceneModel {
+ pub archive_path: PathBuf,
+ pub model_name: String,
+ pub mesh: RenderMesh,
+ pub texture: Option<LoadedTexture>,
+ pub instances: Vec<ModelInstance>,
+}
+
+#[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<PathBuf> {
+ 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<Path>,
+ mission_path: impl AsRef<Path>,
+) -> Result<MissionScene> {
+ load_scene_with_options(game_root, mission_path, LoadOptions::default())
+}
+
+pub fn load_scene_with_options(
+ game_root: impl AsRef<Path>,
+ mission_path: impl AsRef<Path>,
+ options: LoadOptions,
+) -> Result<MissionScene> {
+ 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<ModelKey, Vec<ModelInstance>> = HashMap::new();
+ let mut prototype_cache: HashMap<String, Option<ObjectPrototype>> = 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<PathBuf> {
+ 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<Option<ObjectPrototype>> {
+ 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<Option<ObjectPrototype>> {
+ 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<Option<ObjectPrototype>> {
+ 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<nres::EntryId> {
+ 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<Option<ObjectPrototype>> {
+ 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<ObjectRef> {
+ 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<bool> {
+ 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<nres::EntryId> {
+ 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<String> {
+ 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<String>, 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<Option<LoadedTexture>> {
+ 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<String> {
+ let text = decode_cp1251(bytes).replace('\r', "");
+ let mut lines = text.lines();
+ let count = lines.next()?.trim().parse::<usize>().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<Option<String>> {
+ 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<Option<String>> {
+ 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<Option<LoadedTexture>> {
+ 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<PathBuf> {
+ 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::<usize>();
+ 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, &registry, 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<PathBuf>,
+ 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<GpuTexture>,
+}
+
+struct ModelRenderable {
+ gpu: GpuRenderable,
+ instances: Vec<ModelInstance>,
+}
+
+#[derive(Copy, Clone, Debug)]
+struct Camera {
+ position: [f32; 3],
+ yaw: f32,
+ pitch: f32,
+ move_speed: f32,
+ mouse_sensitivity: f32,
+}
+
+fn parse_args() -> Result<Args, String> {
+ 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::<u32>()
+ .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::<u32>()
+ .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::<f32>()
+ .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 <path/to/data.tma> [--game-root <path>] [--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::<usize>();
+ 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<ModelRenderable, String> {
+ 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<GpuRenderable, String> {
+ 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<GpuRenderable, String> {
+ 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<GpuTexture, String> {
+ 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<glow::NativeProgram, String> {
+ 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<u8> {
+ let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<f32>()));
+ for &value in slice {
+ out.extend_from_slice(&value.to_ne_bytes());
+ }
+ out
+}
+
+fn u16_slice_to_ne_bytes(slice: &[u16]) -> Vec<u8> {
+ let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<u16>()));
+ 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<T> = core::result::Result<T, Error>;
+
+#[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<nres::error::Error> 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<TerrainFace>,
+}
+
+#[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<TerrainRenderVertex>,
+ pub indices: Vec<u16>,
+ 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<Path>) -> Result<TerrainMesh> {
+ 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<TerrainRenderMesh> {
+ 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::<Vec<_>>();
+
+ 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<PathBuf> {
+ 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<T> = core::result::Result<T, Error>;
+
+#[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<std::io::Error> for Error {
+ fn from(value: std::io::Error) -> Self {
+ Self::Io(value)
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct MissionFile {
+ pub footer: MissionFooter,
+ pub objects: Vec<MissionObject>,
+}
+
+#[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<Path>) -> Result<MissionFile> {
+ let bytes = fs::read(path.as_ref())?;
+ parse_bytes(&bytes)
+}
+
+pub fn parse_bytes(bytes: &[u8]) -> Result<MissionFile> {
+ let footer = parse_footer(bytes)?;
+ let objects = parse_objects(bytes);
+ Ok(MissionFile { footer, objects })
+}
+
+fn parse_footer(bytes: &[u8]) -> Result<MissionFooter> {
+ 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<u32> {
+ 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<usize> {
+ 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<MissionObject> {
+ 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<u32> {
+ 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<i32> {
+ read_u32(bytes, offset).map(|v| v as i32)
+}
+
+fn read_f32(bytes: &[u8], offset: usize) -> Option<f32> {
+ 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<PathBuf> {
+ 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<T> = core::result::Result<T, Error>;
+
+#[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<std::io::Error> 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<Path>) -> Result<UnitDat> {
+ let bytes = fs::read(path.as_ref())?;
+ parse_bytes(&bytes)
+}
+
+pub fn parse_bytes(bytes: &[u8]) -> Result<UnitDat> {
+ 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<u32> {
+ 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<PathBuf> {
+ 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`);
+ - искать `<stem>.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