diff options
| author | Valentin Popov <valentin@popov.link> | 2026-06-22 12:12:27 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-06-22 12:13:32 +0300 |
| commit | d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 (patch) | |
| tree | a0bd35c3940be62a5b5de1acc2366af377ffd181 /crates/tma | |
| parent | 7416fdc7e9a48837fff5056e6dc8d0774e90964b (diff) | |
| download | fparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.tar.xz fparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.zip | |
feat: implement FParkan architecture foundation
Add the modular fparkan workspace, domain crates, adapters, apps, xtask policy/CI, acceptance evidence, and licensed corpus gates for the macOS-focused roadmap foundation.
Diffstat (limited to 'crates/tma')
| -rw-r--r-- | crates/tma/Cargo.toml | 10 | ||||
| -rw-r--r-- | crates/tma/src/lib.rs | 485 |
2 files changed, 0 insertions, 495 deletions
diff --git a/crates/tma/Cargo.toml b/crates/tma/Cargo.toml deleted file mode 100644 index 99360c3..0000000 --- a/crates/tma/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[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 deleted file mode 100644 index 3b41bc4..0000000 --- a/crates/tma/src/lib.rs +++ /dev/null @@ -1,485 +0,0 @@ -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"); - } -} |
