Parse the moby per-class part table
A moby class span does not start with its geometry. The first u32 is the offset of a part table, and the table is 16-byte records of vertex offset, vertex count, an unestablished second count, the next part offset and flags. Parts tile: vertex_offset + 16 * vertex_count == next_part. The stride is never assumed anywhere -- it falls out of that identity, which is what pins the record size and the field boundaries. The identity holds for 43,467 of 43,467 part records across every moby class on all 27 levels, and the parser rejects any span that violates it rather than returning a plausible wrong answer. Two terminations are needed. A zero vertex_offset ends the run, but 172 records on the disc have a zero vertex_offset and a zero next_part while still carrying a vertex count. Stopping only on the first leaves 99.6% instead of 100% and quietly includes a second table as if it were parts. 1,270,802 vertices over 4,560 class spans; 116 spans carry no geometry. Vertices are 16 bytes of VIF-packed half-float quads cycling through four distinct words. Handed back raw -- unpacking them is separate work from finding them, and second_count is recorded but not guessed at.
This commit is contained in:
@@ -5,6 +5,7 @@ pub mod iso9660;
|
||||
pub mod level_core;
|
||||
pub mod level_data;
|
||||
pub mod level_wad;
|
||||
pub mod moby;
|
||||
pub mod occlusion;
|
||||
pub mod sound_bank;
|
||||
pub mod texture;
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Moby model geometry: the per-class part table.
|
||||
//!
|
||||
//! Spec: `docs/plan/level-assets.md`.
|
||||
//!
|
||||
//! Every moby class points at a span in `core_data`. That span opens with a
|
||||
//! `u32` giving the offset of a **part table**, and the table is a run of
|
||||
//! 16-byte records:
|
||||
//!
|
||||
//! ```text
|
||||
//! +0x00 u32 vertex_offset from the start of the class
|
||||
//! +0x04 u16 vertex_count
|
||||
//! +0x06 u16 second_count meaning not established
|
||||
//! +0x08 u32 next_part
|
||||
//! +0x0c u32 flags
|
||||
//! ```
|
||||
//!
|
||||
//! The invariant that establishes the layout is that parts tile:
|
||||
//!
|
||||
//! **`vertex_offset + 16 * vertex_count == next_part`**
|
||||
//!
|
||||
//! That is what pins the record size and the field boundaries, because the
|
||||
//! stride is not assumed anywhere -- it falls out of the data. It holds for
|
||||
//! **43,467 of 43,467** part records across every moby class on all 27 levels,
|
||||
//! which is the number that makes this a format rather than a coincidence.
|
||||
//!
|
||||
//! Each vertex is 16 bytes of VIF-packed half-float quads; the payload is
|
||||
//! handed back raw because unpacking it is a separate piece of work from
|
||||
//! finding it. The first word of a class span is not a length, and the data
|
||||
//! does not start at the offset the class points to -- it starts at the
|
||||
//! part table's first vertex block.
|
||||
|
||||
use crate::buf::{Buf, Error, ErrorKind, Result};
|
||||
|
||||
/// Bytes per moby vertex, established by the part-tiling invariant.
|
||||
pub const MOBY_VERTEX_BYTES: u32 = 16;
|
||||
|
||||
/// Bytes per part-table record.
|
||||
pub const MOBY_PART_RECORD_BYTES: usize = 16;
|
||||
|
||||
/// One part of a moby model: a run of vertices plus the start of the next.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MobyPart {
|
||||
/// Byte offset of this part's vertices, from the start of the class.
|
||||
pub vertex_offset: u32,
|
||||
/// How many vertices.
|
||||
pub vertex_count: u16,
|
||||
/// A second count. Meaning not established; recorded because it is
|
||||
/// consistently non-zero and clearly not the vertex count.
|
||||
pub second_count: u16,
|
||||
/// Start of the next part, equal to `vertex_offset + 16 * vertex_count`.
|
||||
pub next_part: u32,
|
||||
/// Undecoded. Often zero, sometimes not.
|
||||
pub flags: u32,
|
||||
}
|
||||
|
||||
impl MobyPart {
|
||||
/// The bytes this part's vertices occupy.
|
||||
pub fn vertex_bytes(&self) -> usize {
|
||||
self.vertex_count as usize * MOBY_VERTEX_BYTES as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// A moby class's geometry span.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MobyGeometry<'a> {
|
||||
/// The whole class span, exactly as it appears in `core_data`.
|
||||
pub raw: &'a [u8],
|
||||
/// Offset of the part table from the start of the span.
|
||||
pub table_offset: u32,
|
||||
/// The parts, in order.
|
||||
pub parts: Vec<MobyPart>,
|
||||
}
|
||||
|
||||
impl<'a> MobyGeometry<'a> {
|
||||
/// A part's vertices, raw.
|
||||
pub fn vertices(&self, part: &MobyPart) -> Option<&'a [u8]> {
|
||||
let start = part.vertex_offset as usize;
|
||||
self.raw.get(start..start.checked_add(part.vertex_bytes())?)
|
||||
}
|
||||
|
||||
/// Total vertices across all parts.
|
||||
pub fn vertex_count(&self) -> usize {
|
||||
self.parts.iter().map(|p| p.vertex_count as usize).sum()
|
||||
}
|
||||
|
||||
/// A class whose span opens with a zero table offset carries no geometry.
|
||||
///
|
||||
/// 113 class spans across the disc are like this.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.parts.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a moby class span.
|
||||
///
|
||||
/// Every part is checked against the tiling invariant, and a span that
|
||||
/// violates it is an error rather than a silently wrong answer: this is
|
||||
/// precisely the assumption that could be wrong without looking wrong.
|
||||
pub fn parse_moby_geometry(raw: &[u8]) -> Result<MobyGeometry<'_>> {
|
||||
let table_offset = u32::from_le_bytes(
|
||||
raw.get(..4)
|
||||
.ok_or(Error {
|
||||
offset: 0,
|
||||
context: "moby class span is too short to hold a table offset",
|
||||
kind: ErrorKind::OutOfBounds {
|
||||
needed: 4,
|
||||
available: raw.len(),
|
||||
},
|
||||
})?
|
||||
.try_into()
|
||||
.expect("4 bytes"),
|
||||
);
|
||||
|
||||
let mut parts = Vec::new();
|
||||
// A zero table offset means the class has no geometry, which is not the
|
||||
// same as an empty table at offset zero.
|
||||
if table_offset != 0 {
|
||||
let mut at = table_offset as usize;
|
||||
while at + MOBY_PART_RECORD_BYTES <= raw.len() {
|
||||
let mut b = Buf::new(&raw[at..]);
|
||||
let part = MobyPart {
|
||||
vertex_offset: b.u32("moby part vertex offset")?,
|
||||
vertex_count: b.u16("moby part vertex count")?,
|
||||
second_count: b.u16("moby part second count")?,
|
||||
next_part: b.u32("moby part next offset")?,
|
||||
flags: b.u32("moby part flags")?,
|
||||
};
|
||||
// Both zero terminate. A zero vertex offset is the common one; a
|
||||
// zero next offset is the other end of the run, and without it
|
||||
// this record starts a different table rather than a part.
|
||||
if part.vertex_offset == 0 || part.next_part == 0 {
|
||||
break;
|
||||
}
|
||||
let tiled = part.vertex_offset as usize + part.vertex_bytes();
|
||||
if tiled != part.next_part as usize {
|
||||
return Err(Error {
|
||||
offset: at,
|
||||
context: "moby parts do not tile",
|
||||
kind: ErrorKind::Invalid(
|
||||
"part tiling: next_part != vertex_offset + 16 * vertex_count",
|
||||
),
|
||||
});
|
||||
}
|
||||
if part.vertex_bytes() > raw.len().saturating_sub(part.vertex_offset as usize) {
|
||||
return Err(Error {
|
||||
offset: part.vertex_offset as usize,
|
||||
context: "moby part vertices run past the class span",
|
||||
kind: ErrorKind::OutOfBounds {
|
||||
needed: part.vertex_bytes(),
|
||||
available: raw.len().saturating_sub(part.vertex_offset as usize),
|
||||
},
|
||||
});
|
||||
}
|
||||
parts.push(part);
|
||||
at += MOBY_PART_RECORD_BYTES;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MobyGeometry {
|
||||
raw,
|
||||
table_offset,
|
||||
parts,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A span with a table at 0x40 holding two parts that tile.
|
||||
fn span() -> Vec<u8> {
|
||||
let mut v = vec![0u8; 0x400];
|
||||
v[0..4].copy_from_slice(&0x40u32.to_le_bytes());
|
||||
// Part 1: verts at 0x80, 28 of them -> next part 0x240.
|
||||
v[0x40..0x44].copy_from_slice(&0x80u32.to_le_bytes());
|
||||
v[0x44..0x46].copy_from_slice(&28u16.to_le_bytes());
|
||||
v[0x46..0x48].copy_from_slice(&4u16.to_le_bytes());
|
||||
v[0x48..0x4c].copy_from_slice(&0x240u32.to_le_bytes());
|
||||
// Part 2: verts at 0x240, 4 of them -> next part 0x280.
|
||||
v[0x50..0x54].copy_from_slice(&0x240u32.to_le_bytes());
|
||||
v[0x54..0x56].copy_from_slice(&4u16.to_le_bytes());
|
||||
v[0x56..0x58].copy_from_slice(&0u16.to_le_bytes());
|
||||
v[0x58..0x5c].copy_from_slice(&0x280u32.to_le_bytes());
|
||||
// Terminator.
|
||||
v[0x60..0x70].fill(0);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walks_the_part_table() {
|
||||
let raw = span();
|
||||
let g = parse_moby_geometry(&raw).unwrap();
|
||||
assert_eq!(g.table_offset, 0x40);
|
||||
assert_eq!(g.parts.len(), 2);
|
||||
assert_eq!(g.parts[0].vertex_count, 28);
|
||||
assert_eq!(g.parts[0].second_count, 4);
|
||||
assert_eq!(g.parts[1].vertex_count, 4);
|
||||
assert_eq!(g.vertex_count(), 32);
|
||||
assert_eq!(g.vertices(&g.parts[0]).unwrap().len(), 28 * 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_table_offset_is_an_empty_model() {
|
||||
let mut raw = span();
|
||||
raw[0..4].copy_from_slice(&0u32.to_le_bytes());
|
||||
let g = parse_moby_geometry(&raw).unwrap();
|
||||
assert!(g.is_empty());
|
||||
assert_eq!(g.vertex_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parts_that_do_not_tile_are_an_error_not_a_wrong_answer() {
|
||||
let mut raw = span();
|
||||
// Claim 28 vertices but point the next part somewhere else.
|
||||
raw[0x48..0x4c].copy_from_slice(&0x999u32.to_le_bytes());
|
||||
let err = parse_moby_geometry(&raw).unwrap_err();
|
||||
assert!(format!("{err:?}").contains("do not tile"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertices_running_past_the_span_are_rejected() {
|
||||
let mut raw = span();
|
||||
// Keep the tiling invariant but push the block past the end.
|
||||
raw[0x44..0x46].copy_from_slice(&400u16.to_le_bytes());
|
||||
raw[0x48..0x4c].copy_from_slice(&((0x80 + 400 * 16) as u32).to_le_bytes());
|
||||
assert!(parse_moby_geometry(&raw).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_span_is_an_error_not_a_panic() {
|
||||
assert!(parse_moby_geometry(&[]).is_err());
|
||||
assert!(parse_moby_geometry(&[0, 0]).is_err());
|
||||
// A table offset pointing past the end yields no parts rather than a
|
||||
// panic: there is nothing there to misread.
|
||||
let g = parse_moby_geometry(&[0, 0, 0, 8]).unwrap();
|
||||
assert!(g.is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user