aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-mission-format
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-22 12:12:27 +0300
committerValentin Popov <valentin@popov.link>2026-06-22 12:13:32 +0300
commitd0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 (patch)
treea0bd35c3940be62a5b5de1acc2366af377ffd181 /crates/fparkan-mission-format
parent7416fdc7e9a48837fff5056e6dc8d0774e90964b (diff)
downloadfparkan-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/fparkan-mission-format')
-rw-r--r--crates/fparkan-mission-format/Cargo.toml13
-rw-r--r--crates/fparkan-mission-format/src/lib.rs1172
2 files changed, 1185 insertions, 0 deletions
diff --git a/crates/fparkan-mission-format/Cargo.toml b/crates/fparkan-mission-format/Cargo.toml
new file mode 100644
index 0000000..52103f8
--- /dev/null
+++ b/crates/fparkan-mission-format/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "fparkan-mission-format"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+encoding_rs = "0.8"
+fparkan-binary = { path = "../fparkan-binary" }
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-mission-format/src/lib.rs b/crates/fparkan-mission-format/src/lib.rs
new file mode 100644
index 0000000..edbe908
--- /dev/null
+++ b/crates/fparkan-mission-format/src/lib.rs
@@ -0,0 +1,1172 @@
+#![forbid(unsafe_code)]
+//! Count-driven mission format primitives.
+
+use encoding_rs::WINDOWS_1251;
+use fparkan_binary::{checked_count_bytes, read_lp_bytes, Cursor, DecodeError};
+use std::sync::Arc;
+
+const FORMAT_VERSION: u32 = 1;
+const CLAN_SECTION_VERSION: u32 = 6;
+const OBJECT_SECTION_VERSION: u32 = 10;
+const PROPERTY_SCHEMA_VERSION: u32 = 1;
+const EXTRA_SECTION_VERSION: u32 = 1;
+const OBJECT_CLASS_OR_FLAGS: u32 = 0x8000_0002;
+const MAX_PATHS: u32 = 16_384;
+const MAX_POINTS: u32 = 1_000_000;
+const MAX_CLANS: u32 = 16_384;
+const MAX_RELATIONS: u32 = 65_536;
+const MAX_SPATIAL_GROUPS: u32 = 65_536;
+const MAX_SPATIAL_RECORDS: u32 = 1_000_000;
+const MAX_OBJECTS: u32 = 1_000_000;
+const MAX_PROPERTIES: u32 = 1_000_000;
+const MAX_EXTRAS: u32 = 1_000_000;
+const MAX_STRING_BYTES: u32 = 64 * 1024;
+
+/// Mission document.
+#[derive(Clone, Debug, PartialEq)]
+pub struct MissionDocument {
+ /// Top-level format version.
+ pub format_version: u32,
+ /// Clan section version.
+ pub clan_section_version: u32,
+ /// Object section version.
+ pub object_section_version: u32,
+ /// Extra section version.
+ pub extra_section_version: u32,
+ /// Version words preserved for compact compatibility checks.
+ pub versions: Vec<u32>,
+ /// Paths.
+ pub paths: Vec<MissionPath>,
+ /// Clans.
+ pub clans: Vec<ClanRecord>,
+ /// Placed objects.
+ pub objects: Vec<PlacedObject>,
+ /// Landscape path.
+ pub land_path: LpString,
+ /// Mission flag.
+ pub mission_flag: u32,
+ /// Raw mission description.
+ pub description_raw: LpString,
+ /// Extras.
+ pub extras: Vec<ExtraRecord28>,
+ /// Original bytes.
+ pub raw: Arc<[u8]>,
+}
+
+/// Length-prefixed string with decoded CP1251 helper text.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct LpString {
+ /// Raw bytes from the file.
+ pub raw: Vec<u8>,
+ /// Decoded text.
+ pub decoded: String,
+}
+
+/// Mission path.
+#[derive(Clone, Debug, PartialEq)]
+pub struct MissionPath {
+ /// Path id.
+ pub id: i32,
+ /// Points.
+ pub points: Vec<[f32; 3]>,
+}
+
+/// Clan record.
+#[derive(Clone, Debug, PartialEq)]
+pub struct ClanRecord {
+ /// Clan name.
+ pub name: LpString,
+ /// Raw id, usually `-1` in checked corpora.
+ pub raw_id: i32,
+ /// Two-dimensional clan anchor.
+ pub anchor: [f32; 2],
+ /// Mode selector.
+ pub mode: u32,
+ /// Mode-dependent payload.
+ pub body: ClanBody,
+ /// Relation table.
+ pub relations: Vec<ClanRelation>,
+}
+
+/// Clan mode-dependent body.
+#[derive(Clone, Debug, PartialEq)]
+pub enum ClanBody {
+ /// Standard modes 1..=3.
+ Standard {
+ /// First tagged resource.
+ first_resource: TaggedResource,
+ /// Second tagged resource.
+ second_resource: TaggedResource,
+ },
+ /// Mode 0 spatial body.
+ Spatial {
+ /// First untagged resource.
+ first_resource: LpString,
+ /// Spatial groups.
+ spatial_groups: Vec<SpatialGroup>,
+ /// Second tagged resource.
+ second_resource: TaggedResource,
+ },
+}
+
+/// Tagged clan resource reference.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct TaggedResource {
+ /// Resource path.
+ pub path: LpString,
+ /// Raw tag.
+ pub tag: i32,
+}
+
+/// Mode 0 spatial group.
+#[derive(Clone, Debug, PartialEq)]
+pub struct SpatialGroup {
+ /// Raw spatial records, five floats each.
+ pub records: Vec<[f32; 5]>,
+}
+
+/// Clan relation entry.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ClanRelation {
+ /// Other clan name.
+ pub other_clan_name: LpString,
+ /// Raw relation value.
+ pub relation_value: i32,
+}
+
+/// Placed object.
+#[derive(Clone, Debug, PartialEq)]
+pub struct PlacedObject {
+ /// Raw object kind.
+ pub raw_kind: u32,
+ /// Class/flags word.
+ pub class_or_flags: u32,
+ /// Resource reference.
+ pub resource_name: LpString,
+ /// Raw resource bytes retained for older callers.
+ pub resource_raw: Vec<u8>,
+ /// Raw word after resource.
+ pub raw_after_resource: u32,
+ /// Raw identity/clan word.
+ pub identity_or_clan_raw: u32,
+ /// Position.
+ pub position: [f32; 3],
+ /// Orientation.
+ pub orientation: [f32; 3],
+ /// Scale.
+ pub scale: [f32; 3],
+ /// Instance name.
+ pub instance_name: LpString,
+ /// Raw word after instance name.
+ pub raw_after_name: u32,
+ /// First link word.
+ pub link0: i32,
+ /// Second link word.
+ pub link1: i32,
+ /// Property schema version.
+ pub property_schema_version: u32,
+ /// Ordered properties.
+ pub properties: Vec<OrderedProperty>,
+}
+
+/// Ordered property.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct OrderedProperty {
+ /// Raw words.
+ pub raw_value: [u32; 4],
+ /// Property name.
+ pub name: LpString,
+ /// Raw name bytes retained for older callers.
+ pub name_raw: Vec<u8>,
+}
+
+/// Mission epilogue marker.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct MissionEpilogue;
+
+/// 28-byte extra record.
+#[derive(Clone, Debug, PartialEq)]
+pub struct ExtraRecord28 {
+ /// Raw 28-byte record.
+ pub raw: [u8; 28],
+ /// Position.
+ pub position: [f32; 3],
+ /// Preserved trailing words.
+ pub raw_words: [u32; 4],
+}
+
+/// TMA profile.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum TmaProfile {
+ /// Strict profile.
+ Strict,
+}
+
+/// Mission error.
+#[derive(Debug)]
+pub enum MissionError {
+ /// Decode error.
+ Decode(DecodeError),
+ /// Unsupported branch.
+ Unsupported(&'static str),
+ /// Invalid section version.
+ InvalidVersion {
+ /// Section name.
+ section: &'static str,
+ /// Expected version.
+ expected: u32,
+ /// Observed version.
+ got: u32,
+ },
+ /// Unknown clan mode.
+ UnknownClanMode {
+ /// Clan index.
+ clan: usize,
+ /// Observed mode.
+ mode: u32,
+ },
+ /// Invalid placed object flags.
+ InvalidObjectFlags {
+ /// Object index.
+ object: usize,
+ /// Observed flags.
+ flags: u32,
+ },
+ /// Non-finite transform field.
+ NonFiniteTransform {
+ /// Object index.
+ object: usize,
+ },
+}
+
+impl From<DecodeError> for MissionError {
+ fn from(value: DecodeError) -> Self {
+ Self::Decode(value)
+ }
+}
+
+impl std::fmt::Display for MissionError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Decode(source) => write!(f, "{source}"),
+ Self::Unsupported(reason) => write!(f, "unsupported TMA branch: {reason}"),
+ Self::InvalidVersion {
+ section,
+ expected,
+ got,
+ } => write!(
+ f,
+ "invalid TMA {section} version {got}, expected {expected}"
+ ),
+ Self::UnknownClanMode { clan, mode } => {
+ write!(f, "unknown TMA clan mode {mode} at clan {clan}")
+ }
+ Self::InvalidObjectFlags { object, flags } => {
+ write!(f, "invalid TMA object {object} flags {flags:#x}")
+ }
+ Self::NonFiniteTransform { object } => {
+ write!(f, "TMA object {object} contains non-finite transform")
+ }
+ }
+ }
+}
+
+impl std::error::Error for MissionError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Decode(source) => Some(source),
+ Self::Unsupported(_)
+ | Self::InvalidVersion { .. }
+ | Self::UnknownClanMode { .. }
+ | Self::InvalidObjectFlags { .. }
+ | Self::NonFiniteTransform { .. } => None,
+ }
+ }
+}
+
+/// Decodes an exact, count-driven TMA document.
+///
+/// # Errors
+///
+/// Returns [`MissionError`] when a count/length is out of bounds, a known
+/// section version does not match strict expectations, a mode-dependent branch
+/// is unknown, object transforms are invalid, or the cursor does not end at EOF.
+pub fn decode_tma(bytes: Arc<[u8]>, profile: TmaProfile) -> Result<MissionDocument, MissionError> {
+ let mut cursor = Cursor::new(&bytes);
+ let format_version = cursor.read_u32_le()?;
+ require_version("format", format_version, FORMAT_VERSION, profile)?;
+
+ let paths = parse_paths(&mut cursor)?;
+
+ let clan_section_version = cursor.read_u32_le()?;
+ require_version(
+ "clan section",
+ clan_section_version,
+ CLAN_SECTION_VERSION,
+ profile,
+ )?;
+ let clans = parse_clans(&mut cursor)?;
+
+ let object_section_version = cursor.read_u32_le()?;
+ require_version(
+ "object section",
+ object_section_version,
+ OBJECT_SECTION_VERSION,
+ profile,
+ )?;
+ let objects = parse_objects(&mut cursor, profile)?;
+
+ let land_path = read_lp_string(&mut cursor)?;
+ let mission_flag = cursor.read_u32_le()?;
+ let description_raw = read_lp_string(&mut cursor)?;
+
+ let extra_section_version = cursor.read_u32_le()?;
+ require_version(
+ "extra section",
+ extra_section_version,
+ EXTRA_SECTION_VERSION,
+ profile,
+ )?;
+ let extras = parse_extras(&mut cursor)?;
+ cursor.require_eof()?;
+
+ Ok(MissionDocument {
+ format_version,
+ clan_section_version,
+ object_section_version,
+ extra_section_version,
+ versions: vec![
+ format_version,
+ clan_section_version,
+ object_section_version,
+ extra_section_version,
+ ],
+ paths,
+ clans,
+ objects,
+ land_path,
+ mission_flag,
+ description_raw,
+ extras,
+ raw: bytes,
+ })
+}
+
+/// Decodes only the TMA landscape path needed to load terrain before the full
+/// mission document is materialized.
+///
+/// # Errors
+///
+/// Returns [`MissionError`] when any section preceding the landscape path is
+/// malformed or unsupported.
+pub fn decode_tma_land_path(bytes: &[u8], profile: TmaProfile) -> Result<LpString, MissionError> {
+ let mut cursor = Cursor::new(bytes);
+ let format_version = cursor.read_u32_le()?;
+ require_version("format", format_version, FORMAT_VERSION, profile)?;
+ let _paths = parse_paths(&mut cursor)?;
+
+ let clan_section_version = cursor.read_u32_le()?;
+ require_version(
+ "clan section",
+ clan_section_version,
+ CLAN_SECTION_VERSION,
+ profile,
+ )?;
+ let _clans = parse_clans(&mut cursor)?;
+
+ let object_section_version = cursor.read_u32_le()?;
+ require_version(
+ "object section",
+ object_section_version,
+ OBJECT_SECTION_VERSION,
+ profile,
+ )?;
+ let _objects = parse_objects(&mut cursor, profile)?;
+ read_lp_string(&mut cursor)
+}
+
+fn require_version(
+ section: &'static str,
+ got: u32,
+ expected: u32,
+ _profile: TmaProfile,
+) -> Result<(), MissionError> {
+ if got == expected {
+ Ok(())
+ } else {
+ Err(MissionError::InvalidVersion {
+ section,
+ expected,
+ got,
+ })
+ }
+}
+
+fn parse_paths(cursor: &mut Cursor<'_>) -> Result<Vec<MissionPath>, MissionError> {
+ let count = checked_count(cursor.read_u32_le()?, MAX_PATHS)?;
+ let mut paths = Vec::with_capacity(count);
+ for _ in 0..count {
+ let id = cursor.read_i32_le()?;
+ let point_count = cursor.read_u32_le()?;
+ checked_count_bytes(u64::from(point_count), 12, cursor.remaining() as u64)?;
+ let point_count = checked_count(point_count, MAX_POINTS)?;
+ let mut points = Vec::with_capacity(point_count);
+ for _ in 0..point_count {
+ points.push(read_vec3(cursor)?);
+ }
+ paths.push(MissionPath { id, points });
+ }
+ Ok(paths)
+}
+
+fn parse_clans(cursor: &mut Cursor<'_>) -> Result<Vec<ClanRecord>, MissionError> {
+ let count = checked_count(cursor.read_u32_le()?, MAX_CLANS)?;
+ let mut clans = Vec::with_capacity(count);
+ for clan_index in 0..count {
+ let name = read_lp_string(cursor)?;
+ let raw_id = cursor.read_i32_le()?;
+ let anchor = [cursor.read_f32_le()?, cursor.read_f32_le()?];
+ let mode = cursor.read_u32_le()?;
+ let (body, relations) = match mode {
+ 0 => parse_spatial_clan(cursor)?,
+ 1..=3 => parse_standard_clan(cursor)?,
+ _ => {
+ return Err(MissionError::UnknownClanMode {
+ clan: clan_index,
+ mode,
+ })
+ }
+ };
+ clans.push(ClanRecord {
+ name,
+ raw_id,
+ anchor,
+ mode,
+ body,
+ relations,
+ });
+ }
+ Ok(clans)
+}
+
+fn parse_standard_clan(
+ cursor: &mut Cursor<'_>,
+) -> Result<(ClanBody, Vec<ClanRelation>), MissionError> {
+ let first_resource = parse_tagged_resource(cursor)?;
+ let second_resource = parse_tagged_resource(cursor)?;
+ let relations = parse_relations(cursor)?;
+ Ok((
+ ClanBody::Standard {
+ first_resource,
+ second_resource,
+ },
+ relations,
+ ))
+}
+
+fn parse_spatial_clan(
+ cursor: &mut Cursor<'_>,
+) -> Result<(ClanBody, Vec<ClanRelation>), MissionError> {
+ let first_resource = read_lp_string(cursor)?;
+ let group_count = checked_count(cursor.read_u32_le()?, MAX_SPATIAL_GROUPS)?;
+ let mut spatial_groups = Vec::with_capacity(group_count);
+ for _ in 0..group_count {
+ let record_count = cursor.read_u32_le()?;
+ checked_count_bytes(u64::from(record_count), 20, cursor.remaining() as u64)?;
+ let record_count = checked_count(record_count, MAX_SPATIAL_RECORDS)?;
+ let mut records = Vec::with_capacity(record_count);
+ for _ in 0..record_count {
+ records.push([
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ ]);
+ }
+ spatial_groups.push(SpatialGroup { records });
+ }
+ let second_resource = parse_tagged_resource(cursor)?;
+ let relations = parse_relations(cursor)?;
+ Ok((
+ ClanBody::Spatial {
+ first_resource,
+ spatial_groups,
+ second_resource,
+ },
+ relations,
+ ))
+}
+
+fn parse_tagged_resource(cursor: &mut Cursor<'_>) -> Result<TaggedResource, MissionError> {
+ Ok(TaggedResource {
+ path: read_lp_string(cursor)?,
+ tag: cursor.read_i32_le()?,
+ })
+}
+
+fn parse_relations(cursor: &mut Cursor<'_>) -> Result<Vec<ClanRelation>, MissionError> {
+ let count = checked_count(cursor.read_u32_le()?, MAX_RELATIONS)?;
+ let mut relations = Vec::with_capacity(count);
+ for _ in 0..count {
+ relations.push(ClanRelation {
+ other_clan_name: read_lp_string(cursor)?,
+ relation_value: cursor.read_i32_le()?,
+ });
+ }
+ Ok(relations)
+}
+
+fn parse_objects(
+ cursor: &mut Cursor<'_>,
+ profile: TmaProfile,
+) -> Result<Vec<PlacedObject>, MissionError> {
+ let count = checked_count(cursor.read_u32_le()?, MAX_OBJECTS)?;
+ let mut objects = Vec::with_capacity(count);
+ for object_index in 0..count {
+ let raw_kind = cursor.read_u32_le()?;
+ let class_or_flags = cursor.read_u32_le()?;
+ if profile == TmaProfile::Strict && class_or_flags != OBJECT_CLASS_OR_FLAGS {
+ return Err(MissionError::InvalidObjectFlags {
+ object: object_index,
+ flags: class_or_flags,
+ });
+ }
+ let resource_name = read_lp_string(cursor)?;
+ let resource_raw = resource_name.raw.clone();
+ let raw_after_resource = cursor.read_u32_le()?;
+ let identity_or_clan_raw = cursor.read_u32_le()?;
+ let position = read_vec3(cursor)?;
+ let orientation = read_vec3(cursor)?;
+ let scale = read_vec3(cursor)?;
+ if !all_finite(&position) || !all_finite(&orientation) || !all_finite(&scale) {
+ return Err(MissionError::NonFiniteTransform {
+ object: object_index,
+ });
+ }
+ let instance_name = read_lp_string(cursor)?;
+ let raw_after_name = cursor.read_u32_le()?;
+ let link0 = cursor.read_i32_le()?;
+ let link1 = cursor.read_i32_le()?;
+ let property_schema_version = cursor.read_u32_le()?;
+ require_version(
+ "property schema",
+ property_schema_version,
+ PROPERTY_SCHEMA_VERSION,
+ profile,
+ )?;
+ let properties = parse_properties(cursor)?;
+ objects.push(PlacedObject {
+ raw_kind,
+ class_or_flags,
+ resource_name,
+ resource_raw,
+ raw_after_resource,
+ identity_or_clan_raw,
+ position,
+ orientation,
+ scale,
+ instance_name,
+ raw_after_name,
+ link0,
+ link1,
+ property_schema_version,
+ properties,
+ });
+ }
+ Ok(objects)
+}
+
+fn parse_properties(cursor: &mut Cursor<'_>) -> Result<Vec<OrderedProperty>, MissionError> {
+ let count = checked_count(cursor.read_u32_le()?, MAX_PROPERTIES)?;
+ let mut properties = Vec::with_capacity(count);
+ for _ in 0..count {
+ let raw_value = [
+ cursor.read_u32_le()?,
+ cursor.read_u32_le()?,
+ cursor.read_u32_le()?,
+ cursor.read_u32_le()?,
+ ];
+ let name = read_lp_string(cursor)?;
+ let name_raw = name.raw.clone();
+ properties.push(OrderedProperty {
+ raw_value,
+ name,
+ name_raw,
+ });
+ }
+ Ok(properties)
+}
+
+fn parse_extras(cursor: &mut Cursor<'_>) -> Result<Vec<ExtraRecord28>, MissionError> {
+ let count = checked_count(cursor.read_u32_le()?, MAX_EXTRAS)?;
+ checked_count_bytes(count as u64, 28, cursor.remaining() as u64)?;
+ let mut extras = Vec::with_capacity(count);
+ for _ in 0..count {
+ let chunk = cursor.read_exact(28)?;
+ let mut raw = [0; 28];
+ raw.copy_from_slice(chunk);
+ extras.push(ExtraRecord28 {
+ raw,
+ position: [
+ read_f32_from(chunk, 0)?,
+ read_f32_from(chunk, 4)?,
+ read_f32_from(chunk, 8)?,
+ ],
+ raw_words: [
+ read_u32_from(chunk, 12)?,
+ read_u32_from(chunk, 16)?,
+ read_u32_from(chunk, 20)?,
+ read_u32_from(chunk, 24)?,
+ ],
+ });
+ }
+ Ok(extras)
+}
+
+fn read_lp_string(cursor: &mut Cursor<'_>) -> Result<LpString, MissionError> {
+ let raw = read_lp_bytes(cursor, MAX_STRING_BYTES)?;
+ let (decoded, _, _) = WINDOWS_1251.decode(&raw);
+ let decoded = decoded.into_owned();
+ Ok(LpString { raw, decoded })
+}
+
+fn read_vec3(cursor: &mut Cursor<'_>) -> Result<[f32; 3], MissionError> {
+ Ok([
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ ])
+}
+
+fn all_finite(value: &[f32; 3]) -> bool {
+ value.iter().all(|component| component.is_finite())
+}
+
+fn checked_count(count: u32, limit: u32) -> Result<usize, MissionError> {
+ if count > limit {
+ return Err(DecodeError::LimitExceeded {
+ count: u64::from(count),
+ limit: u64::from(limit),
+ }
+ .into());
+ }
+ usize::try_from(count).map_err(|_| DecodeError::IntegerOverflow.into())
+}
+
+fn read_u32_from(bytes: &[u8], offset: usize) -> Result<u32, MissionError> {
+ let raw = bytes
+ .get(offset..offset + 4)
+ .ok_or(DecodeError::IntegerOverflow)?;
+ Ok(u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]))
+}
+
+fn read_f32_from(bytes: &[u8], offset: usize) -> Result<f32, MissionError> {
+ Ok(f32::from_bits(read_u32_from(bytes, offset)?))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::path::{Path, PathBuf};
+
+ #[test]
+ fn minimal_synthetic_exact_eof() {
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, FORMAT_VERSION);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, CLAN_SECTION_VERSION);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, OBJECT_SECTION_VERSION);
+ push_u32(&mut bytes, 0);
+ push_lp(&mut bytes, b"DATA\\MAPS\\Tut_1\\land");
+ push_u32(&mut bytes, 0);
+ push_lp(&mut bytes, b"");
+ push_u32(&mut bytes, EXTRA_SECTION_VERSION);
+ push_u32(&mut bytes, 0);
+
+ let doc = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma");
+ assert_eq!(
+ doc.versions,
+ vec![
+ FORMAT_VERSION,
+ CLAN_SECTION_VERSION,
+ OBJECT_SECTION_VERSION,
+ EXTRA_SECTION_VERSION
+ ]
+ );
+ assert_eq!(doc.land_path.decoded, "DATA\\MAPS\\Tut_1\\land");
+ }
+
+ #[test]
+ fn land_path_prefix_decode_matches_full_document() {
+ let bytes = minimal_tma_bytes();
+ let prefix = decode_tma_land_path(&bytes, TmaProfile::Strict).expect("land path prefix");
+ let doc = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma");
+
+ assert_eq!(prefix, doc.land_path);
+ }
+
+ #[test]
+ fn lp_string_does_not_consume_implicit_nul() {
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, FORMAT_VERSION);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, CLAN_SECTION_VERSION);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, OBJECT_SECTION_VERSION);
+ push_u32(&mut bytes, 0);
+ push_lp(&mut bytes, b"A\0B");
+ push_u32(&mut bytes, 0x55aa);
+ push_lp(&mut bytes, b"");
+ push_u32(&mut bytes, EXTRA_SECTION_VERSION);
+ push_u32(&mut bytes, 0);
+
+ let doc = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma");
+ assert_eq!(doc.land_path.raw, b"A\0B");
+ assert_eq!(doc.mission_flag, 0x55aa);
+ }
+
+ #[test]
+ fn synthetic_standard_clan_and_object_preserve_ordered_properties() {
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, FORMAT_VERSION);
+ push_u32(&mut bytes, 1);
+ push_i32(&mut bytes, 42);
+ push_u32(&mut bytes, 1);
+ push_f32(&mut bytes, 1.0);
+ push_f32(&mut bytes, 2.0);
+ push_f32(&mut bytes, 3.0);
+ push_u32(&mut bytes, CLAN_SECTION_VERSION);
+ push_u32(&mut bytes, 1);
+ push_lp(&mut bytes, b"Alpha");
+ push_i32(&mut bytes, -1);
+ push_f32(&mut bytes, 10.0);
+ push_f32(&mut bytes, 20.0);
+ push_u32(&mut bytes, 1);
+ push_lp(&mut bytes, b"Scripts\\a");
+ push_i32(&mut bytes, 7);
+ push_lp(&mut bytes, b"");
+ push_i32(&mut bytes, 8);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, OBJECT_SECTION_VERSION);
+ push_u32(&mut bytes, 1);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, OBJECT_CLASS_OR_FLAGS);
+ push_lp(&mut bytes, b"s_tree_04");
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, 0);
+ for value in [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0] {
+ push_f32(&mut bytes, value);
+ }
+ push_lp(&mut bytes, b"tree_01");
+ push_u32(&mut bytes, 0);
+ push_i32(&mut bytes, -1);
+ push_i32(&mut bytes, -1);
+ push_u32(&mut bytes, PROPERTY_SCHEMA_VERSION);
+ push_u32(&mut bytes, 2);
+ for name in [b"Life state".as_slice(), b"Life state".as_slice()] {
+ push_u32(&mut bytes, 1);
+ push_u32(&mut bytes, 2);
+ push_u32(&mut bytes, 3);
+ push_u32(&mut bytes, 4);
+ push_lp(&mut bytes, name);
+ }
+ push_lp(&mut bytes, b"DATA\\MAPS\\Tut_1\\land");
+ push_u32(&mut bytes, 0);
+ push_lp(&mut bytes, b"");
+ push_u32(&mut bytes, EXTRA_SECTION_VERSION);
+ push_u32(&mut bytes, 0);
+
+ let doc = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma");
+ assert_eq!(doc.paths[0].id, 42);
+ assert_eq!(doc.clans[0].name.decoded, "Alpha");
+ assert_eq!(doc.objects[0].resource_name.decoded, "s_tree_04");
+ assert_eq!(doc.objects[0].properties.len(), 2);
+ assert_eq!(doc.objects[0].properties[0].raw_value, [1, 2, 3, 4]);
+ assert_eq!(doc.objects[0].properties[0].name.decoded, "Life state");
+ }
+
+ #[test]
+ fn path_ids_retain_nonsequential_order_and_truncated_points_fail() {
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, FORMAT_VERSION);
+ push_u32(&mut bytes, 3);
+ for id in [30, -5, 10] {
+ push_i32(&mut bytes, id);
+ push_u32(&mut bytes, 0);
+ }
+ push_empty_tail(&mut bytes);
+
+ let doc = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma");
+ assert_eq!(
+ doc.paths.iter().map(|path| path.id).collect::<Vec<_>>(),
+ vec![30, -5, 10]
+ );
+
+ let mut truncated = Vec::new();
+ push_u32(&mut truncated, FORMAT_VERSION);
+ push_u32(&mut truncated, 1);
+ push_i32(&mut truncated, 1);
+ push_u32(&mut truncated, 1);
+ assert!(decode_tma(Arc::from(truncated.into_boxed_slice()), TmaProfile::Strict).is_err());
+ }
+
+ #[test]
+ fn clan_modes_one_to_three_and_spatial_mode_zero_decode() {
+ for mode in 1..=3 {
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, FORMAT_VERSION);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, CLAN_SECTION_VERSION);
+ push_u32(&mut bytes, 1);
+ push_standard_clan(&mut bytes, mode);
+ push_object_section_and_tail(&mut bytes, 0, b"", &[]);
+
+ let doc =
+ decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma");
+ assert_eq!(doc.clans[0].mode, mode);
+ assert!(matches!(doc.clans[0].body, ClanBody::Standard { .. }));
+ }
+
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, FORMAT_VERSION);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, CLAN_SECTION_VERSION);
+ push_u32(&mut bytes, 1);
+ push_lp(&mut bytes, b"Spatial");
+ push_i32(&mut bytes, -1);
+ push_f32(&mut bytes, 0.0);
+ push_f32(&mut bytes, 0.0);
+ push_u32(&mut bytes, 0);
+ push_lp(&mut bytes, b"first");
+ push_u32(&mut bytes, 1);
+ push_u32(&mut bytes, 1);
+ for value in [1.0, 2.0, 3.0, 4.0, 5.0] {
+ push_f32(&mut bytes, value);
+ }
+ push_lp(&mut bytes, b"second");
+ push_i32(&mut bytes, 9);
+ push_u32(&mut bytes, 0);
+ push_object_section_and_tail(&mut bytes, 0, b"", &[]);
+
+ let doc = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma");
+ let ClanBody::Spatial { spatial_groups, .. } = &doc.clans[0].body else {
+ panic!("spatial body");
+ };
+ assert_eq!(spatial_groups[0].records[0], [1.0, 2.0, 3.0, 4.0, 5.0]);
+ }
+
+ #[test]
+ fn unknown_clan_mode_nonfinite_transform_and_trailing_bytes_are_rejected() {
+ let mut unknown_mode = Vec::new();
+ push_u32(&mut unknown_mode, FORMAT_VERSION);
+ push_u32(&mut unknown_mode, 0);
+ push_u32(&mut unknown_mode, CLAN_SECTION_VERSION);
+ push_u32(&mut unknown_mode, 1);
+ push_lp(&mut unknown_mode, b"Bad");
+ push_i32(&mut unknown_mode, -1);
+ push_f32(&mut unknown_mode, 0.0);
+ push_f32(&mut unknown_mode, 0.0);
+ push_u32(&mut unknown_mode, 99);
+ let err = decode_tma(
+ Arc::from(unknown_mode.into_boxed_slice()),
+ TmaProfile::Strict,
+ )
+ .expect_err("mode");
+ assert!(matches!(
+ err,
+ MissionError::UnknownClanMode { mode: 99, .. }
+ ));
+
+ let mut nonfinite = Vec::new();
+ push_u32(&mut nonfinite, FORMAT_VERSION);
+ push_u32(&mut nonfinite, 0);
+ push_u32(&mut nonfinite, CLAN_SECTION_VERSION);
+ push_u32(&mut nonfinite, 0);
+ push_u32(&mut nonfinite, OBJECT_SECTION_VERSION);
+ push_u32(&mut nonfinite, 1);
+ push_object(&mut nonfinite, f32::NAN, &[]);
+ push_epilogue(&mut nonfinite, b"DATA\\MAPS\\Tut_1\\land", b"", &[]);
+ let err = decode_tma(Arc::from(nonfinite.into_boxed_slice()), TmaProfile::Strict)
+ .expect_err("nan");
+ assert!(matches!(
+ err,
+ MissionError::NonFiniteTransform { object: 0 }
+ ));
+
+ let mut trailing = minimal_tma_bytes();
+ trailing.push(0);
+ assert!(decode_tma(Arc::from(trailing.into_boxed_slice()), TmaProfile::Strict).is_err());
+ }
+
+ #[test]
+ fn description_and_extras_are_exact_raw_records() {
+ let mut extra = Vec::new();
+ for value in 0_u8..28 {
+ extra.push(value);
+ }
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, FORMAT_VERSION);
+ push_u32(&mut bytes, 0);
+ push_empty_tail_with_description(&mut bytes, b"A\x00B", &[extra.as_slice()]);
+
+ let doc = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict).expect("tma");
+ assert_eq!(doc.description_raw.raw, b"A\x00B");
+ assert_eq!(doc.extras.len(), 1);
+ assert_eq!(doc.extras[0].raw[27], 27);
+
+ let mut truncated_extra = Vec::new();
+ push_u32(&mut truncated_extra, FORMAT_VERSION);
+ push_u32(&mut truncated_extra, 0);
+ push_empty_tail_with_description(&mut truncated_extra, b"", &[&extra[..27]]);
+ assert!(decode_tma(
+ Arc::from(truncated_extra.into_boxed_slice()),
+ TmaProfile::Strict
+ )
+ .is_err());
+ }
+
+ #[test]
+ fn signatures_inside_strings_do_not_create_records_and_truncations_are_bounded() {
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, FORMAT_VERSION);
+ push_u32(&mut bytes, 0);
+ push_empty_tail_with_description(&mut bytes, &[1, 0, 0, 0, 6, 0, 0, 0], &[]);
+
+ let doc = decode_tma(
+ Arc::from(bytes.clone().into_boxed_slice()),
+ TmaProfile::Strict,
+ )
+ .expect("tma");
+ assert!(doc.paths.is_empty());
+ assert_eq!(doc.description_raw.raw, [1, 0, 0, 0, 6, 0, 0, 0]);
+
+ for len in 0..bytes.len() {
+ let _ = decode_tma(
+ Arc::from(bytes[..len].to_vec().into_boxed_slice()),
+ TmaProfile::Strict,
+ );
+ }
+ }
+
+ #[test]
+ fn generated_valid_documents_and_arbitrary_inputs_are_bounded() {
+ for seed in 0_u32..64 {
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, FORMAT_VERSION);
+ push_u32(&mut bytes, 1);
+ push_i32(&mut bytes, i32::try_from(seed).expect("seed"));
+ push_u32(&mut bytes, 1);
+ push_f32(&mut bytes, seed as f32);
+ push_f32(&mut bytes, 1.0);
+ push_f32(&mut bytes, 2.0);
+ push_empty_tail_with_description(&mut bytes, &[seed as u8, 0, 1], &[]);
+
+ let doc = decode_tma(
+ Arc::from(bytes.clone().into_boxed_slice()),
+ TmaProfile::Strict,
+ )
+ .expect("generated");
+ assert_eq!(doc.raw.as_ref(), bytes.as_slice());
+ assert_eq!(doc.paths[0].id, i32::try_from(seed).expect("seed"));
+
+ let arbitrary = (0..seed % 31)
+ .map(|offset| seed.wrapping_mul(17).wrapping_add(offset) as u8)
+ .collect::<Vec<_>>();
+ let _ = decode_tma(Arc::from(arbitrary.into_boxed_slice()), TmaProfile::Strict);
+ }
+ }
+
+ #[test]
+ fn licensed_corpus_tma_validate() {
+ for (
+ corpus,
+ expected_files,
+ expected_paths,
+ expected_clans,
+ expected_objects,
+ expected_extras,
+ ) in [
+ ("IS", 29_usize, 34_usize, 101_usize, 864_usize, 28_usize),
+ ("IS2", 31_usize, 61_usize, 91_usize, 885_usize, 41_usize),
+ ] {
+ let Some(root) = corpus_root(corpus) else {
+ continue;
+ };
+ let mut files = 0usize;
+ let mut paths = 0usize;
+ let mut clans = 0usize;
+ let mut objects = 0usize;
+ let mut extras = 0usize;
+ for path in files_under(&root) {
+ if !path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.eq_ignore_ascii_case("data.tma"))
+ {
+ continue;
+ }
+ let bytes = std::fs::read(&path).expect("read data.tma");
+ let document = decode_tma(Arc::from(bytes.into_boxed_slice()), TmaProfile::Strict)
+ .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
+ files += 1;
+ paths += document.paths.len();
+ clans += document.clans.len();
+ objects += document.objects.len();
+ extras += document.extras.len();
+ assert_eq!(document.format_version, FORMAT_VERSION, "{corpus} {path:?}");
+ assert_eq!(
+ document.clan_section_version, CLAN_SECTION_VERSION,
+ "{corpus} {path:?}"
+ );
+ assert_eq!(
+ document.object_section_version, OBJECT_SECTION_VERSION,
+ "{corpus} {path:?}"
+ );
+ assert_eq!(
+ document.extra_section_version, EXTRA_SECTION_VERSION,
+ "{corpus} {path:?}"
+ );
+ assert!(
+ document
+ .land_path
+ .decoded
+ .to_ascii_uppercase()
+ .contains("DATA\\MAPS\\"),
+ "{corpus} {path:?} land path"
+ );
+ }
+
+ assert_eq!(files, expected_files, "{corpus} TMA count");
+ assert_eq!(paths, expected_paths, "{corpus} path count");
+ assert_eq!(clans, expected_clans, "{corpus} clan count");
+ assert_eq!(objects, expected_objects, "{corpus} object count");
+ assert_eq!(extras, expected_extras, "{corpus} extra count");
+ }
+ }
+
+ fn push_lp(out: &mut Vec<u8>, bytes: &[u8]) {
+ push_u32(out, u32::try_from(bytes.len()).expect("lp len"));
+ out.extend_from_slice(bytes);
+ }
+
+ fn push_u32(out: &mut Vec<u8>, value: u32) {
+ out.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn push_i32(out: &mut Vec<u8>, value: i32) {
+ out.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn push_f32(out: &mut Vec<u8>, value: f32) {
+ out.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn minimal_tma_bytes() -> Vec<u8> {
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, FORMAT_VERSION);
+ push_u32(&mut bytes, 0);
+ push_empty_tail(&mut bytes);
+ bytes
+ }
+
+ fn push_empty_tail(out: &mut Vec<u8>) {
+ push_empty_tail_with_description(out, b"", &[]);
+ }
+
+ fn push_empty_tail_with_description(out: &mut Vec<u8>, description: &[u8], extras: &[&[u8]]) {
+ push_u32(out, CLAN_SECTION_VERSION);
+ push_u32(out, 0);
+ push_object_section_and_tail(out, 0, description, extras);
+ }
+
+ fn push_object_section_and_tail(
+ out: &mut Vec<u8>,
+ object_count: u32,
+ description: &[u8],
+ extras: &[&[u8]],
+ ) {
+ push_u32(out, OBJECT_SECTION_VERSION);
+ push_u32(out, object_count);
+ push_epilogue(out, b"DATA\\MAPS\\Tut_1\\land", description, extras);
+ }
+
+ fn push_epilogue(out: &mut Vec<u8>, land_path: &[u8], description: &[u8], extras: &[&[u8]]) {
+ push_lp(out, land_path);
+ push_u32(out, 0);
+ push_lp(out, description);
+ push_u32(out, EXTRA_SECTION_VERSION);
+ push_u32(out, u32::try_from(extras.len()).expect("extra count"));
+ for extra in extras {
+ out.extend_from_slice(extra);
+ }
+ }
+
+ fn push_standard_clan(out: &mut Vec<u8>, mode: u32) {
+ push_lp(out, b"Clan");
+ push_i32(out, -1);
+ push_f32(out, 0.0);
+ push_f32(out, 0.0);
+ push_u32(out, mode);
+ push_lp(out, b"first");
+ push_i32(out, 1);
+ push_lp(out, b"second");
+ push_i32(out, 2);
+ push_u32(out, 0);
+ }
+
+ fn push_object(out: &mut Vec<u8>, first_position: f32, properties: &[(&[u8], [u32; 4])]) {
+ push_u32(out, 0);
+ push_u32(out, OBJECT_CLASS_OR_FLAGS);
+ push_lp(out, b"s_tree_04");
+ push_u32(out, 0);
+ push_u32(out, 0);
+ for value in [first_position, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0] {
+ push_f32(out, value);
+ }
+ push_lp(out, b"tree_01");
+ push_u32(out, 0);
+ push_i32(out, -1);
+ push_i32(out, -1);
+ push_u32(out, PROPERTY_SCHEMA_VERSION);
+ push_u32(
+ out,
+ u32::try_from(properties.len()).expect("property count"),
+ );
+ for (name, raw) in properties {
+ for value in raw {
+ push_u32(out, *value);
+ }
+ push_lp(out, name);
+ }
+ }
+
+ fn corpus_root(name: &str) -> Option<PathBuf> {
+ let root = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("testdata")
+ .join(name);
+ root.is_dir().then_some(root)
+ }
+
+ fn files_under(root: &Path) -> Vec<PathBuf> {
+ let mut out = Vec::new();
+ let mut stack = vec![root.to_path_buf()];
+ while let Some(path) = stack.pop() {
+ let Ok(read_dir) = std::fs::read_dir(path) else {
+ continue;
+ };
+ for entry in read_dir.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ stack.push(path);
+ } else {
+ out.push(path);
+ }
+ }
+ }
+ out.sort();
+ out
+ }
+}