diff --git a/crates/gc-extract/tests/disc.rs b/crates/gc-extract/tests/disc.rs index 3f27af6..551db1c 100644 --- a/crates/gc-extract/tests/disc.rs +++ b/crates/gc-extract/tests/disc.rs @@ -1199,3 +1199,88 @@ fn class_geometry_offsets_tile_core_data_without_overlap() { assert_eq!(levels, 27, "expected all 27 levels, got {levels}"); eprintln!("class geometry offsets verified inside core_data on {levels} levels"); } + +/// Tie class headers, as far as RAC1 and RAC2 agree. +/// +/// The check that carries the weight is `o_class`: the class's own number at +/// header offset 0x44 equals the number the core index filed it under, on every +/// class of every level. That is what makes the header base trustworthy rather +/// than merely plausible. +#[test] +fn tie_class_headers_match_their_index_entry() { + require_disc!(); + gc_formats::iso9660::IsoImage::open(std::path::Path::new( + &std::env::var("GC_DISC").expect("GC_DISC is set by require_disc!"), + )) + .expect("ISO should open"); + let data = std::env::var_os("GC_EXTRACTED") + .map(PathBuf::from) + .unwrap_or_else(|| { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + PathBuf::from(home).join("Documents/gc-extracted") + }); + if !data.exists() { + eprintln!("skipped: unpack the disc first"); + return; + } + + use gc_formats::level_core as lc; + use gc_formats::tie::{TieClassHeader, TIE_CLASS_HEADER_MATCHED}; + let mut levels = 0usize; + let (mut classes, mut ordered, mut unordered) = (0usize, 0usize, 0usize); + + for n in 0..27 { + let dir = data.join(format!("level{n}")); + let (Ok(index), Ok(core)) = ( + std::fs::read(dir.join("core_index.bin")), + std::fs::read(dir.join("core_data.bin")), + ) else { + continue; + }; + let h = lc::LevelCoreHeader::parse(&index).expect("core header"); + let entries = lc::class_table( + &index, + h.tie_classes, + lc::TIE_CLASS_SIZE, + lc::ClassEntry::parse, + ) + .expect("tie classes"); + + for e in &entries { + let off = e.offset_in_asset_wad as usize; + if off == 0 { + continue; // slot with no geometry + } + let at = off + TIE_CLASS_HEADER_MATCHED; + assert!(at <= core.len(), "level{n}: tie class past core data"); + let t = TieClassHeader::parse(&core[off..at]) + .unwrap_or_else(|err| panic!("level{n}: tie class {} header: {err}", e.o_class)); + assert_eq!(t.o_class, e.o_class, "level{n}: tie o_class mismatch"); + assert!( + t.far_dist > 0.0, + "level{n}: tie far distance {}", + t.far_dist + ); + if 0.0 < t.near_dist && t.near_dist < t.mid_dist && t.mid_dist < t.far_dist { + ordered += 1; + } else { + unordered += 1; + } + classes += 1; + } + levels += 1; + } + + assert_eq!(levels, 27, "expected all 27 levels, got {levels}"); + assert!(classes > 1_500, "only {classes} tie classes"); + // Ordered distances are the expected shape; the exceptions are real and + // unexplained, so they are reported rather than asserted away. + assert!( + ordered * 20 >= classes * 19, + "only {ordered} of {classes} tie classes have ordered LOD distances" + ); + eprintln!( + "tie: {classes} class headers, o_class matches the index on all; \\ + {ordered} have ordered LOD distances, {unordered} do not" + ); +} diff --git a/crates/gc-formats/src/lib.rs b/crates/gc-formats/src/lib.rs index d40e96a..4d4ba83 100644 --- a/crates/gc-formats/src/lib.rs +++ b/crates/gc-formats/src/lib.rs @@ -7,4 +7,5 @@ pub mod level_wad; pub mod texture; pub mod tfrag; pub mod tfrag_light; +pub mod tie; pub mod wad; diff --git a/crates/gc-formats/src/tie.rs b/crates/gc-formats/src/tie.rs new file mode 100644 index 0000000..525af5d --- /dev/null +++ b/crates/gc-formats/src/tie.rs @@ -0,0 +1,136 @@ +//! 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, Result}; + +/// Bytes of a tie class header that RAC2 and RAC1 agree on. +pub const TIE_CLASS_HEADER_MATCHED: usize = 0x48; + +/// 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. + /// + /// Meaning confirmed only by being small, ordered and inside the lump; the + /// packet structure itself is not decoded yet. + 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 + } + } +} + +#[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); + } +} diff --git a/docs/plan/level-assets.md b/docs/plan/level-assets.md index 5370137..23b679c 100644 --- a/docs/plan/level-assets.md +++ b/docs/plan/level-assets.md @@ -593,6 +593,33 @@ At the offsets sit raw GS/VIF packet data, not a further container: moby[0] begins `00 00 00 00 3f 16 02 55 6f 6f`, tie[0] `00 01 00 00 70 50 00 00`, which read as packet headers rather than a magic number. +### Tie: the class header only partly matches + +Tie is the easy one of the three -- instanced static geometry, no skeleton -- so +it is the right place to start. + +RAC1's class header is 0x80 bytes. RAC2 agrees with it over the first `0x48`: +the per-LOD packet offsets, the three LOD distances and `o_class` all check out. +From `0x48` on it does not. `lod_info` at `0x50` reads back as noise, +`packet_count` and `texture_count` are zero on every class examined, and the +`normals` offset at `0x0c` points 117,788,434 bytes into a 16.9 MB lump. + +So only the agreed-on prefix is decoded. The evidence for trusting it: + +- **`o_class` at `0x44` equals the core index's class number on all 1,864 tie + classes across all 27 levels.** A field landing on the right number that many + times is not a coincidence, and it is what pins the header base. +- The LOD distances are ordered `0 < near < mid < far` for **1,784 of 1,864**. + The 80 exceptions are real, unexplained, and are reported rather than + asserted away. + +Level 0's first tie class has distances 12.0 / 25.0 / 40.0, which is a +plausible prop LOD band, and packet offsets 256, 20,592 and 26,048. + +The remainder is not guessed at. A field order that produces plausible output +and is wrong is the specific failure this file keeps recording -- the gameplay +extent, the tfrag record framing and the class tables have all been it. + ### Why the frame is still dark: the light bank does not parse on RAC2 The reference frames the bank as "a u32 offset at +0x04, then an `i32` count