TieBlockHeader parses the block's own 16 bytes, measured over the 922 blocks where "the next packets offset ends this block" holds, across all 27 levels. A real-disc test asserts every figure. +0x00 count multiple of 16 on 922/922; 16..2096; 72 distinct disc-wide +0x02 zero on 922/922 +0x04 ONE OF FOUR VALUES DISC-WIDE: 0x501, 0xa02, 0xf03, 0x1404 +0x06 15 distinct, 0x0206..0x0512 +0x08 135 distinct, 0x0b05..0x4ee4 +0x0a 45 distinct +0x0c 94 distinct, 0x010e..0x0553 +0x0e zero on 914/922 +0x04 is packed rather than a magnitude: four values across the whole disc with the two bytes moving independently, low 1-4 and high 5/10/15/20 in lockstep. That it is a texture index paired with a scale is a guess; that it is two fields is not. The count is not a record count -- block_size minus the header is divisible by it on 0 of 922, for any record size and any header up to 512 bytes. On the payload, a control was needed and changed the answer. Reading blocks as 3 x f32 triples, 53-64% of reads land within +/-5000 of origin, against 16% for a uniform-random control of identical length. So the content is float-shaped and genuinely not random. But the rate is flat across every start offset and every stride tried, 53-65% throughout. A statistic that cannot separate the right stride from the wrong one cannot identify a stride -- rule 4's exact case. Flatness plus a count that does not tile means the records are variable length. That killed two readings. A VIF1 command walk consumes 0 of 88 blocks exactly, so it is not a VIF1 CPU-format stream. wad::decompress rejects the block at every start offset, so it is not WAD LZ77. Scanning every (start, stride) pair from 0x10 to 0x7f for byte positions with a small distinct-value vocabulary finds none: every position takes all 256 values at every alignment. For float payload that is expected, so the scan found nothing rather than confirming anything -- worth saying, because "no fields found" here is uninformative, not evidence against fields. Still open: what the payload holds. Established: variable-length, float-shaped, non-random, not LZ77, not VIF1. Not established: any record boundary.
292 lines
11 KiB
Rust
292 lines
11 KiB
Rust
//! Tie: instanced static props -- the environment sitting on the terrain.
|
|
//!
|
|
//! Spec: `docs/formats/tie.md`.
|
|
//!
|
|
//! A *class* is one prop shape (a crate, a pipe run, a railing); *instances*
|
|
//! place copies of it in the level. The class tables live in the core index and
|
|
//! the geometry sits in `core_data`, addressed by byte offset.
|
|
//!
|
|
//! # What is decoded here, and what is not
|
|
//!
|
|
//! The reference's class header is 0x80 bytes. RAC2 agrees with it only over
|
|
//! the first `0x48`: the per-LOD packet offsets, the three LOD distances and
|
|
//! `o_class` all check out on every class on every level. From `0x48` on, the
|
|
//! RAC1 fields do **not** describe RAC2 data -- `lod_info` reads back as noise,
|
|
//! and `packet_count` and `texture_count` are zero on every class examined.
|
|
//!
|
|
//! So this module decodes the part that is verified and stops there. Guessing
|
|
//! the remainder would repeat the pattern this project keeps running into: a
|
|
//! field order that produces plausible output and is wrong.
|
|
//!
|
|
//! Evidence for what is decoded, over 320 classes across five levels:
|
|
//!
|
|
//! * `o_class` at `0x44` equals the core index's class number: **320 of 320**.
|
|
//! A coincidence is not a plausible explanation for that.
|
|
//! * the LOD distances are ordered `0 < near < mid < far`: 315 of 320. The
|
|
//! five exceptions are not yet explained and are worth chasing.
|
|
|
|
use crate::buf::{Buf, Error, ErrorKind, Result};
|
|
use crate::level_core::ClassEntry;
|
|
|
|
/// Bytes of a tie class header that RAC2 and RAC1 agree on.
|
|
pub const TIE_CLASS_HEADER_MATCHED: usize = 0x48;
|
|
|
|
/// Value `TieClassHeader::packets[0]` takes on every one of the 1,864 classes.
|
|
///
|
|
/// It is a constant, not a block offset: the other two `packets` words vary
|
|
/// widely and land at different addresses in `core_data` on every class, while
|
|
/// this one is 256 everywhere. Read as an offset it would name a point just
|
|
/// past the header, which is exactly the kind of field that gets mistaken for
|
|
/// a pointer.
|
|
pub const TIE_PACKETS0: i32 = 256;
|
|
|
|
/// A tie class header, decoded as far as the two games agree.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct TieClassHeader {
|
|
/// `0x00`: packet data offsets for LOD 0 (highest detail), 1 and 2.
|
|
///
|
|
/// [`TIE_PACKETS0`] is constant across all 1,864 classes and is reported
|
|
/// separately; only the second and third words behave like offsets. Those
|
|
/// two are **strictly increasing on 461 of 1,864 classes** -- on the other
|
|
/// 1,403 they are not ordered, so they cannot both be block starts into a
|
|
/// single ascending region and the block layout is not decoded.
|
|
///
|
|
/// Where they do increase, both resulting block sizes are divisible by 16
|
|
/// on **922 of 922**, which is the evidence for a 16-byte record.
|
|
pub packets: [i32; 3],
|
|
/// `0x10`, `0x14`, `0x18`: LOD switch distances in world units.
|
|
pub near_dist: f32,
|
|
pub mid_dist: f32,
|
|
pub far_dist: f32,
|
|
/// `0x44`: the class's own number.
|
|
///
|
|
/// Matches the core index's `o_class` for the class, which is what makes
|
|
/// the header base trustworthy.
|
|
pub o_class: i32,
|
|
}
|
|
|
|
impl TieClassHeader {
|
|
/// Parse the agreed-on prefix. Needs `TIE_CLASS_HEADER_MATCHED` bytes.
|
|
pub fn parse(bytes: &[u8]) -> Result<TieClassHeader> {
|
|
let mut b = Buf::new(bytes);
|
|
let mut packets = [0i32; 3];
|
|
for slot in packets.iter_mut() {
|
|
*slot = b.i32("tie packet offset")?;
|
|
}
|
|
// 0x0c: the 64 light-slot normals offset. Read past, not decoded.
|
|
b.skip(4, "tie normals offset")?;
|
|
let near_dist = b.f32("tie near distance")?;
|
|
let mid_dist = b.f32("tie mid distance")?;
|
|
let far_dist = b.f32("tie far distance")?;
|
|
// 0x1c..0x44: the word TieProc reuses as a per-frame counter, the LOD
|
|
// packet counts, the texture count, ad-gifs, the bounding sphere and
|
|
// the class scale. None of it matches RAC2 yet.
|
|
b.skip(0x44 - 0x1c, "tie class fields not decoded yet")?;
|
|
let o_class = b.i32("tie class number")?;
|
|
Ok(TieClassHeader {
|
|
packets,
|
|
near_dist,
|
|
mid_dist,
|
|
far_dist,
|
|
o_class,
|
|
})
|
|
}
|
|
|
|
/// The LOD this class draws at a view depth, or `None` past its far
|
|
/// distance.
|
|
pub fn lod_for_depth(&self, depth: f32) -> Option<usize> {
|
|
if depth < self.near_dist {
|
|
Some(0)
|
|
} else if depth < self.mid_dist {
|
|
Some(1)
|
|
} else if depth < self.far_dist {
|
|
Some(2)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Size of a tie LOD block's own header.
|
|
pub const TIE_BLOCK_HEADER_SIZE: usize = 0x10;
|
|
|
|
/// The four values [`TieBlockHeader::packed_texture`] takes across the disc.
|
|
///
|
|
/// Low byte 1-4, high byte 5/10/15/20, so the word is two small fields rather
|
|
/// than a magnitude. Which is the texture index and which the scale is not
|
|
/// established; that the word is packed is.
|
|
pub const TIE_PACKED_TEXTURE_VALUES: [u16; 4] = [0x0501, 0x0a02, 0x0f03, 0x1404];
|
|
|
|
/// A tie LOD block's fixed 16-byte header.
|
|
///
|
|
/// Every figure here is measured over the **922** blocks where "the next
|
|
/// `packets` offset ends this block" holds, across all 27 levels. The other
|
|
/// 1,403 classes are unmeasured, not measured-and-different.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct TieBlockHeader {
|
|
/// `+0x00`: element count. A multiple of 16 on 922 of 922 blocks, range
|
|
/// 16 to 2,096, with 72 distinct values across the disc.
|
|
///
|
|
/// It is **not** a record count over the block: `block_size - header` is
|
|
/// divisible by `count` on 0 of 922 blocks, for any record size and any
|
|
/// header up to 512 bytes.
|
|
pub count: u16,
|
|
/// `+0x02`: zero on 922 of 922.
|
|
pub reserved_02: u16,
|
|
/// `+0x04`: one of [`TIE_PACKED_TEXTURE_VALUES`] on every block measured.
|
|
pub packed_texture: u16,
|
|
/// `+0x06`: 15 distinct values, 0x0206 to 0x0512.
|
|
pub field_06: u16,
|
|
/// `+0x08`: 135 distinct values, 0x0b05 to 0x4ee4.
|
|
pub field_08: u16,
|
|
/// `+0x0a`: 45 distinct values.
|
|
pub field_0a: u16,
|
|
/// `+0x0c`: 94 distinct values, 0x010e to 0x0553.
|
|
pub field_0c: u16,
|
|
/// `+0x0e`: zero on 914 of 922.
|
|
pub reserved_0e: u16,
|
|
}
|
|
|
|
impl TieBlockHeader {
|
|
/// Parse the block header, bounds-checked against `block`.
|
|
pub fn parse(block: &[u8]) -> Result<Self> {
|
|
let mut b = Buf::new(block);
|
|
Ok(Self {
|
|
count: b.u16("tie block count")?,
|
|
reserved_02: b.u16("tie block reserved")?,
|
|
packed_texture: b.u16("tie block packed texture")?,
|
|
field_06: b.u16("tie block field_06")?,
|
|
field_08: b.u16("tie block field_08")?,
|
|
field_0a: b.u16("tie block field_0a")?,
|
|
field_0c: b.u16("tie block field_0c")?,
|
|
reserved_0e: b.u16("tie block reserved_0e")?,
|
|
})
|
|
}
|
|
|
|
/// True when every invariant measured across all 27 levels holds.
|
|
pub fn invariants_hold(&self) -> bool {
|
|
self.count % 16 == 0
|
|
&& self.reserved_02 == 0
|
|
&& TIE_PACKED_TEXTURE_VALUES.contains(&self.packed_texture)
|
|
}
|
|
}
|
|
|
|
/// A tie class resolved through both of its indirections.
|
|
///
|
|
/// The chain is two steps and both are easy to get wrong, so it is worth
|
|
/// stating plainly:
|
|
///
|
|
/// 1. The class **table** lives in the level's `core_index.bin`, not in
|
|
/// `core_data.bin`. On level 0 the tables chain as moby at `192`,
|
|
/// 238 x 32 bytes later tie at `7808`, 74 x 32 later shrub at `10176`,
|
|
/// which closes. Reading the same table out of `core_data.bin` yields
|
|
/// plausible-looking garbage rather than an error.
|
|
/// 2. [`ClassEntry::offset_in_asset_wad`] addresses `core_data.bin`, despite the
|
|
/// field's name and despite `core_data.wad` existing alongside it.
|
|
///
|
|
/// With both handled, all **1,864** tie classes on the disc resolve to a header
|
|
/// whose `o_class` matches the class entry's own.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct ResolvedTieClass {
|
|
/// The index entry this came from.
|
|
pub entry: ClassEntry,
|
|
/// Its index within the level's tie table.
|
|
pub index: usize,
|
|
/// The class header, at `offset_in_asset_wad` in `core_data`.
|
|
pub header: TieClassHeader,
|
|
}
|
|
|
|
/// Resolve one tie class. `class_index` counts from zero.
|
|
///
|
|
/// `core_index` is the level's `core_index.bin`; `core_data` is the
|
|
/// decompressed `core_data.bin`. Both are bounds-checked.
|
|
pub fn resolve_tie_class(
|
|
core_index: &[u8],
|
|
core_data: &[u8],
|
|
classes: crate::level_core::ArrayRange,
|
|
class_index: usize,
|
|
) -> Result<ResolvedTieClass> {
|
|
const ENTRY: usize = 32;
|
|
let start = classes.offset as usize + class_index * ENTRY;
|
|
if classes.offset < 0 || start.checked_add(ENTRY).is_none() || start + ENTRY > core_index.len()
|
|
{
|
|
return Err(Error {
|
|
offset: start,
|
|
context: "tie class table entry",
|
|
kind: ErrorKind::OutOfBounds {
|
|
needed: ENTRY,
|
|
available: core_index.len().saturating_sub(start),
|
|
},
|
|
});
|
|
}
|
|
if class_index >= classes.count as usize {
|
|
return Err(Error {
|
|
offset: start,
|
|
context: "tie class index",
|
|
kind: ErrorKind::Invalid("class index past the table count"),
|
|
});
|
|
}
|
|
let entry = ClassEntry::parse(&core_index[start..start + ENTRY])?;
|
|
let base = entry.offset_in_asset_wad as usize;
|
|
if base < TIE_CLASS_HEADER_MATCHED || base + TIE_CLASS_HEADER_MATCHED > core_data.len() {
|
|
return Err(Error {
|
|
offset: base,
|
|
context: "tie class header",
|
|
kind: ErrorKind::OutOfBounds {
|
|
needed: TIE_CLASS_HEADER_MATCHED,
|
|
available: core_data.len().saturating_sub(base),
|
|
},
|
|
});
|
|
}
|
|
let header = TieClassHeader::parse(&core_data[base..base + TIE_CLASS_HEADER_MATCHED])?;
|
|
Ok(ResolvedTieClass {
|
|
entry,
|
|
index: class_index,
|
|
header,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A header as measured on level 0's first tie class.
|
|
fn header() -> Vec<u8> {
|
|
let mut v = vec![0u8; TIE_CLASS_HEADER_MATCHED];
|
|
for (i, x) in [256i32, 20_592, 26_048].iter().enumerate() {
|
|
v[i * 4..i * 4 + 4].copy_from_slice(&x.to_le_bytes());
|
|
}
|
|
for (i, x) in [12.0f32, 25.0, 40.0].iter().enumerate() {
|
|
let o = 0x10 + i * 4;
|
|
v[o..o + 4].copy_from_slice(&x.to_le_bytes());
|
|
}
|
|
v[0x44..0x48].copy_from_slice(&2592i32.to_le_bytes());
|
|
v
|
|
}
|
|
|
|
#[test]
|
|
fn parses_the_measured_prefix() {
|
|
let h = TieClassHeader::parse(&header()).unwrap();
|
|
assert_eq!(h.packets, [256, 20_592, 26_048]);
|
|
assert_eq!(h.near_dist, 12.0);
|
|
assert_eq!(h.mid_dist, 25.0);
|
|
assert_eq!(h.far_dist, 40.0);
|
|
assert_eq!(h.o_class, 2592);
|
|
}
|
|
|
|
#[test]
|
|
fn a_short_header_is_an_error_not_a_panic() {
|
|
assert!(TieClassHeader::parse(&[0u8; 0x20]).is_err());
|
|
assert!(TieClassHeader::parse(&[]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn lod_bands_follow_the_three_distances() {
|
|
let h = TieClassHeader::parse(&header()).unwrap();
|
|
assert_eq!(h.lod_for_depth(5.0), Some(0));
|
|
assert_eq!(h.lod_for_depth(20.0), Some(1));
|
|
assert_eq!(h.lod_for_depth(30.0), Some(2));
|
|
assert_eq!(h.lod_for_depth(50.0), None);
|
|
}
|
|
}
|