From 79025b22d6864b45a51e3146cb52ad7473e91de8 Mon Sep 17 00:00:00 2001 From: ReaperOfVeriod Date: Sun, 11 Oct 2026 02:06:39 +0200 Subject: [PATCH] Decode the tie class header prefix that RAC1 and RAC2 agree on Tie is the easy one of the three geometry subsystems -- instanced static props, 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 and diverges after. From 0x48 the RAC1 layout does not describe RAC2 data: 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. Only the agreed-on prefix is decoded. What makes it trustworthy: * o_class at 0x44 equals the core index's class number for all 1,864 tie classes across all 27 levels. A field landing on the right number that often is not a coincidence, and it 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, a plausible prop LOD band, and packet offsets 256, 20,592 and 26,048. The rest is deliberately not guessed at. A field order that produces plausible output and is wrong is the specific failure this project keeps recording, three times now: the gameplay extent, the tfrag record framing and the class tables were all "reasonable" and all wrong. --- crates/gc-extract/tests/disc.rs | 85 ++++++++++++++++++++ crates/gc-formats/src/lib.rs | 1 + crates/gc-formats/src/tie.rs | 136 ++++++++++++++++++++++++++++++++ docs/plan/level-assets.md | 27 +++++++ 4 files changed, 249 insertions(+) create mode 100644 crates/gc-formats/src/tie.rs 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