Files
Going-Commando/crates/gc-formats/src/shrub.rs
T
ReaperOfVeriod 5ade83cf0a Parse the shrub packet header, a fixed signature on all 2056 disc packets
The packets found by the +0x40 table have a header the whole disc agrees on.

Range measured: every packet on the disc -- 2056 of them, across the 334
successor-bounded spans -- read at each packet's span-relative offset.

  bytes 0-3   04 04 00 01   2056 / 2056
  bytes 4-7   zero           2056 / 2056
  byte  8     zero           2056 / 2056
  byte  11    0x05           2056 / 2056
  size % 16   zero           2056 / 2056

Zero exceptions on any of the five.

And the loader fills the rest in. Read back out of the running game the same
records carry byte 8 as a count (0x0b to 0x29 observed), byte 11 as one of four
tag values, and bytes 12-15 as an absolute EE RAM pointer where the disc has a
span-relative offset. Bytes 8-15 are rewritten wholesale on load.

That settles what the disc header is for. It carries no count of its own, and
the count the RAM copy has is the +0x40 table's count arriving by another
route. The disc header is a signature; the RAM header is populated state.

ShrubPacketHeader::parse and Shrub::packet_headers() expose this. parse returns
None unless all the invariants hold, rather than a default, so a packet that
does not match is reported as not-understood instead of half-interpreted.

The gated test asserts all four header invariants and the size alignment on
every packet of every bounded span.

This is the first piece of the mesh packet format represented in gc-formats;
everything known about the packet payloads so far is RAM measurement, since the
disc form of bytes 8-15 is rewritten at load.
2026-10-12 02:35:00 +02:00

847 lines
36 KiB
Rust

