aboutsummaryrefslogtreecommitdiff
path: root/crates/tma/src
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-02-19 15:07:01 +0300
committerValentin Popov <valentin@popov.link>2026-02-19 15:07:01 +0300
commit4ef08d0bf6366b0bc8ccb6357b794937411f74cc (patch)
treed733ac655029b10e466c450be25c63e156f40773 /crates/tma/src
parent598137ed132d95a3e3bf9b95e9e27286cc2186ac (diff)
downloadfparkan-4ef08d0bf6366b0bc8ccb6357b794937411f74cc.tar.xz
fparkan-4ef08d0bf6366b0bc8ccb6357b794937411f74cc.zip
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.
Diffstat (limited to 'crates/tma/src')
-rw-r--r--crates/tma/src/lib.rs485
1 files changed, 485 insertions, 0 deletions
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");
+ }
+}