Remove planning and acceptance scaffolding while retaining the native Vulkan mission preview, format readers, runtime algorithms, and ordinary Rust tests. Keep the book aligned with the runnable project and validate checked-in shaders without generated tool metadata.
1181 lines
38 KiB
Rust
1181 lines
38 KiB
Rust
#![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),
|
|
] {
|
|
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);
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|