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

1990 lines
64 KiB
Rust

#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Terrain disk format primitives.
use fparkan_binary::{checked_count_bytes, Cursor, DecodeError};
use fparkan_nres::{EntryId, EntryMeta, NresDocument, NresError};
const TYPE_AREAL_MAP: u32 = 12;
const TYPE_NODES: u32 = 1;
const TYPE_SLOTS: u32 = 2;
const TYPE_POSITIONS: u32 = 3;
const TYPE_NORMALS: u32 = 4;
const TYPE_UV0: u32 = 5;
const TYPE_ACCELERATOR: u32 = 11;
const TYPE_AUX14: u32 = 14;
const TYPE_AUX18: u32 = 18;
const TYPE_FACES: u32 = 21;
const REQUIRED_TYPES: [u32; 9] = [
TYPE_NODES,
TYPE_SLOTS,
TYPE_POSITIONS,
TYPE_NORMALS,
TYPE_UV0,
TYPE_AUX18,
TYPE_AUX14,
TYPE_ACCELERATOR,
TYPE_FACES,
];
const AREAL_PREFIX_SIZE: usize = 56;
const SLOT_HEADER_SIZE: usize = 0x8c;
const SLOT_STRIDE: usize = 68;
const GRID_HIT_COUNT_BITS: u32 = 10;
const GRID_POOL_OFFSET_MASK: u32 = (1 << 22) - 1;
/// Full surface mask.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct FullSurfaceMask(pub u32);
/// Compact surface mask.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CompactSurfaceMask(pub u16);
/// Material class mask.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MaterialClassMask(pub u8);
/// The two positional material-table selectors packed into a terrain face tag.
///
/// The high byte selects from map-local `Land1.wea`; `0xff` is the observed
/// no-selection sentinel. The low byte selects from `Land2.wea`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerrainMaterialLayers {
/// Optional high-byte selector for `Land1.wea`.
pub land1_selector: Option<u8>,
/// Low-byte selector for `Land2.wea`.
pub land2_selector: u8,
}
/// Terrain face with 28-byte source layout.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TerrainFace28 {
/// Full 32-bit surface mask/flags from bytes 0..4.
pub flags: FullSurfaceMask,
/// Opaque tag at bytes 4..6.
pub material_tag: u16,
/// Opaque tag at bytes 6..8.
pub aux_tag: u16,
/// Vertex indices at bytes 8..14.
pub vertices: [u16; 3],
/// Neighbor face indices at bytes 14..20.
pub neighbors: [Option<u16>; 3],
/// Preserved bytes 20..28.
pub tail_raw: [u8; 8],
/// Preserved raw bytes.
pub raw: [u8; 28],
}
impl TerrainFace28 {
/// Decodes the proven two-table selector layout of [`Self::material_tag`].
#[must_use]
pub const fn material_layers(&self) -> TerrainMaterialLayers {
let [land2_selector, land1] = self.material_tag.to_le_bytes();
TerrainMaterialLayers {
land1_selector: if land1 == u8::MAX { None } else { Some(land1) },
land2_selector,
}
}
}
/// Terrain stream descriptor.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TerrainStream {
/// Stream type id.
pub type_id: u32,
/// Entry attributes.
pub attributes: TerrainStreamAttributes,
/// Payload size.
pub size: u32,
}
/// Opaque stream attributes.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct TerrainStreamAttributes {
/// Attribute 1.
pub attr1: u32,
/// Attribute 2.
pub attr2: u32,
/// Attribute 3.
pub attr3: u32,
}
/// Slot table metadata.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TerrainSlotTable {
/// Raw 0x8c-byte header.
pub header_raw: Vec<u8>,
/// Slot records.
pub slots_raw: Vec<[u8; SLOT_STRIDE]>,
}
/// Land mesh document.
#[derive(Clone, Debug, PartialEq)]
pub struct LandMeshDocument {
/// Stream descriptors in archive order.
pub streams: Vec<TerrainStream>,
/// Raw node/slot mapping bytes.
pub nodes_raw: Vec<u8>,
/// Slot table.
pub slots: TerrainSlotTable,
/// Positions from type 3.
pub positions: Vec<[f32; 3]>,
/// Packed normals from type 4.
pub normals: Vec<[i8; 4]>,
/// Packed UV from type 5.
pub uv0: Vec<[i16; 2]>,
/// Type 11 accelerator words.
pub accelerator: Vec<[u8; 4]>,
/// Type 14 auxiliary words.
pub aux14: Vec<[u8; 4]>,
/// Type 18 microtexture mapping words.
///
/// `Terrain.dll!CLandscape` requires this stream and retains it as a
/// four-byte-per-entry mapping chunk.
pub aux18: Vec<[u8; 4]>,
/// Faces.
pub faces: Vec<TerrainFace28>,
}
/// Decoded `Land.map` document.
#[derive(Clone, Debug, PartialEq)]
pub struct LandMapDocument {
/// Type 12 entry attributes.
pub entry: TerrainStream,
/// Areal count declared by entry attribute 1.
pub areal_count: u32,
/// Decoded areals.
pub areals: Vec<Areal>,
/// Fast lookup grid.
pub grid: ArealGrid,
}
/// Logical terrain area.
#[derive(Clone, Debug, PartialEq)]
pub struct Areal {
/// Preserved 56-byte prefix.
pub prefix_raw: [u8; AREAL_PREFIX_SIZE],
/// Anchor position.
pub anchor: [f32; 3],
/// Preserved float at prefix offset 12.
pub reserved_12: f32,
/// Area metric from the source file.
pub area_metric: f32,
/// Area normal.
pub normal: [f32; 3],
/// Logic flag.
pub logic_flag: u32,
/// Preserved integer at prefix offset 36.
pub reserved_36: u32,
/// Area class identifier.
pub class_id: u32,
/// Preserved integer at prefix offset 44.
pub reserved_44: u32,
/// Boundary vertices.
pub vertices: Vec<[f32; 3]>,
/// Edge and polygon links.
pub links: Vec<EdgeLink>,
/// Polygon payload blocks.
pub polygon_blocks: Vec<ArealPolygonBlock>,
}
/// Neighbor link for an areal edge or polygon slot.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EdgeLink {
/// Raw signed area reference.
pub raw_area_ref: i32,
/// Raw signed edge reference.
pub raw_edge_ref: i32,
/// Referenced area, or `None` for `(-1, -1)`.
pub area_ref: Option<u32>,
/// Referenced edge/link slot in the target area, or `None` for `(-1, -1)`.
pub edge_ref: Option<u32>,
}
/// Preserved polygon block.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ArealPolygonBlock {
/// Leading `n` value.
pub n: u32,
/// Raw block following `n`.
pub body_raw: Vec<u8>,
}
/// Fast area lookup grid.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ArealGrid {
/// Number of cells on X axis.
pub cells_x: u32,
/// Number of cells on Y axis.
pub cells_y: u32,
/// Per-cell decoded candidates.
pub cells: Vec<ArealGridCell>,
/// Concatenated candidate pool used by compact lookup.
pub candidate_pool: Vec<u32>,
/// Per-cell compact descriptor: high 10 bits are hit count, low 22 bits are pool offset.
pub compact_cells: Vec<u32>,
}
/// Candidate list for one areal grid cell.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ArealGridCell {
/// Area identifiers referenced by this cell.
pub area_ids: Vec<u32>,
}
/// Build category from `BuildDat.lst`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BuildCategory {
/// Category name from the section header.
pub name: String,
/// Known category mask.
pub mask: u32,
/// Unit DAT paths listed in the section.
pub unit_paths: Vec<String>,
}
/// Terrain format error.
#[derive(Debug)]
pub enum TerrainFormatError {
/// Binary decode error.
Decode(DecodeError),
/// Nested `NRes` error.
Nres(NresError),
/// Invalid `Land.map` archive entry count.
InvalidLandMapEntryCount {
/// Observed entry count.
entry_count: usize,
},
/// Invalid `Land.map` entry type.
InvalidLandMapEntryType {
/// Observed type id.
type_id: u32,
},
/// Missing required stream.
MissingStream {
/// Stream type id.
type_id: u32,
},
/// Duplicate required stream.
DuplicateStream {
/// Stream type id.
type_id: u32,
},
/// Invalid stream stride.
InvalidStride {
/// Stream type id.
type_id: u32,
/// Observed stride.
stride: u32,
/// Expected stride.
expected: u32,
},
/// Invalid stream size.
InvalidSize {
/// Stream type id.
type_id: u32,
/// Observed size.
size: usize,
/// Expected stride or framing.
stride: usize,
},
/// Stream count does not match payload size.
CountMismatch {
/// Stream type id.
type_id: u32,
/// Attribute count.
attr_count: u32,
/// Payload-derived count.
payload_count: usize,
},
/// Invalid vertex.
InvalidVertexIndex {
/// Face index.
face: usize,
/// Vertex index.
vertex: u16,
/// Position count.
position_count: usize,
},
/// Invalid neighbor.
InvalidNeighborIndex {
/// Face index.
face: usize,
/// Neighbor index.
neighbor: u16,
/// Face count.
face_count: usize,
},
/// Invalid areal link.
InvalidArealLink {
/// Source area index.
area: usize,
/// Source link index.
link: usize,
/// Raw area reference.
area_ref: i32,
/// Raw edge reference.
edge_ref: i32,
},
/// Invalid grid dimensions.
InvalidGridSize {
/// Cells on X axis.
cells_x: u32,
/// Cells on Y axis.
cells_y: u32,
},
/// Invalid area reference in a grid cell.
InvalidGridAreaRef {
/// Linear cell index.
cell: usize,
/// Referenced area.
area_ref: u32,
/// Total area count.
area_count: usize,
},
/// Invalid `BuildDat.lst` text encoding.
InvalidBuildDatUtf8,
/// Invalid `BuildDat.lst` section structure.
InvalidBuildDatStructure {
/// One-based line number.
line: usize,
/// Reason.
reason: &'static str,
},
/// Unknown `BuildDat.lst` category name.
UnknownBuildCategory {
/// One-based line number.
line: usize,
/// Category name.
name: String,
},
/// Integer overflow.
IntegerOverflow,
}
impl From<DecodeError> for TerrainFormatError {
fn from(value: DecodeError) -> Self {
Self::Decode(value)
}
}
impl From<NresError> for TerrainFormatError {
fn from(value: NresError) -> Self {
Self::Nres(value)
}
}
impl std::fmt::Display for TerrainFormatError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Decode(source) => write!(f, "{source}"),
Self::Nres(source) => write!(f, "{source}"),
Self::InvalidLandMapEntryCount { entry_count } => {
write!(f, "Land.map must contain exactly one entry, got {entry_count}")
}
Self::InvalidLandMapEntryType { type_id } => {
write!(f, "Land.map entry type must be 12, got {type_id}")
}
Self::MissingStream { type_id } => write!(f, "missing Land.msh stream {type_id}"),
Self::DuplicateStream { type_id } => write!(f, "duplicate Land.msh stream {type_id}"),
Self::InvalidStride {
type_id,
stride,
expected,
} => write!(
f,
"invalid Land.msh stream {type_id} stride {stride}, expected {expected}"
),
Self::InvalidSize {
type_id,
size,
stride,
} => write!(
f,
"invalid Land.msh stream {type_id} size {size}, stride/framing {stride}"
),
Self::CountMismatch {
type_id,
attr_count,
payload_count,
} => write!(
f,
"Land.msh stream {type_id} count mismatch: attr={attr_count}, payload={payload_count}"
),
Self::InvalidVertexIndex {
face,
vertex,
position_count,
} => write!(
f,
"Land.msh face {face} vertex {vertex} outside {position_count} positions"
),
Self::InvalidNeighborIndex {
face,
neighbor,
face_count,
} => write!(
f,
"Land.msh face {face} neighbor {neighbor} outside {face_count} faces"
),
Self::InvalidArealLink {
area,
link,
area_ref,
edge_ref,
} => write!(
f,
"Land.map area {area} link {link} has invalid reference ({area_ref}, {edge_ref})"
),
Self::InvalidGridSize { cells_x, cells_y } => {
write!(f, "Land.map invalid grid size {cells_x}x{cells_y}")
}
Self::InvalidGridAreaRef {
cell,
area_ref,
area_count,
} => write!(
f,
"Land.map grid cell {cell} references area {area_ref} outside {area_count} areas"
),
Self::InvalidBuildDatUtf8 => write!(f, "BuildDat.lst is not valid UTF-8/ASCII text"),
Self::InvalidBuildDatStructure { line, reason } => {
write!(f, "invalid BuildDat.lst structure at line {line}: {reason}")
}
Self::UnknownBuildCategory { line, name } => {
write!(f, "unknown BuildDat.lst category '{name}' at line {line}")
}
Self::IntegerOverflow => write!(f, "integer overflow"),
}
}
}
impl std::error::Error for TerrainFormatError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Decode(source) => Some(source),
Self::Nres(source) => Some(source),
Self::InvalidLandMapEntryCount { .. }
| Self::InvalidLandMapEntryType { .. }
| Self::MissingStream { .. }
| Self::DuplicateStream { .. }
| Self::InvalidStride { .. }
| Self::InvalidSize { .. }
| Self::CountMismatch { .. }
| Self::InvalidVertexIndex { .. }
| Self::InvalidNeighborIndex { .. }
| Self::InvalidArealLink { .. }
| Self::InvalidGridSize { .. }
| Self::InvalidGridAreaRef { .. }
| Self::InvalidBuildDatUtf8
| Self::InvalidBuildDatStructure { .. }
| Self::UnknownBuildCategory { .. }
| Self::IntegerOverflow => None,
}
}
}
/// Decodes a `Land.msh` `NRes` document.
///
/// # Errors
///
/// Returns [`TerrainFormatError`] when required streams are missing, stream
/// strides/counts do not match, or face vertex/neighbor references are invalid.
pub fn decode_land_msh(nres: &NresDocument) -> Result<LandMeshDocument, TerrainFormatError> {
for type_id in REQUIRED_TYPES {
require_single_stream(nres, type_id)?;
}
let nodes = stream_payload(nres, TYPE_NODES)?;
let slots = stream_payload(nres, TYPE_SLOTS)?;
let positions = stream_payload(nres, TYPE_POSITIONS)?;
let normals = stream_payload(nres, TYPE_NORMALS)?;
let uv0 = stream_payload(nres, TYPE_UV0)?;
let accelerator = stream_payload(nres, TYPE_ACCELERATOR)?;
let aux14 = stream_payload(nres, TYPE_AUX14)?;
let aux18 = stream_payload(nres, TYPE_AUX18)?;
let faces = stream_payload(nres, TYPE_FACES)?;
validate_stream(nres, TYPE_NODES, 38, nodes.len() / 38)?;
validate_slots(nres, slots)?;
let positions = parse_positions(nres, positions)?;
let normals = parse_i8x4_stream(nres, TYPE_NORMALS, normals)?;
let uv0 = parse_i16x2_stream(nres, TYPE_UV0, uv0)?;
let accelerator = parse_word_stream(nres, TYPE_ACCELERATOR, accelerator)?;
let aux14 = parse_word_stream(nres, TYPE_AUX14, aux14)?;
let aux18 = parse_word_stream(nres, TYPE_AUX18, aux18)?;
let faces = parse_faces(nres, faces)?;
validate_faces(&faces, positions.len())?;
Ok(LandMeshDocument {
streams: nres
.entries()
.iter()
.map(|entry| TerrainStream {
type_id: entry.meta().type_id,
attributes: attributes(entry.meta()),
size: entry.meta().data_size,
})
.collect(),
nodes_raw: nodes.to_vec(),
slots: parse_slot_table(slots),
positions,
normals,
uv0,
accelerator,
aux14,
aux18,
faces,
})
}
/// Decodes a `Land.map` `NRes` document.
///
/// # Errors
///
/// Returns [`TerrainFormatError`] when the archive does not contain exactly one
/// type 12 entry, the payload framing is invalid, references are out of range,
/// or the parser does not finish exactly at EOF.
pub fn decode_land_map(nres: &NresDocument) -> Result<LandMapDocument, TerrainFormatError> {
if nres.entry_count() != 1 {
return Err(TerrainFormatError::InvalidLandMapEntryCount {
entry_count: nres.entry_count(),
});
}
let entry = &nres.entries()[0];
let meta = entry.meta();
if meta.type_id != TYPE_AREAL_MAP {
return Err(TerrainFormatError::InvalidLandMapEntryType {
type_id: meta.type_id,
});
}
let payload = nres.payload(entry.id())?;
let areal_count =
usize::try_from(meta.attr1).map_err(|_| TerrainFormatError::IntegerOverflow)?;
let mut cursor = Cursor::new(payload);
let mut areals = Vec::with_capacity(areal_count);
for area_index in 0..areal_count {
areals.push(parse_areal(&mut cursor, area_index)?);
}
validate_areal_links(&areals)?;
let grid = parse_areal_grid(&mut cursor, areals.len())?;
cursor.require_eof()?;
Ok(LandMapDocument {
entry: TerrainStream {
type_id: meta.type_id,
attributes: attributes(meta),
size: meta.data_size,
},
areal_count: meta.attr1,
areals,
grid,
})
}
/// Decodes `Build.dat`.
///
/// # Errors
///
/// Returns [`TerrainFormatError`] when the file contains malformed sections,
/// unknown category names, invalid counts, or invalid quoted unit paths.
pub fn decode_build_dat(bytes: &[u8]) -> Result<Vec<BuildCategory>, TerrainFormatError> {
let text = std::str::from_utf8(bytes).map_err(|_| TerrainFormatError::InvalidBuildDatUtf8)?;
let mut categories = Vec::new();
let mut iter = text.lines().enumerate().peekable();
while let Some((line_index, raw_line)) = iter.next() {
let line_no = line_index + 1;
let line = raw_line.trim();
if line.is_empty() || line.starts_with("//") {
continue;
}
let (name, count) = parse_build_header(line_no, line)?;
let mask =
build_category_mask(name).ok_or_else(|| TerrainFormatError::UnknownBuildCategory {
line: line_no,
name: name.to_string(),
})?;
let mut unit_paths = Vec::with_capacity(count);
for _ in 0..count {
let Some((path_line_index, path_line_raw)) = iter.next() else {
return Err(TerrainFormatError::InvalidBuildDatStructure {
line: line_no,
reason: "section ended before declared path count",
});
};
let path_line_no = path_line_index + 1;
let path_line = path_line_raw.trim();
unit_paths.push(parse_quoted_path(path_line_no, path_line)?);
}
categories.push(BuildCategory {
name: name.to_string(),
mask,
unit_paths,
});
}
Ok(categories)
}
/// Converts full mask to compact mask with explicit bit preservation policy.
#[must_use]
pub fn full_to_compact(mask: FullSurfaceMask) -> CompactSurfaceMask {
let mut compact = 0u16;
for (full_bit, compact_bit) in SURFACE_MASK_MAP {
if mask.0 & full_bit != 0 {
compact |= compact_bit;
}
}
CompactSurfaceMask(compact)
}
/// Converts compact mask to full mask.
#[must_use]
pub fn compact_to_full(mask: CompactSurfaceMask) -> FullSurfaceMask {
let mut full = 0u32;
for (full_bit, compact_bit) in SURFACE_MASK_MAP {
if mask.0 & compact_bit != 0 {
full |= full_bit;
}
}
FullSurfaceMask(full)
}
/// Converts full mask to compact material class mask.
#[must_use]
pub fn full_to_material_class(mask: FullSurfaceMask) -> MaterialClassMask {
let mut compact = 0u8;
for (full_bit, compact_bit) in MATERIAL_MASK_MAP {
if mask.0 & full_bit != 0 {
compact |= compact_bit;
}
}
MaterialClassMask(compact)
}
/// Validates face references.
///
/// # Errors
///
/// Returns [`TerrainFormatError`] when a face references a vertex or neighbor
/// outside the decoded document.
pub fn validate_faces(
faces: &[TerrainFace28],
vertex_count: usize,
) -> Result<(), TerrainFormatError> {
for (face_index, face) in faces.iter().enumerate() {
for vertex in face.vertices {
if usize::from(vertex) >= vertex_count {
return Err(TerrainFormatError::InvalidVertexIndex {
face: face_index,
vertex,
position_count: vertex_count,
});
}
}
for neighbor in face.neighbors.iter().flatten() {
if usize::from(*neighbor) >= faces.len() {
return Err(TerrainFormatError::InvalidNeighborIndex {
face: face_index,
neighbor: *neighbor,
face_count: faces.len(),
});
}
}
}
Ok(())
}
const BUILD_CATEGORY_MASKS: &[(&str, u32)] = &[
("Bunker_Small", 0x8001_0000),
("Bunker_Medium", 0x8002_0000),
("Bunker_Large", 0x8004_0000),
("Generator", 0x8000_0002),
("Mine", 0x8000_0004),
("Storage", 0x8000_0008),
("Plant", 0x8000_0010),
("Hangar", 0x8000_0040),
("MainTeleport", 0x8000_0200),
("Institute", 0x8000_0400),
("Tower_Medium", 0x8010_0000),
("Tower_Large", 0x8020_0000),
];
const SURFACE_MASK_MAP: &[(u32, u16)] = &[
(0x0000_0001, 0x0001),
(0x0000_0008, 0x0002),
(0x0000_0010, 0x0004),
(0x0000_0020, 0x0008),
(0x0000_1000, 0x0010),
(0x0000_4000, 0x0020),
(0x0000_0002, 0x0040),
(0x0000_0400, 0x0080),
(0x0000_0800, 0x0100),
(0x0002_0000, 0x0200),
(0x0000_2000, 0x0400),
(0x0000_0200, 0x0800),
(0x0000_0004, 0x1000),
(0x0000_0040, 0x2000),
(0x0020_0000, 0x8000),
];
const MATERIAL_MASK_MAP: &[(u32, u8)] = &[
(0x0000_0100, 0x01),
(0x0000_8000, 0x02),
(0x0001_0000, 0x04),
(0x0004_0000, 0x08),
(0x0008_0000, 0x10),
(0x0000_0080, 0x20),
];
fn parse_build_header(line: usize, text: &str) -> Result<(&str, usize), TerrainFormatError> {
let mut parts = text.split_ascii_whitespace();
let name = parts
.next()
.ok_or(TerrainFormatError::InvalidBuildDatStructure {
line,
reason: "missing category name",
})?;
let count_raw = parts
.next()
.ok_or(TerrainFormatError::InvalidBuildDatStructure {
line,
reason: "missing category count",
})?;
if parts.next().is_some() {
return Err(TerrainFormatError::InvalidBuildDatStructure {
line,
reason: "extra fields in category header",
});
}
let count =
count_raw
.parse::<usize>()
.map_err(|_| TerrainFormatError::InvalidBuildDatStructure {
line,
reason: "invalid category count",
})?;
Ok((name, count))
}
fn parse_quoted_path(line: usize, text: &str) -> Result<String, TerrainFormatError> {
if text.len() < 2 || !text.starts_with('"') || !text.ends_with('"') {
return Err(TerrainFormatError::InvalidBuildDatStructure {
line,
reason: "unit path must be quoted",
});
}
let path = &text[1..text.len() - 1];
if path.is_empty() {
return Err(TerrainFormatError::InvalidBuildDatStructure {
line,
reason: "unit path must not be empty",
});
}
if !path.bytes().all(is_build_path_byte) {
return Err(TerrainFormatError::InvalidBuildDatStructure {
line,
reason: "unit path contains invalid byte",
});
}
Ok(path.to_string())
}
fn is_build_path_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'/' | b'\\' | b'-')
}
fn build_category_mask(name: &str) -> Option<u32> {
BUILD_CATEGORY_MASKS
.iter()
.find_map(|(category, mask)| (*category == name).then_some(*mask))
}
fn require_single_stream(nres: &NresDocument, type_id: u32) -> Result<EntryId, TerrainFormatError> {
let mut found = None;
for entry in nres
.entries()
.iter()
.filter(|entry| entry.meta().type_id == type_id)
{
if found.is_some() {
return Err(TerrainFormatError::DuplicateStream { type_id });
}
found = Some(entry.id());
}
found.ok_or(TerrainFormatError::MissingStream { type_id })
}
fn stream_payload(nres: &NresDocument, type_id: u32) -> Result<&[u8], TerrainFormatError> {
let id = require_single_stream(nres, type_id)?;
nres.payload(id).map_err(Into::into)
}
fn stream_meta(nres: &NresDocument, type_id: u32) -> Result<&EntryMeta, TerrainFormatError> {
let id = require_single_stream(nres, type_id)?;
nres.entry(id)
.map(fparkan_nres::NresEntry::meta)
.ok_or(TerrainFormatError::MissingStream { type_id })
}
fn validate_stream(
nres: &NresDocument,
type_id: u32,
stride: usize,
count: usize,
) -> Result<(), TerrainFormatError> {
let meta = stream_meta(nres, type_id)?;
let expected = u32::try_from(stride).map_err(|_| TerrainFormatError::IntegerOverflow)?;
if meta.attr3 != expected {
return Err(TerrainFormatError::InvalidStride {
type_id,
stride: meta.attr3,
expected,
});
}
let attr_count =
usize::try_from(meta.attr1).map_err(|_| TerrainFormatError::IntegerOverflow)?;
if attr_count != count {
return Err(TerrainFormatError::CountMismatch {
type_id,
attr_count: meta.attr1,
payload_count: count,
});
}
Ok(())
}
fn validate_slots(nres: &NresDocument, payload: &[u8]) -> Result<(), TerrainFormatError> {
let meta = stream_meta(nres, TYPE_SLOTS)?;
if payload.len() < SLOT_HEADER_SIZE {
return Err(TerrainFormatError::InvalidSize {
type_id: TYPE_SLOTS,
size: payload.len(),
stride: SLOT_HEADER_SIZE,
});
}
let tail = payload.len() - SLOT_HEADER_SIZE;
if !tail.is_multiple_of(SLOT_STRIDE) {
return Err(TerrainFormatError::InvalidSize {
type_id: TYPE_SLOTS,
size: payload.len(),
stride: SLOT_STRIDE,
});
}
let slots = tail / SLOT_STRIDE;
let attr_count =
usize::try_from(meta.attr1).map_err(|_| TerrainFormatError::IntegerOverflow)?;
if attr_count != slots {
return Err(TerrainFormatError::CountMismatch {
type_id: TYPE_SLOTS,
attr_count: meta.attr1,
payload_count: slots,
});
}
Ok(())
}
fn parse_slot_table(payload: &[u8]) -> TerrainSlotTable {
let mut slots_raw = Vec::new();
for chunk in payload[SLOT_HEADER_SIZE..].chunks_exact(SLOT_STRIDE) {
let mut raw = [0; SLOT_STRIDE];
raw.copy_from_slice(chunk);
slots_raw.push(raw);
}
TerrainSlotTable {
header_raw: payload[..SLOT_HEADER_SIZE].to_vec(),
slots_raw,
}
}
fn parse_positions(
nres: &NresDocument,
payload: &[u8],
) -> Result<Vec<[f32; 3]>, TerrainFormatError> {
if !payload.len().is_multiple_of(12) {
return Err(TerrainFormatError::InvalidSize {
type_id: TYPE_POSITIONS,
size: payload.len(),
stride: 12,
});
}
let count = payload.len() / 12;
validate_stream(nres, TYPE_POSITIONS, 12, count)?;
let mut out = Vec::with_capacity(count);
for chunk in payload.chunks_exact(12) {
out.push([
read_f32(chunk, 0)?,
read_f32(chunk, 4)?,
read_f32(chunk, 8)?,
]);
}
Ok(out)
}
fn parse_i8x4_stream(
nres: &NresDocument,
type_id: u32,
payload: &[u8],
) -> Result<Vec<[i8; 4]>, TerrainFormatError> {
if !payload.len().is_multiple_of(4) {
return Err(TerrainFormatError::InvalidSize {
type_id,
size: payload.len(),
stride: 4,
});
}
let count = payload.len() / 4;
validate_stream(nres, type_id, 4, count)?;
Ok(payload
.chunks_exact(4)
.map(|chunk| {
[
i8::from_le_bytes([chunk[0]]),
i8::from_le_bytes([chunk[1]]),
i8::from_le_bytes([chunk[2]]),
i8::from_le_bytes([chunk[3]]),
]
})
.collect())
}
fn parse_i16x2_stream(
nres: &NresDocument,
type_id: u32,
payload: &[u8],
) -> Result<Vec<[i16; 2]>, TerrainFormatError> {
if !payload.len().is_multiple_of(4) {
return Err(TerrainFormatError::InvalidSize {
type_id,
size: payload.len(),
stride: 4,
});
}
let count = payload.len() / 4;
validate_stream(nres, type_id, 4, count)?;
let mut out = Vec::with_capacity(count);
for chunk in payload.chunks_exact(4) {
out.push([read_i16(chunk, 0)?, read_i16(chunk, 2)?]);
}
Ok(out)
}
fn parse_word_stream(
nres: &NresDocument,
type_id: u32,
payload: &[u8],
) -> Result<Vec<[u8; 4]>, TerrainFormatError> {
if !payload.len().is_multiple_of(4) {
return Err(TerrainFormatError::InvalidSize {
type_id,
size: payload.len(),
stride: 4,
});
}
let count = payload.len() / 4;
validate_stream(nres, type_id, 4, count)?;
Ok(payload
.chunks_exact(4)
.map(|chunk| [chunk[0], chunk[1], chunk[2], chunk[3]])
.collect())
}
fn parse_faces(
nres: &NresDocument,
payload: &[u8],
) -> Result<Vec<TerrainFace28>, TerrainFormatError> {
if !payload.len().is_multiple_of(28) {
return Err(TerrainFormatError::InvalidSize {
type_id: TYPE_FACES,
size: payload.len(),
stride: 28,
});
}
let count = payload.len() / 28;
validate_stream(nres, TYPE_FACES, 28, count)?;
let mut out = Vec::with_capacity(count);
for chunk in payload.chunks_exact(28) {
let mut raw = [0; 28];
raw.copy_from_slice(chunk);
let mut tail_raw = [0; 8];
tail_raw.copy_from_slice(&chunk[20..28]);
out.push(TerrainFace28 {
flags: FullSurfaceMask(read_u32(chunk, 0)?),
material_tag: read_u16(chunk, 4)?,
aux_tag: read_u16(chunk, 6)?,
vertices: [
read_u16(chunk, 8)?,
read_u16(chunk, 10)?,
read_u16(chunk, 12)?,
],
neighbors: [
neighbor(read_u16(chunk, 14)?),
neighbor(read_u16(chunk, 16)?),
neighbor(read_u16(chunk, 18)?),
],
tail_raw,
raw,
});
}
Ok(out)
}
fn neighbor(raw: u16) -> Option<u16> {
(raw != u16::MAX).then_some(raw)
}
fn parse_areal(cursor: &mut Cursor<'_>, _area_index: usize) -> Result<Areal, TerrainFormatError> {
let prefix = cursor.read_exact(AREAL_PREFIX_SIZE)?;
let mut prefix_raw = [0; AREAL_PREFIX_SIZE];
prefix_raw.copy_from_slice(prefix);
let vertex_count = read_u32(prefix, 48)?;
let poly_count = read_u32(prefix, 52)?;
let vertices = parse_areal_vertices(cursor, vertex_count)?;
let link_count = vertex_count
.checked_add(
poly_count
.checked_mul(3)
.ok_or(TerrainFormatError::IntegerOverflow)?,
)
.ok_or(TerrainFormatError::IntegerOverflow)?;
let links = parse_edge_links(cursor, link_count)?;
let polygon_blocks = parse_polygon_blocks(cursor, poly_count)?;
Ok(Areal {
prefix_raw,
anchor: [
read_f32(prefix, 0)?,
read_f32(prefix, 4)?,
read_f32(prefix, 8)?,
],
reserved_12: read_f32(prefix, 12)?,
area_metric: read_f32(prefix, 16)?,
normal: [
read_f32(prefix, 20)?,
read_f32(prefix, 24)?,
read_f32(prefix, 28)?,
],
logic_flag: read_u32(prefix, 32)?,
reserved_36: read_u32(prefix, 36)?,
class_id: read_u32(prefix, 40)?,
reserved_44: read_u32(prefix, 44)?,
vertices,
links,
polygon_blocks,
})
}
fn parse_areal_vertices(
cursor: &mut Cursor<'_>,
vertex_count: u32,
) -> Result<Vec<[f32; 3]>, TerrainFormatError> {
checked_count_bytes(u64::from(vertex_count), 12, cursor.remaining() as u64)?;
let count = usize::try_from(vertex_count).map_err(|_| TerrainFormatError::IntegerOverflow)?;
let mut vertices = Vec::with_capacity(count);
for _ in 0..count {
vertices.push([
cursor.read_f32_le()?,
cursor.read_f32_le()?,
cursor.read_f32_le()?,
]);
}
Ok(vertices)
}
fn parse_edge_links(
cursor: &mut Cursor<'_>,
link_count: u32,
) -> Result<Vec<EdgeLink>, TerrainFormatError> {
checked_count_bytes(u64::from(link_count), 8, cursor.remaining() as u64)?;
let count = usize::try_from(link_count).map_err(|_| TerrainFormatError::IntegerOverflow)?;
let mut links = Vec::with_capacity(count);
for _ in 0..count {
let raw_area_ref = cursor.read_i32_le()?;
let raw_edge_ref = cursor.read_i32_le()?;
let (area_ref, edge_ref) = match (raw_area_ref, raw_edge_ref) {
(-1, -1) => (None, None),
(area, edge) if area >= 0 && edge >= 0 => {
let area = u32::try_from(area).map_err(|_| TerrainFormatError::IntegerOverflow)?;
let edge = u32::try_from(edge).map_err(|_| TerrainFormatError::IntegerOverflow)?;
(Some(area), Some(edge))
}
_ => (None, None),
};
links.push(EdgeLink {
raw_area_ref,
raw_edge_ref,
area_ref,
edge_ref,
});
}
Ok(links)
}
fn parse_polygon_blocks(
cursor: &mut Cursor<'_>,
poly_count: u32,
) -> Result<Vec<ArealPolygonBlock>, TerrainFormatError> {
let count = usize::try_from(poly_count).map_err(|_| TerrainFormatError::IntegerOverflow)?;
let mut blocks = Vec::with_capacity(count);
for _ in 0..count {
let n = cursor.read_u32_le()?;
let word_count = u64::from(n)
.checked_mul(3)
.and_then(|count| count.checked_add(1))
.ok_or(TerrainFormatError::IntegerOverflow)?;
let byte_count = checked_count_bytes(word_count, 4, cursor.remaining() as u64)?;
blocks.push(ArealPolygonBlock {
n,
body_raw: cursor.read_exact(byte_count)?.to_vec(),
});
}
Ok(blocks)
}
fn validate_areal_links(areals: &[Areal]) -> Result<(), TerrainFormatError> {
for (area_index, area) in areals.iter().enumerate() {
for (link_index, link) in area.links.iter().enumerate() {
match (link.area_ref, link.edge_ref) {
(None, None) if link.raw_area_ref == -1 && link.raw_edge_ref == -1 => {}
(Some(area_ref), Some(edge_ref)) => {
let Some(target) = usize::try_from(area_ref)
.ok()
.and_then(|index| areals.get(index))
else {
return Err(invalid_areal_link(area_index, link_index, link));
};
let edge_index = usize::try_from(edge_ref)
.map_err(|_| TerrainFormatError::IntegerOverflow)?;
if edge_index >= target.links.len() {
return Err(invalid_areal_link(area_index, link_index, link));
}
}
_ => return Err(invalid_areal_link(area_index, link_index, link)),
}
}
}
Ok(())
}
fn invalid_areal_link(area: usize, link: usize, edge_link: &EdgeLink) -> TerrainFormatError {
TerrainFormatError::InvalidArealLink {
area,
link,
area_ref: edge_link.raw_area_ref,
edge_ref: edge_link.raw_edge_ref,
}
}
fn parse_areal_grid(
cursor: &mut Cursor<'_>,
area_count: usize,
) -> Result<ArealGrid, TerrainFormatError> {
let cells_x = cursor.read_u32_le()?;
let cells_y = cursor.read_u32_le()?;
let cell_count = cells_x
.checked_mul(cells_y)
.ok_or(TerrainFormatError::IntegerOverflow)?;
if cell_count == 0 {
return Err(TerrainFormatError::InvalidGridSize { cells_x, cells_y });
}
let cell_count_usize =
usize::try_from(cell_count).map_err(|_| TerrainFormatError::IntegerOverflow)?;
let mut cells = Vec::with_capacity(cell_count_usize);
let mut candidate_pool = Vec::new();
let mut compact_cells = Vec::with_capacity(cell_count_usize);
for cell_index in 0..cell_count_usize {
let hit_count = cursor.read_u16_le()?;
let pool_offset =
u32::try_from(candidate_pool.len()).map_err(|_| TerrainFormatError::IntegerOverflow)?;
if u32::from(hit_count) >= (1 << GRID_HIT_COUNT_BITS) || pool_offset > GRID_POOL_OFFSET_MASK
{
return Err(TerrainFormatError::IntegerOverflow);
}
let mut area_ids = Vec::with_capacity(usize::from(hit_count));
for _ in 0..hit_count {
let area_ref = u32::from(cursor.read_u16_le()?);
if usize::try_from(area_ref).map_or(true, |index| index >= area_count) {
return Err(TerrainFormatError::InvalidGridAreaRef {
cell: cell_index,
area_ref,
area_count,
});
}
area_ids.push(area_ref);
candidate_pool.push(area_ref);
}
compact_cells.push((u32::from(hit_count) << 22) | pool_offset);
cells.push(ArealGridCell { area_ids });
}
Ok(ArealGrid {
cells_x,
cells_y,
cells,
candidate_pool,
compact_cells,
})
}
fn attributes(meta: &EntryMeta) -> TerrainStreamAttributes {
TerrainStreamAttributes {
attr1: meta.attr1,
attr2: meta.attr2,
attr3: meta.attr3,
}
}
fn read_u16(bytes: &[u8], offset: usize) -> Result<u16, TerrainFormatError> {
let raw = bytes
.get(offset..offset + 2)
.ok_or(TerrainFormatError::IntegerOverflow)?;
Ok(u16::from_le_bytes([raw[0], raw[1]]))
}
fn read_i16(bytes: &[u8], offset: usize) -> Result<i16, TerrainFormatError> {
let raw = bytes
.get(offset..offset + 2)
.ok_or(TerrainFormatError::IntegerOverflow)?;
Ok(i16::from_le_bytes([raw[0], raw[1]]))
}
fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, TerrainFormatError> {
let raw = bytes
.get(offset..offset + 4)
.ok_or(TerrainFormatError::IntegerOverflow)?;
Ok(u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]))
}
fn read_f32(bytes: &[u8], offset: usize) -> Result<f32, TerrainFormatError> {
Ok(f32::from_bits(read_u32(bytes, offset)?))
}
#[cfg(test)]
mod tests {
use super::*;
use fparkan_nres::ReadProfile;
use std::path::{Path, PathBuf};
use std::sync::Arc;
static SLOT_HEADER_ZERO: [u8; SLOT_HEADER_SIZE] = [0; SLOT_HEADER_SIZE];
static STREAM12_ZERO: [u8; 12] = [0; 12];
#[test]
fn decodes_minimal_land_msh() {
let nres =
decode_nres(&minimal_land_msh(&face([0, 1, 2], [None, None, None]))).expect("nres");
let document = decode_land_msh(&nres).expect("land mesh");
assert_eq!(document.positions.len(), 3);
assert_eq!(document.faces.len(), 1);
assert_eq!(document.faces[0].vertices, [0, 1, 2]);
assert_eq!(document.faces[0].neighbors, [None, None, None]);
}
#[test]
fn land_msh_required_streams_are_order_independent_and_stride_checked() {
let face = face([0, 1, 2], [None, None, None]);
let positions = minimal_positions_payload();
let entries = minimal_land_msh_entries(&face, &positions);
let shuffled = [
entries[8], entries[2], entries[0], entries[7], entries[4], entries[3], entries[6],
entries[5], entries[1],
];
let nres = decode_nres(&build_nres(&shuffled)).expect("nres");
let document = decode_land_msh(&nres).expect("land mesh");
assert_eq!(document.positions.len(), 3);
assert_eq!(
document
.streams
.iter()
.map(|stream| stream.type_id)
.collect::<Vec<_>>(),
vec![
TYPE_FACES,
TYPE_POSITIONS,
TYPE_NODES,
TYPE_ACCELERATOR,
TYPE_UV0,
TYPE_NORMALS,
TYPE_AUX14,
TYPE_AUX18,
TYPE_SLOTS,
]
);
let bad_stride = [
entries[0],
entries[1],
entries[2],
entry(TYPE_NORMALS, 3, 8, &[0; 12]),
entries[4],
entries[5],
entries[6],
entries[7],
entries[8],
];
let nres = decode_nres(&build_nres(&bad_stride)).expect("nres");
assert!(matches!(
decode_land_msh(&nres),
Err(TerrainFormatError::InvalidStride {
type_id: TYPE_NORMALS,
..
})
));
}
#[test]
fn rejects_invalid_vertex_index() {
let nres =
decode_nres(&minimal_land_msh(&face([0, 1, 3], [None, None, None]))).expect("nres");
let err = decode_land_msh(&nres).expect_err("invalid vertex");
assert!(matches!(
err,
TerrainFormatError::InvalidVertexIndex { vertex: 3, .. }
));
}
#[test]
fn rejects_invalid_neighbor_index() {
let nres =
decode_nres(&minimal_land_msh(&face([0, 1, 2], [Some(1), None, None]))).expect("nres");
let err = decode_land_msh(&nres).expect_err("invalid neighbor");
assert!(matches!(
err,
TerrainFormatError::InvalidNeighborIndex { neighbor: 1, .. }
));
}
#[test]
fn face_layout_preserves_tail_and_all_surface_mask_mappings_are_explicit() {
let mut raw_face = face([0, 1, 2], [None, None, None]);
raw_face[20..28].copy_from_slice(b"UNKNOWN!");
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
let document = decode_land_msh(&nres).expect("land mesh");
assert_eq!(document.faces[0].tail_raw, *b"UNKNOWN!");
assert_eq!(document.faces[0].raw, raw_face);
for (full, compact) in SURFACE_MASK_MAP {
assert_eq!(
full_to_compact(FullSurfaceMask(*full)),
CompactSurfaceMask(*compact)
);
assert_eq!(
compact_to_full(CompactSurfaceMask(*compact)),
FullSurfaceMask(*full)
);
}
assert_eq!(
full_to_compact(FullSurfaceMask(0x0000_0008)),
CompactSurfaceMask(0x0002)
);
assert_eq!(
full_to_compact(FullSurfaceMask(0x0020_0000)),
CompactSurfaceMask(0x8000)
);
assert_eq!(
compact_to_full(CompactSurfaceMask(0x8000)),
FullSurfaceMask(0x0020_0000)
);
assert_eq!(
full_to_material_class(FullSurfaceMask(0x0000_8000 | 0x0000_0080)),
MaterialClassMask(0x22)
);
}
#[test]
fn terrain_material_tag_decodes_two_sidecar_selectors() {
let mut raw_face = face([0, 1, 2], [None, None, None]);
raw_face[4..6].copy_from_slice(&0x0102_u16.to_le_bytes());
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
let document = decode_land_msh(&nres).expect("land mesh");
assert_eq!(
document.faces[0].material_layers(),
TerrainMaterialLayers {
land1_selector: Some(1),
land2_selector: 2,
}
);
raw_face[4..6].copy_from_slice(&0xff03_u16.to_le_bytes());
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
let document = decode_land_msh(&nres).expect("land mesh");
assert_eq!(
document.faces[0].material_layers(),
TerrainMaterialLayers {
land1_selector: None,
land2_selector: 3,
}
);
}
#[test]
fn decodes_minimal_land_map() {
let nres = decode_nres(&minimal_land_map([(-1, -1), (-1, -1)], 0)).expect("nres");
let document = decode_land_map(&nres).expect("land map");
assert_eq!(document.areal_count, 1);
assert_eq!(document.areals.len(), 1);
assert_eq!(document.areals[0].vertices.len(), 2);
assert_eq!(document.areals[0].links.len(), 2);
assert_eq!(document.grid.cells_x, 1);
assert_eq!(document.grid.cells_y, 1);
assert_eq!(document.grid.cells[0].area_ids, [0]);
assert_eq!(document.grid.compact_cells, [0x0040_0000]);
}
#[test]
fn land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof() {
let nres = decode_nres(&minimal_land_map_with_poly(1, true)).expect("nres");
let document = decode_land_map(&nres).expect("land map");
assert_eq!(document.areals[0].prefix_raw.len(), AREAL_PREFIX_SIZE);
assert_eq!(document.areals[0].anchor, [0.0, 0.0, 0.0]);
assert_eq!(document.areals[0].area_metric, 2.0);
assert_eq!(document.areals[0].links[0].area_ref, None);
assert_eq!(document.areals[0].polygon_blocks.len(), 1);
assert_eq!(document.areals[0].links.len(), 5);
assert_eq!(document.grid.cells_x, 1);
assert_eq!(document.grid.cells_y, 1);
let nres = decode_nres(&minimal_land_map_with_vertex_count(3)).expect("nres");
assert!(decode_land_map(&nres).is_err());
let nres = decode_nres(&minimal_land_map_with_poly(1_000_000, true)).expect("nres");
assert!(decode_land_map(&nres).is_err());
let nres = decode_nres(&minimal_land_map_with_poly(0, false)).expect("nres");
assert!(matches!(
decode_land_map(&nres),
Err(TerrainFormatError::InvalidGridSize { cells_x: 0, .. })
));
let nres = decode_nres(&minimal_land_map_with_payload_tail()).expect("nres");
assert!(decode_land_map(&nres).is_err());
}
#[test]
fn rejects_invalid_areal_link() {
let nres = decode_nres(&minimal_land_map([(1, 0), (-1, -1)], 0)).expect("nres");
let err = decode_land_map(&nres).expect_err("invalid link");
assert!(matches!(
err,
TerrainFormatError::InvalidArealLink {
area: 0,
link: 0,
area_ref: 1,
edge_ref: 0
}
));
}
#[test]
fn rejects_invalid_grid_area_ref() {
let nres = decode_nres(&minimal_land_map([(-1, -1), (-1, -1)], 1)).expect("nres");
let err = decode_land_map(&nres).expect_err("invalid grid");
assert!(matches!(
err,
TerrainFormatError::InvalidGridAreaRef {
cell: 0,
area_ref: 1,
area_count: 1
}
));
}
#[test]
fn decodes_synthetic_build_dat() {
let bytes = br#"
// comment
Bunker_Small 2
"UNITS\BUILDS\BUNKER\sbunk01.dat"
"UNITS\BUILDS\BUNKER\sbunk02.dat"
Generator 1
"UNITS\BUILDS\GENER\gener01.dat"
"#;
let categories = decode_build_dat(bytes).expect("BuildDat");
assert_eq!(categories.len(), 2);
assert_eq!(categories[0].name, "Bunker_Small");
assert_eq!(categories[0].mask, 0x8001_0000);
assert_eq!(categories[0].unit_paths.len(), 2);
assert_eq!(categories[1].name, "Generator");
assert_eq!(categories[1].mask, 0x8000_0002);
}
#[test]
fn rejects_unknown_build_category() {
let err = decode_build_dat(br#"Unknown 0"#).expect_err("unknown category");
assert!(matches!(
err,
TerrainFormatError::UnknownBuildCategory { line: 1, .. }
));
}
#[test]
fn rejects_build_category_count_mismatch() {
let err = decode_build_dat(
br#"Bunker_Small 2
"UNITS\BUILDS\BUNKER\sbunk01.dat"
"#,
)
.expect_err("count mismatch");
assert!(matches!(
err,
TerrainFormatError::InvalidBuildDatStructure { line: 1, .. }
));
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_land_msh_validate() {
for (corpus, expected_files, expected_vertices, expected_faces) in [
("IS", 33_usize, 299_450_usize, 275_882_usize),
("IS2", 32_usize, 188_024_usize, 184_454_usize),
] {
let root = corpus_root(corpus);
let mut files = 0usize;
let mut vertices = 0usize;
let mut faces = 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("Land.msh"))
{
continue;
}
let bytes = std::fs::read(&path).expect("read Land.msh");
let nres = fparkan_nres::decode(
Arc::from(bytes.into_boxed_slice()),
ReadProfile::Compatible,
)
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
let document =
decode_land_msh(&nres).unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
files += 1;
vertices += document.positions.len();
faces += document.faces.len();
assert_eq!(
document
.streams
.iter()
.map(|stream| stream.type_id)
.collect::<Vec<_>>(),
REQUIRED_TYPES,
"{corpus} {path:?} stream order"
);
}
assert_eq!(files, expected_files, "{corpus} Land.msh count");
assert_eq!(vertices, expected_vertices, "{corpus} vertex count");
assert_eq!(faces, expected_faces, "{corpus} face count");
}
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_build_dat_validate() {
for (corpus, expected_ai_prefix) in [("IS", false), ("IS2", true)] {
let root = corpus_root(corpus);
let path = root.join("BuildDat.lst");
let bytes = std::fs::read(&path).expect("read BuildDat.lst");
let categories =
decode_build_dat(&bytes).unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
assert_eq!(categories.len(), BUILD_CATEGORY_MASKS.len(), "{corpus}");
assert_eq!(
categories
.iter()
.map(|category| (category.name.as_str(), category.mask))
.collect::<Vec<_>>(),
BUILD_CATEGORY_MASKS,
"{corpus} category order/masks"
);
assert_eq!(
categories
.iter()
.map(|category| category.unit_paths.len())
.sum::<usize>(),
32,
"{corpus} unit path count"
);
assert!(
categories
.iter()
.all(
|category| category.unit_paths.iter().all(|path| path.starts_with(
if expected_ai_prefix {
"UNITS\\BUILDS\\AI\\"
} else {
"UNITS\\BUILDS\\"
}
) && path
.to_ascii_lowercase()
.ends_with(".dat"))
),
"{corpus} unit path prefixes"
);
}
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_land_map_validate() {
for (corpus, expected_files, expected_areals, expected_vertices, expected_max_hits) in [
("IS", 33_usize, 34_662_usize, 197_698_usize, 20_usize),
("IS2", 32_usize, 18_984_usize, 114_968_usize, 14_usize),
] {
let root = corpus_root(corpus);
let mut files = 0usize;
let mut areals = 0usize;
let mut vertices = 0usize;
let mut max_hits = 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("Land.map"))
{
continue;
}
let bytes = std::fs::read(&path).expect("read Land.map");
let nres = fparkan_nres::decode(
Arc::from(bytes.into_boxed_slice()),
ReadProfile::Compatible,
)
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
let document =
decode_land_map(&nres).unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
files += 1;
areals += document.areals.len();
vertices += document
.areals
.iter()
.map(|area| area.vertices.len())
.sum::<usize>();
max_hits = max_hits.max(
document
.grid
.cells
.iter()
.map(|cell| cell.area_ids.len())
.max()
.unwrap_or(0),
);
assert_eq!(document.grid.cells_x, 128, "{corpus} {path:?} cells_x");
assert_eq!(document.grid.cells_y, 128, "{corpus} {path:?} cells_y");
assert!(
document
.areals
.iter()
.all(|area| area.polygon_blocks.is_empty()),
"{corpus} {path:?} polygon blocks"
);
}
assert_eq!(files, expected_files, "{corpus} Land.map count");
assert_eq!(areals, expected_areals, "{corpus} areal count");
assert_eq!(vertices, expected_vertices, "{corpus} areal vertex count");
assert_eq!(max_hits, expected_max_hits, "{corpus} max grid hits");
}
}
fn decode_nres(bytes: &[u8]) -> Result<NresDocument, fparkan_nres::NresError> {
fparkan_nres::decode(
Arc::from(bytes.to_vec().into_boxed_slice()),
ReadProfile::Compatible,
)
}
fn minimal_land_msh(face: &[u8; 28]) -> Vec<u8> {
let positions = minimal_positions_payload();
build_nres(&minimal_land_msh_entries(face, &positions))
}
fn minimal_positions_payload() -> Vec<u8> {
[
0.0_f32.to_le_bytes(),
0.0_f32.to_le_bytes(),
0.0_f32.to_le_bytes(),
1.0_f32.to_le_bytes(),
0.0_f32.to_le_bytes(),
0.0_f32.to_le_bytes(),
0.0_f32.to_le_bytes(),
1.0_f32.to_le_bytes(),
0.0_f32.to_le_bytes(),
]
.concat()
}
fn minimal_land_msh_entries<'a>(face: &'a [u8; 28], positions: &'a [u8]) -> [TestEntry<'a>; 9] {
[
entry(TYPE_NODES, 0, 38, &[]),
entry(TYPE_SLOTS, 0, 0, &SLOT_HEADER_ZERO),
entry(TYPE_POSITIONS, 3, 12, positions),
entry(TYPE_NORMALS, 3, 4, &STREAM12_ZERO),
entry(TYPE_UV0, 3, 4, &STREAM12_ZERO),
entry(TYPE_AUX18, 0, 4, &[]),
entry(TYPE_AUX14, 0, 4, &[]),
entry(TYPE_ACCELERATOR, 0, 4, &[]),
entry(TYPE_FACES, 1, 28, face),
]
}
fn minimal_land_map(links: [(i32, i32); 2], grid_area_ref: u16) -> Vec<u8> {
let mut payload = Vec::new();
push_f32(&mut payload, 0.0);
push_f32(&mut payload, 0.0);
push_f32(&mut payload, 0.0);
push_f32(&mut payload, 0.0);
push_f32(&mut payload, 2.0);
push_f32(&mut payload, 0.0);
push_f32(&mut payload, 1.0);
push_f32(&mut payload, 0.0);
push_u32(&mut payload, 0);
push_u32(&mut payload, 0);
push_u32(&mut payload, 7);
push_u32(&mut payload, 0);
push_u32(&mut payload, 2);
push_u32(&mut payload, 0);
push_f32(&mut payload, 0.0);
push_f32(&mut payload, 0.0);
push_f32(&mut payload, 0.0);
push_f32(&mut payload, 1.0);
push_f32(&mut payload, 0.0);
push_f32(&mut payload, 0.0);
for (area_ref, edge_ref) in links {
push_i32(&mut payload, area_ref);
push_i32(&mut payload, edge_ref);
}
push_u32(&mut payload, 1);
push_u32(&mut payload, 1);
push_u16(&mut payload, 1);
push_u16(&mut payload, grid_area_ref);
build_nres(&[entry(TYPE_AREAL_MAP, 1, 0, &payload)])
}
fn minimal_land_map_with_poly(poly_n: u32, valid_grid: bool) -> Vec<u8> {
let mut payload = Vec::new();
push_areal_prefix(&mut payload, 2, 1);
push_f32(&mut payload, 0.0);
push_f32(&mut payload, 0.0);
push_f32(&mut payload, 0.0);
push_f32(&mut payload, 1.0);
push_f32(&mut payload, 0.0);
push_f32(&mut payload, 0.0);
for _ in 0..5 {
push_i32(&mut payload, -1);
push_i32(&mut payload, -1);
}
push_u32(&mut payload, poly_n);
match poly_n {
0 => payload.extend_from_slice(&[0; 4]),
1 => payload.extend_from_slice(&[0; 16]),
_ => {}
}
if valid_grid {
push_u32(&mut payload, 1);
push_u32(&mut payload, 1);
push_u16(&mut payload, 1);
push_u16(&mut payload, 0);
} else {
push_u32(&mut payload, 0);
push_u32(&mut payload, 1);
}
build_nres(&[entry(TYPE_AREAL_MAP, 1, 0, &payload)])
}
fn minimal_land_map_with_vertex_count(vertex_count: u32) -> Vec<u8> {
let mut payload = Vec::new();
push_areal_prefix(&mut payload, vertex_count, 0);
build_nres(&[entry(TYPE_AREAL_MAP, 1, 0, &payload)])
}
fn minimal_land_map_with_payload_tail() -> Vec<u8> {
let mut payload = Vec::new();
push_areal_prefix(&mut payload, 2, 0);
push_f32(&mut payload, 0.0);
push_f32(&mut payload, 0.0);
push_f32(&mut payload, 0.0);
push_f32(&mut payload, 1.0);
push_f32(&mut payload, 0.0);
push_f32(&mut payload, 0.0);
for _ in 0..2 {
push_i32(&mut payload, -1);
push_i32(&mut payload, -1);
}
push_u32(&mut payload, 1);
push_u32(&mut payload, 1);
push_u16(&mut payload, 1);
push_u16(&mut payload, 0);
payload.push(0);
build_nres(&[entry(TYPE_AREAL_MAP, 1, 0, &payload)])
}
fn push_areal_prefix(payload: &mut Vec<u8>, vertex_count: u32, poly_count: u32) {
push_f32(payload, 0.0);
push_f32(payload, 0.0);
push_f32(payload, 0.0);
push_f32(payload, 0.0);
push_f32(payload, 2.0);
push_f32(payload, 0.0);
push_f32(payload, 1.0);
push_f32(payload, 0.0);
push_u32(payload, 0);
push_u32(payload, 0);
push_u32(payload, 7);
push_u32(payload, 0);
push_u32(payload, vertex_count);
push_u32(payload, poly_count);
}
fn face(vertices: [u16; 3], neighbors: [Option<u16>; 3]) -> [u8; 28] {
let mut out = [0; 28];
out[8..10].copy_from_slice(&vertices[0].to_le_bytes());
out[10..12].copy_from_slice(&vertices[1].to_le_bytes());
out[12..14].copy_from_slice(&vertices[2].to_le_bytes());
for (idx, neighbor) in neighbors.iter().enumerate() {
let raw = neighbor.unwrap_or(u16::MAX);
let offset = 14 + idx * 2;
out[offset..offset + 2].copy_from_slice(&raw.to_le_bytes());
}
out[20..28].copy_from_slice(b"TAILFACE");
out
}
fn entry(type_id: u32, attr1: u32, attr3: u32, payload: &[u8]) -> TestEntry<'_> {
TestEntry {
type_id,
attr1,
attr3,
payload,
}
}
#[derive(Clone, Copy)]
struct TestEntry<'a> {
type_id: u32,
attr1: u32,
attr3: u32,
payload: &'a [u8],
}
fn build_nres(entries: &[TestEntry<'_>]) -> Vec<u8> {
let mut out = vec![0; 16];
let mut offsets = Vec::with_capacity(entries.len());
for entry in entries {
offsets.push(u32::try_from(out.len()).expect("offset"));
out.extend_from_slice(entry.payload);
let padding = (8 - (out.len() % 8)) % 8;
out.resize(out.len() + padding, 0);
}
let order: Vec<usize> = (0..entries.len()).collect();
for (idx, entry) in entries.iter().enumerate() {
push_u32(&mut out, entry.type_id);
push_u32(&mut out, entry.attr1);
push_u32(&mut out, 0);
push_u32(
&mut out,
u32::try_from(entry.payload.len()).expect("payload"),
);
push_u32(&mut out, entry.attr3);
let mut name_raw = [0; 36];
let name = format!("Res{}", entry.type_id);
copy_cstr(&mut name_raw, name.as_bytes());
out.extend_from_slice(&name_raw);
push_u32(&mut out, offsets[idx]);
push_u32(&mut out, u32::try_from(order[idx]).expect("sort index"));
}
out[0..4].copy_from_slice(b"NRes");
out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
out[8..12].copy_from_slice(&u32::try_from(entries.len()).expect("count").to_le_bytes());
let total_size = u32::try_from(out.len()).expect("total size");
out[12..16].copy_from_slice(&total_size.to_le_bytes());
out
}
fn copy_cstr(dst: &mut [u8], src: &[u8]) {
let len = dst.len().saturating_sub(1).min(src.len());
dst[..len].copy_from_slice(&src[..len]);
}
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_u16(out: &mut Vec<u8>, value: u16) {
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 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
}
}