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 {