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.
This commit is contained in:
2026-10-14 05:01:40 +02:00
parent f43a1c2e6e
commit ff9699c3c7
2 changed files with 136 additions and 3 deletions
+125
View File
@@ -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<f32> = Vec::new();
let mut w3c: BTreeMap<u32, usize> = BTreeMap::new();
let mut by3c: BTreeMap<u32, (usize, usize, u64)> = 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<_>>(), vec![0]);
assert_eq!(reserved_30.iter().copied().collect::<Vec<_>>(), 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:?}");
}
+11 -3
View File
@@ -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 {