diff --git a/crates/gc-extract/tests/disc.rs b/crates/gc-extract/tests/disc.rs index 32681e5..51a8f74 100644 --- a/crates/gc-extract/tests/disc.rs +++ b/crates/gc-extract/tests/disc.rs @@ -6539,3 +6539,85 @@ fn tie_class_header_tail_words() { ); eprintln!("0x3c histogram: {w3c:?}"); } + +/// `0x1c` and `0x34` of the tie class header are base-relative offsets, and they +/// land past the class's own tie packets. +#[test] +fn tie_class_tail_offsets_point_past_the_tie_packets() { + 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::TieClassHeader; + + let (mut classes, mut in_range, mut past_packets, mut both_nonzero) = + (0usize, 0usize, 0usize, 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; + } + let Ok(hdr) = TieClassHeader::parse(&core[base..base + 0x48]) else { + 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()) + }; + let (o1c, o34) = (word(0) as usize, word(6) as usize); + if usize::from(o1c) + 32 <= core.len() && o34 + 32 <= core.len() { + in_range += 1; + } + if word(0) != 0 && word(6) != 0 { + both_nonzero += 1; + } + // Past the class's own tie packet blocks. + let last_packet = hdr + .packets + .iter() + .filter(|p| **p > 0) + .max() + .copied() + .unwrap_or(0); + if o1c as i32 > last_packet && o34 as i32 > last_packet { + past_packets += 1; + } + } + } + + assert!(classes > 1_800, "only {classes} tie classes read"); + eprintln!("0x1c/0x34 in range {in_range}/{classes}, past own packets {past_packets}/{classes}"); + // Both are relative to the class base, not absolute: base + offset lands in + // core_data, and absolute offsets would not. + assert!( + in_range * 100 >= classes * 99, + "only {in_range}/{classes} offsets in range" + ); + assert_eq!(both_nonzero, classes, "a class has a zero offset"); + // They are never inside the class's own tie data. + assert_eq!( + past_packets, classes, + "{classes} offsets fall inside the tie packets" + ); +} diff --git a/crates/gc-formats/src/tie.rs b/crates/gc-formats/src/tie.rs index 5282b30..a4032a7 100644 --- a/crates/gc-formats/src/tie.rs +++ b/crates/gc-formats/src/tie.rs @@ -111,8 +111,14 @@ impl TieClassHeader { // 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. + // 0x1c and 0x34 are base-relative offsets: base + offset lands inside + // core_data on 1,864 of 1,864 classes, and always *past* the class's own + // tie packet blocks, so they address per-class data stored elsewhere. + // The bytes there are structured -- repeating small-integer records, + // occasional `ff 00 ff ff` -- but are not VIF or GIF DMA tags and are + // not identified. See tie_class_tail_offsets_point_past_the_tie_packets. + // 0x24, 0x28, 0x2c, 0x38 are high-entropy and unidentified; 0x24 is + // demonstrably not an offset (17 of 1,864 land in range). b.skip(0x44 - 0x1c, "tie class fields not decoded yet")?; let o_class = b.i32("tie class number")?; Ok(TieClassHeader { diff --git a/docs/plan/level-assets.md b/docs/plan/level-assets.md index 97b8408..95ab69e 100644 --- a/docs/plan/level-assets.md +++ b/docs/plan/level-assets.md @@ -1849,3 +1849,41 @@ vertices, scissor. `bfc_distance` is a per-record quantity relating to the shade count whose meaning is not established, and the "quadwords of ad-gif data" reading of it is wrong. The field doc said so in terms before this measurement; it does not now. + + +## Where the AD-GIF is not: class-header offsets 0x1c and 0x34 + +Two words in the class header's undecoded tail are offsets, and they are the +best remaining lead on the AD-GIF. + +`base + 0x1c` and `base + 0x34` land inside `core_data` on **1,864 of 1,864 +classes**, so both are relative to the class base rather than absolute, and both +are non-zero on every class. And both land **past** that class's own tie packet +blocks on every class -- they address per-class data stored somewhere else in +core_data, not the tie geometry. + +What is there is structured, not random. Class 0 at 0x1c: + + 54 5d 5f 0c 44 44 44 01 03 11 19 00 1c 00 60 0a 30 08 60 0a + ff 00 ff ff 20 0c 50 02 44 38 2b 50 + +and class 3 at the same field: + + 20 05 c5 00 00 80 01 00 26 05 cb 00 21 88 01 00 2c 05 c0 00 + 00 80 01 00 32 05 c0 00 00 80 01 00 + +Class 3 is a fixed-stride table with `80 01 00` recurring. Class 5's 0x34 field +has `38 38 38` and `ff 00 ff ff`. Class 2's 0x1c field is a monotone byte table: +`26 27 0e 0f 10 11 12 13 24 25 15 1e 17 1f 14 15 16 17 20 21 22 23`. + +These read like compiled shader programs with small-integer parameters, or as +lookup tables. **They are not identified**, and the resemblance to anything +specific is exactly the kind of finding rule 4 exists to refuse -- none of these +three fragments has been shown to beat a control, and they were picked because +they looked like something. + +What is established is structural: two base-relative offsets, always in range, +always past the tie packets, pointing at repeated structured records rather than +DMA tags. That is consistent with the earlier finding that the packet's own +control region is not a tag stream -- the game's per-class draw state is stored +elsewhere and referenced, not inlined.