//! Shrub geometry: the class header, the normal palette, and the billboard.
//!
//! Spec: `docs/plan/level-assets.md`.
//!
//! Shrub is the third class-geometry family, and it is not the moby one. All
//! 358 shrub spans on the disc have a leading word of 0 or one too large to
//! index a table into, so none of them contain a moby-style part table -- the
//! rule `vertex_offset + 16 * vertex_count == next_part`, which holds for
//! 43,467 of 43,467 moby parts, holds for **zero** of them.
//!
//! The class blob is header, palette, optional billboard, in that order. The
//! reference for the shape is R&C1's shrub class (documented in
//! `vendor/rerac/docs/formats/shrub_sky_rac1.md`, whose offsets this header
//! follows: `o_class` at 0x24, a count at 0x28, the data offset at 0x2c), but
//! every field below is either **measured on all 358 spans** or marked as not
//! established. Where a field is only parallel to R&C1 and not independently
//! verified here, the doc comment says so.
//!
//! What self-identifies exactly, on all 358 spans:
//!
//! - `+0x24` equals the owning class entry's own `o_class`.
//! - `+0x2c` is a data offset that lands inside the span.
//! - `+0x1c` (billboard offset) is non-zero on exactly the 25 classes whose
//! core-index entry carries billboard texture fields, and zero on the other
//! 333 -- two independent places, one answer.
//!
//! The class entry size differs here too: shrub entries are 0x30 bytes, not
//! 0x20, because they carry billboard texture fields the other two tables do
//! not have.
use crate::buf::{Buf, Error, ErrorKind, Result};
/// The measured size of a shrub class entry.
pub const SHRUB_CLASS_ENTRY_BYTES: usize = 0x30;
/// Normals in the palette. Not a maximum: the body's zero padding follows the
/// twenty-fourth entry, so a body of 26, 28 or 30 records holds 24 normals and
/// 2, 4 or 6 zero records.
pub const SHRUB_NORMAL_COUNT: usize = 24;
/// One palette entry: a unit normal as three `s16`, then a zero `s16`.
///
/// The three components are a fraction of 32767 -- entry `i` of every body
/// satisfies `x^2 + y^2 + z^2` within rounding of `32767^2` on all 358 spans.
/// The fourth word is zero on all 9,600 records on the disc, which is what
/// makes the record 8 bytes rather than 6.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShrubNormal {
pub x: i16,
pub y: i16,
pub z: i16,
}
impl ShrubNormal {
/// Parse one 8-byte palette record.
pub fn parse(bytes: &[u8]) -> Result<ShrubNormal> {
let mut b = Buf::new(bytes);
let x = b.i16("shrub normal x")?;
let y = b.i16("shrub normal y")?;
let z = b.i16("shrub normal z")?;
let pad = b.i16("shrub normal pad")?;
if pad != 0 {
return Err(Error {
offset: 6,
context: "shrub normal pad",
kind: ErrorKind::Invalid("shrub normal pad is not zero"),
});
}
Ok(ShrubNormal { x, y, z })
}
/// Whether the entry is a unit normal, within the format's quantisation.
///
/// The palette stores a unit vector scaled by 32768, so an axis-aligned
/// normal quantises to `(32768, 0, 0)` -- stored as the `s16` `-32768`,
/// because the positive axis clamps instead. The squared length therefore
/// lands within `32768^2 - 32767^2 = 65535` of `32767^2`, and that bound
/// is tight: the largest deviation on the disc is exactly 65,535.
///
/// The margin is what makes this a test rather than a tautology. A random
/// `s16` triple has a squared length spread about a *billion* either side
/// of `32767^2`, so roughly one entry in fifteen thousand would pass by
/// chance; 8,592 of 8,592 real entries pass.
pub fn is_unit(&self) -> bool {
let n = (self.x as i64).pow(2) + (self.y as i64).pow(2) + (self.z as i64).pow(2);
(n - 32767i64.pow(2)).abs() <= 65_535
}
/// The normal as a unit vector in `[0, 1]`-ish float terms.
pub fn unit(self) -> [f32; 3] {
[
self.x as f32 / 32767.0,
self.y as f32 / 32767.0,
self.z as f32 / 32767.0,
]
}
}
/// The decoded part of a shrub class header.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ShrubHeader {
/// Four floats at `+0x00..+0x10`: a bounding sphere read as
/// centre (x, y, z) and radius (w), following R&C1's header at the same
/// offsets. What is measured here rather than assumed: all four are
/// finite, the radius is positive on all 358 spans (1.72..41.57), and the
/// centres sit near the class's own origin (-18..15, -23..23, -16..20).
/// The earlier reading of this field as a `min.xyz / max.xyz` box was
/// wrong -- that ordering holds on only 146 of 358 spans -- because it was
/// the wrong shape, not because the bytes were.
pub sphere: [f32; 4],
/// A positive float at `+0x10`, 3.0 to 20.0 across the disc. R&C1's
/// parallel at this offset is `mip_distance`; not independently
/// established.
pub size: f32,
/// A `u16` at `+0x14`: zero on 342 spans, 4 on the other 16. R&C1's
/// `mode_bits` is at the same offset. Not established.
pub mode_bits: u16,
/// A `u16` at `+0x16`, zero on all 358 spans. R&C1's field here is
/// `instance_count`, runtime scratch that retail files leave zero.
///
/// On the 16 spans where `mode_bits` is 4 the whole word at `+0x14` is an
/// `f32` denormal, so the Emotion Engine -- which has no denormals -- reads
/// zero for the pair either way. That is the sharpest available statement
/// of what the hardware does with this field.
pub scratch: u16,
/// A `u32` at `+0x18`, zero on all 358 spans. R&C1's `instances_pointer`
/// is runtime scratch at the same offset.
pub reserved: u32,
/// Offset of the [`ShrubBillboard`] record from the start of the span.
/// Zero means no billboard; a positive value lands inside the span on all
/// 25 spans that have one.
///
/// This is the strongest self-identification in the header after
/// `o_class`: it is non-zero on exactly the classes whose core-index
/// entry carries billboard texture fields (25 of 358 each way), and the
/// two answers are produced by different bytes in different files.
pub billboard_offset: u32,
/// A positive `f32` at `+0x20` (0.0044..1.8 on the disc). R&C1's
/// `scale` is the position dequantisation factor at the same offset.
/// Not independently established.
pub scale: f32,
/// The class this geometry belongs to; equals the class entry's
/// `o_class` on all 358 spans on the disc.
pub o_class: u32,
/// A count. It is the **packet count** -- the length of the table at
/// `+0x40` -- which the packet tiling proves (see [`Shrub::packets`]).
/// Ranges 1..55 on the disc; unrelated to the palette's length.
pub count: u32,
/// Offset of the geometry data from the start of the span. Lands inside
/// the span on all 358 spans on the disc.
pub data_offset: u32,
}
/// The billboard record at `billboard_offset`, 0x40 bytes.
///
/// The register block matches R&C1's layout field for field -- TEX1 at `+0x10`
/// with `K` and `MMIN`, TEX0 at `+0x20`, MIPTBP1_1 at `+0x30`, each carrying
/// its own register address word (0x14, 0x06, 0x34) -- and the three floats
/// in front of them are the one part that does not carry over: they are far
/// too large and too varied to be R&C1's `width`/`height`/`z_ofs`, and they
/// repeat byte-identically across levels, which dimensions would not. They
/// are handed back unnamed rather than guessed at.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ShrubBillboard {
/// Fade distance at `+0x00` (5..1000 on the disc). Following R&C1, the
/// loader takes `trunc(fade) & 0xff`, which makes 512 read as 0 --
/// billboard only at any distance.
pub fade_distance: f32,
/// Three floats at `+0x04..+0x10`. Meaning not established.
pub unknown: [f32; 3],
/// `TEX1_1` at `+0x10`: the signed LOD bias K in 1/16 units, retail
/// -233..-168 on the disc.
pub k: i16,
/// `TEX1_1` minification filter, 4 on every disc billboard.
pub mmin: u32,
/// `TEX0_1.data_lo` at `+0x20`: a small index (1..4 on the disc). In R&C1
/// this is an index into the class's texture list.
pub tex0: u32,
/// The GIF register address words carried behind the three registers,
/// 0x14 / 0x06 / 0x34 on every disc billboard. Exposed rather than
/// asserted on: they corroborate the layout, but a billboard that carries
/// a different register set is still a billboard.
pub tex1_addr: u32,
/// See [`ShrubBillboard::tex1_addr`].
pub tex0_addr: u32,
/// See [`ShrubBillboard::tex1_addr`].
pub miptbp_addr: u32,
}
/// A shrub span: its header, palette and optional billboard.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Shrub<'a> {
pub header: ShrubHeader,
/// The whole span, starting at `+0x24` with the class number.
pub raw: &'a [u8],
/// The bytes at `data_offset`, running to `span_end`. This is the palette
/// plus its zero padding.
pub data: &'a [u8],
/// Whether `span_end` came from the next class or the located chunk
/// container rather than from the end of the lump. False only for a span
/// bounded by a caller that could not locate either.
pub span_end_is_a_successor: bool,
}
/// The 16-byte header at the front of a mesh packet.
///
/// Measured on **every packet on the disc** -- 2,056 of them, across the 334
/// successor-bounded spans -- the header is almost entirely constant:
///
/// | bytes | value | packets |
/// |---|---|---|
/// | 0-3 | `04 04 00 01` | 2056 / 2056 |
/// | 4-7 | zero | 2056 / 2056 |
/// | 8 | zero | 2056 / 2056 |
/// | 11 | `0x05` | 2056 / 2056 |
///
/// and every packet's size is a multiple of 16 (2056 / 2056).
///
/// **What the loader does to it.** Read back out of the running game's memory,
/// the same records have byte 8 holding a **count** (0x0b to 0x29 observed),
/// byte 11 holding one of four tag values, and bytes 12-15 holding an absolute
/// EE RAM pointer where the disc has a span-relative offset. So bytes 8-15 are
/// rewritten wholesale on load, and the count this header does *not* carry on
/// disc is the one the `+0x40` table already supplies.
///
/// That is why the disc header is worth asserting and the RAM one is not: the
/// disc form is a fixed signature, the RAM form is populated state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShrubPacketHeader {
/// The constant leading magic, `04 04 00 01`.
pub magic: [u8; 4],
/// The tag byte at 11. `0x05` on every packet on the disc; rewritten on
/// load to one of `0x00`, `0x20`, `0x30`, `0xbc`.
pub tag: u8,
}
/// The magic every mesh packet opens with, on every span on the disc.
pub const SHRUB_PACKET_MAGIC: [u8; 4] = [0x04, 0x04, 0x00, 0x01];
/// The tag byte every mesh packet carries on the disc, before the loader
/// replaces it.
pub const SHRUB_PACKET_TAG: u8 = 0x05;
impl ShrubPacketHeader {
/// Parse a packet header, bounds-checked against `bytes`.
///
/// Returns `None` unless the magic matches, bytes 4-8 are zero and the tag
/// is [`SHRUB_PACKET_TAG`] -- the three invariants that hold on 2,056 of
/// 2,056 packets. Rejecting anything else is deliberate: a packet that does
/// not match is not one this parser understands, and passing it back
/// half-understood is how a wrong reading gets treated as a finding.
pub fn parse(bytes: &[u8]) -> Option<ShrubPacketHeader> {
if bytes.len() < 16 || bytes[..4] != SHRUB_PACKET_MAGIC {
return None;
}
if bytes[4..9].iter().any(|&b| b != 0) {
return None;
}
if bytes[11] != SHRUB_PACKET_TAG {
return None;
}
Some(ShrubPacketHeader {
magic: SHRUB_PACKET_MAGIC,
tag: bytes[11],
})
}
}
/// One mesh packet: where it sits in the span and how long it is.
///
/// The offsets are relative to the start of the span, like every other
/// pointer in a class blob.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShrubPacket {
pub offset: usize,
pub size: usize,
}
impl<'a> Shrub<'a> {
/// The palette's 8-byte records, raw.
pub fn body_records(&self) -> impl Iterator<Item = &'a [u8]> {
self.data.chunks_exact(8)
}
/// How many whole records the body holds.
pub fn body_record_count(&self) -> usize {
self.data.len() / 8
}
/// The mesh packets, as the packet table at `+0x40` names them.
///
/// The table is `count` entries of `(offset, size)`, both relative to the
/// span start. What makes it a table rather than a guess is that the
/// entries **tile the blob**: the first packet starts at
/// `align16(0x40 + count * 8)` -- the table, rounded up to 16 -- every
/// entry is exactly `size` past the previous one, and the last ends at the
/// palette, or exactly 0x40 short of it on the spans that carry a
/// billboard record in between. That holds on **358 of 358** spans on the
/// disc, and it is what pins the field meanings: `count` is the packet
/// count, and `data_offset` is the palette's offset.
///
/// Returns `None` when the entries do not tile, rather than handing back
/// a partial list: a table that stops making sense means this is not
/// laid out the way this thinks it is.
pub fn packets(&self) -> Option<Vec<ShrubPacket>> {
let count = self.header.count as usize;
// A count that cannot be read out of the span is not a count.
if self.raw.len() < 0x40 + count.saturating_mul(8) {
return None;
}
let mut out = Vec::with_capacity(count);
let mut cursor = (0x40 + count.saturating_mul(8) + 0xf) & !0xf;
for i in 0..count {
let at = 0x40 + i * 8;
let entry = self.raw.get(at..at + 8)?;
let offset = u32::from_le_bytes(entry[..4].try_into().ok()?) as usize;
let size = u32::from_le_bytes(entry[4..].try_into().ok()?) as usize;
if offset != cursor {
return None;
}
cursor += size;
out.push(ShrubPacket { offset, size });
}
// The packets end where the palette begins, leaving room for the
// billboard record when the class has one.
let palette = self.header.data_offset as usize;
let expected_end = if self.header.billboard_offset > 0 {
palette.checked_sub(0x40)?
} else {
palette
};
(cursor == expected_end).then_some(out)
}
/// The mesh packets' headers, parsed.
///
/// Yields `None` for any packet whose header does not match the disc
/// invariants, so a caller can tell a bad packet from a good one rather
/// than receiving a default.
pub fn packet_headers(&self) -> impl Iterator<Item = Option<ShrubPacketHeader>> + 'a {
self.packets()
.unwrap_or_default()
.into_iter()
.map(move |p| {
self.raw
.get(p.offset..p.offset + 16)
.and_then(ShrubPacketHeader::parse)
})
.collect::<Vec<_>>()
.into_iter()
}
/// The palette: the first [`SHRUB_NORMAL_COUNT`] records, parsed.
///
/// Returns fewer only if the body is shorter than the palette, which does
/// not happen on the disc -- every one of the 358 spans carries all 24.
pub fn normals(&self) -> impl Iterator<Item = ShrubNormal> + 'a {
self.data
.chunks_exact(8)
.take(SHRUB_NORMAL_COUNT)
.filter_map(move |r| ShrubNormal::parse(r).ok())
}
/// Records past the palette. Zero on every disc span: the palette is
/// padded out with zero records rather than with live normals.
pub fn padding_record_count(&self) -> usize {
self.body_record_count().saturating_sub(SHRUB_NORMAL_COUNT)
}
/// Whether every whole record's trailing pair is zero.
///
/// The palette's pad word and the padding records are both zero, so a
/// body that satisfies this is whole records; it is how a class's body is
/// delimited, and it holds on all 358 disc spans now that each is bounded
/// by its successor.
pub fn body_is_well_formed(&self) -> bool {
let records: Vec<&[u8]> = self.body_records().collect();
!records.is_empty() && records.iter().all(|r| r[6..8] == [0, 0])
}
/// The billboard record, when the header names one.
///
/// The offset is checked against `span_end` by
/// [`ShrubHeader::region`] rather than trusted: a non-zero offset that
/// runs past the span is a mis-set field, not a billboard.
pub fn billboard(&self) -> Result<Option<ShrubBillboard>> {
if self.header.billboard_offset == 0 {
return Ok(None);
}
let at = self.header.billboard_offset as usize;
// `raw` runs from the span start, so the record sits at raw[at..].
let bytes = self.raw.get(at..at + 0x40).ok_or(Error {
offset: at,
context: "shrub billboard",
kind: ErrorKind::OutOfBounds {
needed: 0x40,
available: self.raw.len().saturating_sub(at),
},
})?;
let mut b = Buf::new(bytes);
let fade_distance = b.f32("shrub billboard fade")?;
let unknown = [
b.f32("shrub billboard unknown 0")?,
b.f32("shrub billboard unknown 1")?,
b.f32("shrub billboard unknown 2")?,
];
// TEX1_1: K, a zero high half, then MMIN, then the register address
// word and a zero pad -- a 16-byte GifAdData16 per register.
let k = b.i16("shrub billboard K")?;
b.skip(2, "shrub billboard K high")?;
let mmin = b.u32("shrub billboard mmin")?;
let tex1_addr = b.u32("shrub billboard tex1 address")?;
b.skip(4, "shrub billboard tex1 pad")?;
let tex0 = b.u32("shrub billboard tex0 index")?;
b.skip(4, "shrub billboard tex0 data hi")?;
let tex0_addr = b.u32("shrub billboard tex0 address")?;
// MIPTBP1_1: two zero data words, then the register address word and a
// pad. The mip base pointers are filled in at level load, as they are
// for every ad-gif in this format.
b.skip(4, "shrub billboard tex0 pad")?;
b.skip(8, "shrub billboard miptbp data")?;
let miptbp_addr = b.u32("shrub billboard miptbp address")?;
Ok(Some(ShrubBillboard {
fade_distance,
unknown,
k,
mmin,
tex0,
tex1_addr,
tex0_addr,
miptbp_addr,
}))
}
}
impl ShrubHeader {
/// Parse a shrub span starting at `offset` within `core`.
///
/// `o_class` is passed in so the self-identifying field can be checked
/// rather than merely reported: a span whose `+0x24` disagrees with its
/// own class entry is a different thing entirely, and quietly returning
/// the mismatched value would hide that.
///
/// `span_end` bounds the class span within `core`. It matters: without it
/// `data` runs to the end of the lump and swallows whatever follows, which
/// is how a per-class check ends up measuring its neighbours.
pub fn region<'a>(
core: &'a [u8],
offset: i32,
o_class: i32,
span_end: usize,
) -> Result<Option<Shrub<'a>>> {
if offset <= 0 {
return Ok(None);
}
let start = offset as usize;
let head = core.get(start..).filter(|s| s.len() >= 0x30).ok_or(Error {
offset: start,
context: "shrub span is too short to hold a header",
kind: ErrorKind::OutOfBounds {
needed: 0x30,
available: core.len().saturating_sub(start),
},
})?;
let mut fb = Buf::new(&head[..16]);
let sphere = [
fb.f32("shrub sphere x")?,
fb.f32("shrub sphere y")?,
fb.f32("shrub sphere z")?,
fb.f32("shrub sphere radius")?,
];
let mut fs = Buf::new(&head[0x10..0x18]);
let size = fs.f32("shrub size")?;
let mode_bits = fs.u16("shrub mode bits")?;
let scratch = fs.u16("shrub scratch")?;
if !sphere.iter().all(|v| v.is_finite()) || !size.is_finite() {
return Err(Error {
offset: start,
context: "shrub header holds a non-finite float",
kind: ErrorKind::Invalid("shrub header float"),
});
}
// A radius is a radius: non-positive means the header is not laid out
// the way this thinks it is.
if sphere[3] <= 0.0 {
return Err(Error {
offset: start + 0x0c,
context: "shrub sphere radius",
kind: ErrorKind::Invalid("shrub sphere radius is not positive"),
});
}
// One invariant across all 358 spans: the scratch word at +0x16 is
// zero everywhere. A non-zero value means this is not a shrub header.
// `mode_bits` at +0x14 is *not* asserted -- it is 4 on 16 of the 358
// spans -- so the field is reported rather than validated.
if scratch != 0 {
return Err(Error {
offset: start + 0x16,
context: "shrub scratch",
kind: ErrorKind::Invalid("shrub scratch is not zero"),
});
}
let reserved = u32::from_le_bytes(head[0x18..0x1c].try_into().expect("4 bytes at +0x18"));
if reserved != 0 {
return Err(Error {
offset: start + 0x18,
context: "shrub reserved word",
kind: ErrorKind::Invalid("shrub reserved word is not zero"),
});
}
let mut b = Buf::new(&head[0x1c..]);
let billboard_offset = b.u32("shrub billboard offset")?;
let scale = b.f32("shrub scale")?;
if !scale.is_finite() || scale <= 0.0 {
return Err(Error {
offset: start + 0x20,
context: "shrub scale",
kind: ErrorKind::Invalid("shrub scale is not positive"),
});
}
let mut bc = Buf::new(&head[0x24..]);
let span_class = bc.u32("shrub o_class")?;
let header = ShrubHeader {
sphere: [0.0; 4],
size: 0.0,
mode_bits: 0,
scratch,
reserved,
billboard_offset,
scale,
o_class: span_class,
count: bc.u32("shrub count")?,
data_offset: bc.u32("shrub data offset")?,
};
if header.o_class != o_class.max(0) as u32 {
return Err(Error {
offset: start + 0x24,
context: "shrub span does not name its own class",
kind: ErrorKind::Invalid("shrub o_class disagrees with the class entry"),
});
}
// A billboard that is not inside the span is a mis-set offset.
let stop = span_end.clamp(start, core.len());
if billboard_offset > 0 && start + billboard_offset as usize + 0x40 > stop {
return Err(Error {
offset: start + 0x1c,
context: "shrub billboard offset",
kind: ErrorKind::Invalid("shrub billboard offset runs past the span"),
});
}
let at = header.data_offset as usize;
let data = core.get(start + at..stop).ok_or(Error {
offset: start + at,
context: "shrub data offset runs past the lump",
kind: ErrorKind::OutOfBounds {
needed: at,
available: core.len().saturating_sub(start),
},
})?;
Ok(Some(Shrub {
span_end_is_a_successor: true,
header: ShrubHeader {
sphere,
size,
mode_bits,
scratch,
reserved,
billboard_offset,
scale,
o_class: header.o_class,
count: header.count,
data_offset: header.data_offset,
},
raw: &core[start..],
data,
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Where the span sits in the fake lump. Offset 0 means "absent", so the
/// span cannot start there.
const BASE: usize = 0x10;
/// A unit direction per palette slot: a spiral over the sphere, so every
/// entry is a different unit vector rather than a repeated one.
fn fixture_normal(i: usize) -> [f32; 3] {
let t = i as f32 / SHRUB_NORMAL_COUNT as f32 * std::f32::consts::TAU;
let z = (i % 5) as f32 / 2.0 - 1.0;
let r = (1.0 - z * z).max(0.0).sqrt();
[r * t.cos(), r * t.sin(), z]
}
/// A span naming class 1072, data at 0x30, a positive radius and scale,
/// and a 24-entry palette of unit normals. `tail` extra bytes follow the
/// palette inside the span: zero ones read as padding records.
fn span_with_tail(tail: usize) -> Vec<u8> {
let mut v = vec![0u8; BASE + 0x30 + SHRUB_NORMAL_COUNT * 8 + tail];
v[BASE + 0x0c..BASE + 0x10].copy_from_slice(&12.5f32.to_le_bytes());
v[BASE + 0x10..BASE + 0x14].copy_from_slice(&6.0f32.to_le_bytes());
v[BASE + 0x20..BASE + 0x24].copy_from_slice(&0.25f32.to_le_bytes());
v[BASE + 0x24..BASE + 0x28].copy_from_slice(&1072u32.to_le_bytes());
v[BASE + 0x28..BASE + 0x2c].copy_from_slice(&5u32.to_le_bytes());
v[BASE + 0x2c..BASE + 0x30].copy_from_slice(&0x30u32.to_le_bytes());
for (i, n) in (0..SHRUB_NORMAL_COUNT).map(fixture_normal).enumerate() {
let at = BASE + 0x30 + i * 8;
for (c, &v_c) in n.iter().enumerate() {
let q = (v_c * 32767.0).round() as i16;
v[at + c * 2..at + c * 2 + 2].copy_from_slice(&q.to_le_bytes());
}
}
v
}
/// A span with two zero padding records after the palette.
fn span() -> Vec<u8> {
span_with_tail(16)
}
#[test]
fn reads_the_self_identifying_header() {
let c = span();
let s = ShrubHeader::region(&c, BASE as i32, 1072, c.len())
.unwrap()
.expect("present");
assert_eq!(s.header.o_class, 1072);
assert_eq!(s.header.count, 5);
assert_eq!(s.header.data_offset, 0x30);
assert_eq!(s.header.billboard_offset, 0);
assert_eq!(s.header.scale, 0.25);
assert_eq!(s.header.sphere[3], 12.5);
assert!(s.billboard().unwrap().is_none());
}
#[test]
fn the_body_is_24_unit_normals_plus_zero_padding() {
let c = span();
let s = ShrubHeader::region(&c, BASE as i32, 1072, c.len())
.unwrap()
.expect("present");
let normals: Vec<ShrubNormal> = s.normals().collect();
assert_eq!(normals.len(), SHRUB_NORMAL_COUNT);
assert!(normals.iter().all(|n| n.is_unit()), "palette is not unit");
// The fixture's two trailing zero records are padding, not normals.
assert_eq!(s.padding_record_count(), 2);
assert_eq!(s.body_record_count(), SHRUB_NORMAL_COUNT + 2);
assert!(s.body_is_well_formed());
// The palette repeats no direction, and the padding is not in it.
assert!(!s.normals().any(|n| n == ShrubNormal { x: 0, y: 0, z: 0 }));
}
#[test]
fn a_body_of_zeros_is_not_a_palette_of_normals() {
let mut v = vec![0u8; BASE + 0x40];
v[BASE + 0x0c..BASE + 0x10].copy_from_slice(&12.5f32.to_le_bytes());
v[BASE + 0x10..BASE + 0x14].copy_from_slice(&6.0f32.to_le_bytes());
v[BASE + 0x20..BASE + 0x24].copy_from_slice(&0.25f32.to_le_bytes());
v[BASE + 0x24..BASE + 0x28].copy_from_slice(&1072u32.to_le_bytes());
v[BASE + 0x28..BASE + 0x2c].copy_from_slice(&5u32.to_le_bytes());
v[BASE + 0x2c..BASE + 0x30].copy_from_slice(&0x30u32.to_le_bytes());
let s = ShrubHeader::region(&v, BASE as i32, 1072, v.len())
.unwrap()
.expect("present");
// Every record is whole and zero-tailed, but no entry is a normal.
assert!(s.body_is_well_formed());
assert!(!s.normals().any(|n| n.is_unit()));
}
/// A span laid out the way a disc blob is: header, packet table, packets,
/// then the palette at the end.
fn span_with_packets(palette_at: usize) -> Vec<u8> {
let mut v = vec![0u8; BASE + palette_at + SHRUB_NORMAL_COUNT * 8];
v[BASE + 0x0c..BASE + 0x10].copy_from_slice(&12.5f32.to_le_bytes());
v[BASE + 0x10..BASE + 0x14].copy_from_slice(&6.0f32.to_le_bytes());
v[BASE + 0x20..BASE + 0x24].copy_from_slice(&0.25f32.to_le_bytes());
v[BASE + 0x24..BASE + 0x28].copy_from_slice(&1072u32.to_le_bytes());
v[BASE + 0x2c..BASE + 0x30].copy_from_slice(&(palette_at as u32).to_le_bytes());
for (i, n) in (0..SHRUB_NORMAL_COUNT).map(fixture_normal).enumerate() {
let at = BASE + palette_at + i * 8;
for (c, v_c) in n.iter().enumerate() {
let q = (v_c * 32767.0).round() as i16;
v[at + c * 2..at + c * 2 + 2].copy_from_slice(&q.to_le_bytes());
}
}
v
}
#[test]
fn the_packets_tile_the_blob() {
// Three packets tiling from align16(0x40 + 3*8) = 0x60 to the palette.
let palette_at = 0x300usize;
let mut c = span_with_packets(palette_at);
c[BASE + 0x28..BASE + 0x2c].copy_from_slice(&3u32.to_le_bytes());
let entries = [
(0x60usize, 0x40usize),
(0xa0, 0x30),
(0xd0, palette_at - 0xd0),
];
for (i, (o, sz)) in entries.iter().enumerate() {
let at = BASE + 0x40 + i * 8;
c[at..at + 4].copy_from_slice(&(*o as u32).to_le_bytes());
c[at + 4..at + 8].copy_from_slice(&(*sz as u32).to_le_bytes());
}
let s = ShrubHeader::region(&c, BASE as i32, 1072, c.len())
.unwrap()
.expect("present");
let packets = s.packets().expect("the entries tile");
assert_eq!(packets.len(), 3);
assert_eq!(packets[0].size, 0x40);
assert_eq!(packets[2].size, palette_at - 0xd0);
// The palette is untouched by the packets sitting before it.
assert_eq!(s.normals().count(), SHRUB_NORMAL_COUNT);
assert!(s.normals().all(|n| n.is_unit()));
}
#[test]
fn a_billboard_record_sits_between_the_packets_and_the_palette() {
// With a billboard, the packets end 0x40 short of the palette.
let palette_at = 0x300usize;
let mut c = span_with_packets(palette_at);
c[BASE + 0x28..BASE + 0x2c].copy_from_slice(&1u32.to_le_bytes());
// One entry: the table ends at 0x48, aligned up to 0x50.
let size = palette_at - 0x40 - 0x50;
c[BASE + 0x40..BASE + 0x44].copy_from_slice(&0x50u32.to_le_bytes());
c[BASE + 0x44..BASE + 0x48].copy_from_slice(&(size as u32).to_le_bytes());
// A billboard offset inside the span, in its last 0x40 bytes.
c[BASE + 0x1c..BASE + 0x20].copy_from_slice(&((palette_at - 0x40) as u32).to_le_bytes());
let s = ShrubHeader::region(&c, BASE as i32, 1072, c.len())
.unwrap()
.expect("present");
let packets = s.packets().expect("the entry stops at the billboard");
assert_eq!(packets.len(), 1);
assert_eq!(packets[0].size, palette_at - 0x40 - 0x50);
}
#[test]
fn a_packet_table_that_does_not_tile_is_rejected() {
let palette_at = 0x300usize;
let mut c = span_with_packets(palette_at);
c[BASE + 0x28..BASE + 0x2c].copy_from_slice(&2u32.to_le_bytes());
// The second entry starts mid-packet, not where the first ended.
c[BASE + 0x40..BASE + 0x44].copy_from_slice(&0x60u32.to_le_bytes());
c[BASE + 0x44..BASE + 0x48].copy_from_slice(&0x40u32.to_le_bytes());
c[BASE + 0x48..BASE + 0x4c].copy_from_slice(&0x61u32.to_le_bytes());
c[BASE + 0x4c..BASE + 0x50].copy_from_slice(&0x40u32.to_le_bytes());
let s = ShrubHeader::region(&c, BASE as i32, 1072, c.len())
.unwrap()
.expect("present");
assert!(s.packets().is_none());
}
#[test]
fn a_billboard_record_is_read_and_checked() {
let mut c = span_with_tail(0x40 + 8);
// The billboard sits directly after the palette.
let bb = BASE + 0x30 + SHRUB_NORMAL_COUNT * 8;
c[BASE + 0x1c..BASE + 0x20].copy_from_slice(&((bb - BASE) as u32).to_le_bytes());
c[bb..bb + 4].copy_from_slice(&50.0f32.to_le_bytes());
// TEX1: K = -217, MMIN = 4, address 0x14.
c[bb + 0x10..bb + 0x12].copy_from_slice(&(-217i16).to_le_bytes());
c[bb + 0x14..bb + 0x18].copy_from_slice(&4u32.to_le_bytes());
c[bb + 0x18..bb + 0x1c].copy_from_slice(&0x14u32.to_le_bytes());
// TEX0: data_lo = 1, address 0x06.
c[bb + 0x20..bb + 0x24].copy_from_slice(&1u32.to_le_bytes());
c[bb + 0x28..bb + 0x2c].copy_from_slice(&0x06u32.to_le_bytes());
// MIPTBP1_1: address 0x34.
c[bb + 0x38..bb + 0x3c].copy_from_slice(&0x34u32.to_le_bytes());
let s = ShrubHeader::region(&c, BASE as i32, 1072, c.len())
.unwrap()
.expect("present");
let b = s.billboard().unwrap().expect("billboard present");
assert_eq!(b.fade_distance, 50.0);
assert_eq!(b.k, -217);
assert_eq!(b.mmin, 4);
assert_eq!(b.tex0, 1);
assert_eq!(b.tex1_addr, 0x14);
assert_eq!(b.tex0_addr, 0x06);
assert_eq!(b.miptbp_addr, 0x34);
// The palette is untouched by the billboard sitting after it.
assert_eq!(s.normals().count(), SHRUB_NORMAL_COUNT);
assert!(s.normals().all(|n| n.is_unit()));
}
#[test]
fn a_billboard_offset_past_the_span_is_rejected() {
let mut c = span_with_tail(0x40);
// Far past the span's end.
c[BASE + 0x1c..BASE + 0x20].copy_from_slice(&9000u32.to_le_bytes());
assert!(ShrubHeader::region(&c, BASE as i32, 1072, c.len()).is_err());
}
#[test]
fn a_span_naming_the_wrong_class_is_rejected() {
let c = span();
let err = ShrubHeader::region(&c, BASE as i32, 999, c.len()).unwrap_err();
assert!(format!("{err:?}").contains("disagrees"));
}
#[test]
fn a_zero_offset_means_absent_not_present() {
let c = span();
assert!(ShrubHeader::region(&c, 0, 1072, c.len()).unwrap().is_none());
assert!(ShrubHeader::region(&c, -1, 1072, c.len())
.unwrap()
.is_none());
}
#[test]
fn a_nonpositive_radius_or_scale_is_rejected() {
let mut c = span();
c[BASE + 0x0c..BASE + 0x10].copy_from_slice(&0.0f32.to_le_bytes());
assert!(ShrubHeader::region(&c, BASE as i32, 1072, c.len()).is_err());
let mut c = span();
c[BASE + 0x20..BASE + 0x24].copy_from_slice(&0.0f32.to_le_bytes());
assert!(ShrubHeader::region(&c, BASE as i32, 1072, c.len()).is_err());
}
#[test]
fn a_nonzero_reserved_word_is_rejected() {
let mut c = span();
c[BASE + 0x18..BASE + 0x1c].copy_from_slice(&0x40u32.to_le_bytes());
assert!(ShrubHeader::region(&c, BASE as i32, 1072, c.len()).is_err());
}
#[test]
fn out_of_range_is_an_error_not_a_panic() {
// Offset 0 is absent, not an error -- an empty lump is just empty.
assert!(ShrubHeader::region(&[], 0, 1, 0).unwrap().is_none());
assert!(ShrubHeader::region(&[], 4, 1, 8).is_err());
// Data offset past the end of the lump.
let mut c = span();
c[BASE + 0x2c..BASE + 0x30].copy_from_slice(&9_000u32.to_le_bytes());
assert!(ShrubHeader::region(&c, BASE as i32, 1072, c.len()).is_err());
// Span starting near the end, with no room for a header.
assert!(ShrubHeader::region(&c, (c.len() - 4) as i32, 1072, c.len()).is_err());
}
}