Files
fparkan/crates/fparkan-mission-format/src/lib.rs
T

1181 lines
38 KiB
Rust
Raw Normal View History

#![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]
#[ignore = "requires licensed corpus"]
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),
] {
2026-06-22 17:29:33 +04:00
let root = corpus_root(corpus);
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);
}
}
2026-06-22 17:29:33 +04:00
fn corpus_root(name: &str) -> PathBuf {
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => panic!("unknown licensed corpus part: {name}"),
};
let root = std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
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
}
}