aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-terrain-format/src/lib.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/fparkan-terrain-format/src/lib.rs')
-rw-r--r--crates/fparkan-terrain-format/src/lib.rs1910
1 files changed, 1910 insertions, 0 deletions
diff --git a/crates/fparkan-terrain-format/src/lib.rs b/crates/fparkan-terrain-format/src/lib.rs
new file mode 100644
index 0000000..8b97d79
--- /dev/null
+++ b/crates/fparkan-terrain-format/src/lib.rs
@@ -0,0 +1,1910 @@
+#![forbid(unsafe_code)]
+//! 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);
+
+/// 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],
+}
+
+/// 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 auxiliary words.
+ 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 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]
+ 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 Some(root) = corpus_root(corpus) else {
+ continue;
+ };
+ 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]
+ fn licensed_corpus_build_dat_validate() {
+ for (corpus, expected_ai_prefix) in [("IS", false), ("IS2", true)] {
+ let Some(root) = corpus_root(corpus) else {
+ continue;
+ };
+ 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]
+ 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 Some(root) = corpus_root(corpus) else {
+ continue;
+ };
+ 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) -> Option<PathBuf> {
+ let root = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("testdata")
+ .join(name);
+ root.is_dir().then_some(root)
+ }
+
+ fn files_under(root: &Path) -> Vec<PathBuf> {
+ let mut out = Vec::new();
+ let mut stack = vec![root.to_path_buf()];
+ while let Some(path) = stack.pop() {
+ let Ok(read_dir) = std::fs::read_dir(path) else {
+ continue;
+ };
+ for entry in read_dir.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ stack.push(path);
+ } else {
+ out.push(path);
+ }
+ }
+ }
+ out.sort();
+ out
+ }
+}