//! 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 { 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 { 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 { 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 { 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 { 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); } }