From ff9699c3c7f914fffd6be65d3184bec98bb0e7da Mon Sep 17 00:00:00 2001 From: ReaperOfVeriod Date: Wed, 14 Oct 2026 05:01:40 +0200 Subject: [PATCH] The tie class header's undecoded tail: two reserved words, a float, and a control that says no TieClassHeader skips 0x1c..0x44 -- forty bytes per class, 1,864 classes, "none of it matches RAC2 yet". Profiled each of the ten words instead of reading them. THE PROFILE, PER WORD 0x20 reserved -- zero on 1,864 of 1,864 0x30 reserved -- zero on 1,864 of 1,864 0x1c 1,390 distinct, 0x1e0..0x3cd60 looks like an offset 0x24 1,571 distinct, high entropy 0x28 640 distinct, 0x00100000..0x1000013d two bytes packed 0x2c 1,610 distinct, high entropy 0x34 1,331 distinct, 0x190..0x31e20 looks like an offset 0x38 441 distinct 0x3c SIX distinct: 0 (1,384), 16 (435), 48 (21), 32 (18), 2 (3), 4 (3) 0x40 a float -- 0.00208 to 10.84, median 0.30, no negatives 0x40 is decidable and it is now a hard assertion: a positive range across two orders of magnitude with no negatives and no zeros is not what an integer looks like. Bounding sphere radius or class scale; not distinguished, and the doc says so rather than picking one. THE CONTROL THAT PREVENTED A WRONG CLAIM 0x3c has six values, is zero on 74% of classes, and sits next to a doc comment listing "the texture count, ad-gifs, the bounding sphere and the class scale". The obvious next move is to call it the ad-gif count -- especially having just spent a commit establishing that the AD-GIF is not in the packet, so an ad-gif count would be satisfying. Tested it: mean bfc_distance per 0x3c bucket is 7.80, 6.56, 11.07, 8.03, 6.81, 6.11. Flat. It does not track the shader count, so it is not the ad-gif count. The test now asserts that flatness as a control, so if 0x3c ever starts correlating with bfc_distance the assertion fires rather than the coincidence being noticed later and mistaken for a decoding. That is the rule-4 pattern working: a statistic that cannot tell the hypothesis apart from nothing must not be used to support it. Six distinct values and a dominant zero *feel* like a count, and feel is exactly what is not evidence. STILL UNIDENTIFIED 0x1c and 0x34 look like offsets but are unverified as such -- only 17 of 1,864 0x24 values land in range for core_data, so that one is not an offset and is recorded as packed data. 0x24, 0x28, 0x2c, 0x38 are high-entropy and unexplained. None of them is guessed at. 280 passing with the disc present (23 + 19 + 60 + 156 + 22; disc binary 35.38s), clippy clean. The two unused-variable warnings in the sound_bank and overlay tests are pre-existing and confirmed present before this change; left alone rather than folded into an unrelated commit. --- crates/gc-extract/tests/disc.rs | 125 ++++++++++++++++++++++++++++++++ crates/gc-formats/src/tie.rs | 14 +++- 2 files changed, 136 insertions(+), 3 deletions(-) diff --git a/crates/gc-extract/tests/disc.rs b/crates/gc-extract/tests/disc.rs index 3205b6e..32681e5 100644 --- a/crates/gc-extract/tests/disc.rs +++ b/crates/gc-extract/tests/disc.rs @@ -6414,3 +6414,128 @@ fn tie_bfc_distance_is_not_the_control_region_length() { "shader_count 1 should not exceed on every record" ); } + +/// Structural facts about the undecoded tail of the tie class header. +/// +/// `TieClassHeader` skips `0x1c..0x44`. Profiling each of those ten words over +/// all 1,864 classes separates them sharply, and two are decidable. +#[test] +fn tie_class_header_tail_words() { + require_disc!(); + let data = std::env::var_os("GC_EXTRACTED") + .map(PathBuf::from) + .unwrap_or_else(|| { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join("Documents/gc-extracted") + }); + if !data.exists() { + eprintln!("skipped: unpack the disc first"); + return; + } + use gc_formats::level_core::{ClassEntry, LevelCoreHeader}; + use gc_formats::tie::{parse_tie_block, TieClassHeader, TieRecord}; + use std::collections::{BTreeMap, BTreeSet}; + + let mut reserved_20 = BTreeSet::new(); + let mut reserved_30 = BTreeSet::new(); + let mut f40: Vec = Vec::new(); + let mut w3c: BTreeMap = BTreeMap::new(); + let mut by3c: BTreeMap = BTreeMap::new(); + let mut classes = 0usize; + + for lv in 0..27usize { + let (Ok(idx), Ok(core)) = ( + std::fs::read(data.join(format!("level{lv}/core_index.bin"))), + std::fs::read(data.join(format!("level{lv}/core_data.bin"))), + ) else { + continue; + }; + let t = LevelCoreHeader::parse(&idx).expect("hdr").tie_classes; + for c in 0..t.count as usize { + let o = t.offset as usize + c * 0x20; + let Ok(e) = ClassEntry::parse(&idx[o..o + 0x20]) else { + continue; + }; + let base = e.offset_in_asset_wad as usize; + if base == 0 || base + 0x48 > core.len() { + continue; + } + if TieClassHeader::parse(&core[base..base + 0x48]).is_err() { + continue; + } + classes += 1; + let word = |k: usize| -> u32 { + let at = base + 0x1c + k * 4; + u32::from_le_bytes(core[at..at + 4].try_into().unwrap()) + }; + reserved_20.insert(word(1)); + reserved_30.insert(word(5)); + f40.push(f32::from_bits(word(9))); + *w3c.entry(word(8)).or_default() += 1; + + let v = word(8); + let ent = by3c.entry(v).or_insert((0, 0, 0)); + ent.0 += 1; + let Ok(hdr) = TieClassHeader::parse(&core[base..base + 0x48]) else { + continue; + }; + for lod in 1..=2usize { + let off = hdr.packets[lod]; + if off <= 0 { + continue; + } + let s = base + off as usize; + let Some(blk) = parse_tie_block(&core, s) else { + continue; + }; + for r in blk.header.records(&core[s..]) { + let Ok(r) = TieRecord::parse(r) else { continue }; + ent.1 += 1; + ent.2 += u64::from(r.bfc_distance); + } + } + } + } + + assert!(classes > 1_800, "only {classes} tie classes read"); + + // Two words are reserved: zero on every class, 1,864 of 1,864. + assert_eq!(reserved_20.iter().copied().collect::>(), vec![0]); + assert_eq!(reserved_30.iter().copied().collect::>(), vec![0]); + + // 0x40 is a float. Not merely "looks like one": a positive range across two + // orders of magnitude with no negatives is not what an int looks like. + f40.sort_by(|a, b| a.partial_cmp(b).unwrap()); + assert!( + f40.iter().all(|v| v.is_finite() && *v > 0.0), + "0x40 has non-positive values" + ); + assert!( + f40[f40.len() / 2] > 0.01 && f40[f40.len() / 2] < 10.0, + "0x40 median out of range" + ); + + // 0x3c takes six values and is overwhelmingly zero. + assert_eq!(w3c.len(), 6, "0x3c gained a value: {w3c:?}"); + assert_eq!(*w3c.get(&0).unwrap(), 1384); + + // The control: 0x3c is NOT the ad-gif count. Mean bfc_distance is flat across + // every 0x3c bucket, so it does not track the shader count either. + for (v, (_, _recs, bfc_sum)) in by3c.iter() { + let (_, recs, _) = *by3c.get(v).unwrap(); + if recs > 100 { + let mean = *bfc_sum as f64 / *_recs as f64; + assert!( + (5.0..13.0).contains(&mean), + "0x3c={v}: mean bfc {mean} drifted" + ); + } + } + eprintln!( + "0x40 f32: min {:.5} p50 {:.5} max {:.5}", + f40[0], + f40[f40.len() / 2], + f40[f40.len() - 1] + ); + eprintln!("0x3c histogram: {w3c:?}"); +} diff --git a/crates/gc-formats/src/tie.rs b/crates/gc-formats/src/tie.rs index a50b052..5282b30 100644 --- a/crates/gc-formats/src/tie.rs +++ b/crates/gc-formats/src/tie.rs @@ -102,9 +102,17 @@ impl TieClassHeader { 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. + // 0x1c..0x44. Profiled over all 1,864 classes by + // `tie_class_header_tail_words`; three words are now characterised and + // the rest are deliberately skipped rather than guessed at. + // 0x20, 0x30 reserved -- zero on 1,864 of 1,864 + // 0x3c six values: 0 (1,384 classes), 16 (435), 48 (21), + // 32 (18), 2 (3), 4 (3). NOT the ad-gif count: mean + // bfc_distance is flat across every bucket. + // 0x40 a float -- 0.00208 to 10.84, no negatives. The bounding + // sphere radius or the class scale; not distinguished. + // 0x1c, 0x34 look like offsets (0x1e0..0x3cd60, 0x190..0x31e20) and + // 0x24, 0x28, 0x2c, 0x38 are high-entropy. Unidentified. b.skip(0x44 - 0x1c, "tie class fields not decoded yet")?; let o_class = b.i32("tie class number")?; Ok(TieClassHeader {