Decode the tie class header's +0x0c word: three LOD record counts

Range measured: every tie class on the disc, 1,864 classes over all 27 levels.

Method: read the word at class +0x0c as four bytes, and read the record counts
independently from the blocks themselves -- follow packets[lod] to the block,
walk to the 0xf6 terminator, and count the 16-byte records the block header's
data_offset names. Two paths to the same number, so the check is not circular.

Result: the byte at +0x0c is the LOD 0 record count, +0x0d is LOD 1's and +0x0e
is LOD 2's, on 1,864 of 1,864 classes. Classes with an absent LOD agree too:
a zero packets[lod] gives a zero count and the byte is zero.

This closes the file's oldest lie about the tie header. The comment in
TieClassHeader::parse read "0x0c: the 64 light-slot normals offset. Read past,
not decoded" -- inherited from R&C1's layout, where the word genuinely is an
offset. On R&C2 it is not an offset at all; it is three counts, and R&C1 carries
the same quadruple at +0x20. Skipping the field was the right action on the
wrong information: the field was unknown only because R&C1's map was applied to
R&C2's bytes.

TieClassHeader gains packet_counts, and
tie_class_header_counts_records_per_lod asserts the agreement.

The fourth byte, +0x0f, is left unnamed. It ranges 1..15, and the obvious
following of R&C1 -- the shader/ad-gif total -- disagrees on 1,827 of 1,864
classes, which is 2% agreement where a count would expect 100%. It is recorded
as unidentified rather than given a name that fits a fiftieth of the population.
This commit is contained in:
2026-10-13 08:59:40 +02:00
parent f1f0b517a5
commit 55ea619b4c
4 changed files with 181 additions and 2 deletions
+116
View File
@@ -2572,6 +2572,122 @@ fn tie_records_are_packet_headers() {
); );
} }
/// The tie class header's `+0x0c` word is the three LOD record counts.
///
/// The word was read as R&C1's 64-light-slot *normals offset* and skipped for
/// two commits, which was the right thing to do with the wrong information: the
/// byte at `+0x0c` is not an offset at all. It is the **record count of the LOD
/// 0 block**, `+0x0d` of LOD 1 and `+0x0e` of LOD 2 -- verified on **1,864 of
/// 1,864** classes across all 27 levels. R&C1 carries the same quadruple at
/// `+0x20`; R&C2 has it here.
///
/// The check is not circular: the counts are read from the header, and
/// compared against the record tables the blocks themselves carry, which are
/// reached through `packets[]` and delimited by the terminator. Two independent
/// paths to the same number.
///
/// The fourth byte, `+0x0f`, is left unnamed. It is *not* the shader (ad-gif)
/// total, which disagrees on 1,827 of the classes measured here.
#[test]
fn tie_class_header_counts_records_per_lod() {
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};
let mut levels = 0usize;
let mut classes = 0usize;
let mut byte_matches = [0usize; 3];
let mut per_class_all_three = 0usize;
let mut fourth_bad = 0usize;
let mut fourth_range = (u8::MAX, 0u8);
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;
levels += 1;
for c in 0..t.count as usize {
let o = t.offset as usize + c * 0x20;
let e = ClassEntry::parse(&idx[o..o + 0x20]).expect("entry");
let base = e.offset_in_asset_wad as usize;
if base == 0 || base + 0x30 > core.len() {
continue;
}
let Ok(hdr) = TieClassHeader::parse(&core[base..base + 0x48]) else {
continue;
};
// The record tables, from the blocks themselves.
let mut counts = [0usize; 3];
let mut shader_total = 0usize;
for (lod, count) in counts.iter_mut().enumerate() {
let off = hdr.packets[lod];
if off <= 0 {
continue;
}
let s = base + off as usize;
if let Some(blk) = parse_tie_block(&core, s) {
let recs: Vec<&[u8]> = blk.header.records(&core[s..]).collect();
*count = recs.len();
for rec in recs {
if let Ok(r) = TieRecord::parse(rec) {
shader_total += r.shader_count as usize;
}
}
}
}
classes += 1;
for (lod, slot) in byte_matches.iter_mut().enumerate() {
*slot += usize::from(hdr.packet_counts[lod] as usize == counts[lod]);
}
per_class_all_three +=
usize::from((0..3).all(|l| hdr.packet_counts[l] as usize == counts[l]));
if hdr.unknown_0f as usize != shader_total {
fourth_bad += 1;
}
fourth_range.0 = fourth_range.0.min(hdr.unknown_0f);
fourth_range.1 = fourth_range.1.max(hdr.unknown_0f);
}
}
assert!(levels >= 25, "only {levels} levels were read");
assert_eq!(classes, 1_864, "expected 1,864 tie classes, got {classes}");
for (lod, &matched) in byte_matches.iter().enumerate() {
assert_eq!(
matched,
classes,
"LOD{lod} record count byte disagrees on {} of {classes}",
classes - matched
);
}
assert_eq!(
per_class_all_three, classes,
"all three bytes match on {} of {classes}",
per_class_all_three
);
eprintln!(
"tie class header +0x0c: LOD0/LOD1/LOD2 record counts on {classes} classes; \
+0x0f range {}..{}, not the shader total on {}",
fourth_range.0, fourth_range.1, fourth_bad
);
}
/// Byte positions that hold the same value in every supplied record. /// Byte positions that hold the same value in every supplied record.
fn constant_positions(records: &[&[u8]]) -> usize { fn constant_positions(records: &[&[u8]]) -> usize {
let Some(first) = records.first() else { let Some(first) = records.first() else {
+24 -2
View File
@@ -54,6 +54,21 @@ pub struct TieClassHeader {
/// They are strictly increasing on only 461 of 1,864 classes, so neither one /// They are strictly increasing on only 461 of 1,864 classes, so neither one
/// bounds the other -- the `0xf6` terminator does that instead. /// bounds the other -- the `0xf6` terminator does that instead.
pub packets: [i32; 3], pub packets: [i32; 3],
/// `0x0c`: the record table's length of each LOD, as three `u8` counts
/// followed by one unidentified byte.
///
/// The word was read as R&C1's 64-light-slot *normals offset* and skipped,
/// which was the right thing to do with the wrong information: the byte at
/// `+0x0c` is not an offset at all. It is the **record count of the LOD 0
/// block**, `+0x0d` of LOD 1, `+0x0e` of LOD 2, verified on **1,864 of
/// 1,864** classes on all 27 levels. R&C1 carries the same quadruple at
/// `+0x20`; R&C2 has it here.
///
/// `+0x0f` is not the shader (ad-gif) total, which disagrees on 1,827 of
/// the classes measured; it is left unnamed rather than guessed at.
pub packet_counts: [u8; 3],
/// `0x0f`: unidentified. Not the shader total.
pub unknown_0f: u8,
/// `0x10`, `0x14`, `0x18`: LOD switch distances in world units. /// `0x10`, `0x14`, `0x18`: LOD switch distances in world units.
pub near_dist: f32, pub near_dist: f32,
pub mid_dist: f32, pub mid_dist: f32,
@@ -73,8 +88,13 @@ impl TieClassHeader {
for slot in packets.iter_mut() { for slot in packets.iter_mut() {
*slot = b.i32("tie packet offset")?; *slot = b.i32("tie packet offset")?;
} }
// 0x0c: the 64 light-slot normals offset. Read past, not decoded. // 0x0c: the three LOD record counts and one unidentified byte.
b.skip(4, "tie normals offset")?; let packet_counts = [
b.u8("tie LOD0 record count")?,
b.u8("tie LOD1 record count")?,
b.u8("tie LOD2 record count")?,
];
let unknown_0f = b.u8("tie class word 0x0f")?;
let near_dist = b.f32("tie near distance")?; let near_dist = b.f32("tie near distance")?;
let mid_dist = b.f32("tie mid distance")?; let mid_dist = b.f32("tie mid distance")?;
let far_dist = b.f32("tie far distance")?; let far_dist = b.f32("tie far distance")?;
@@ -85,6 +105,8 @@ impl TieClassHeader {
let o_class = b.i32("tie class number")?; let o_class = b.i32("tie class number")?;
Ok(TieClassHeader { Ok(TieClassHeader {
packets, packets,
packet_counts,
unknown_0f,
near_dist, near_dist,
mid_dist, mid_dist,
far_dist, far_dist,
+33
View File
@@ -1722,6 +1722,39 @@ are verbatim, the grid is known, and three independent segmentation theories hav
failed on the whole population. Further probing without the loader's own logic is failed on the whole population. Further probing without the loader's own logic is
not going to converge. not going to converge.
## Tie class header: `+0x0c` is the three LOD record counts (2026-10-11)
**Range measured:** every tie class on the disc -- 1,864 classes, all 27 levels.
**Method:** read the word at class `+0x0c` as four bytes, and read the record
counts independently from the blocks themselves: follow `packets[lod]` to the
block, walk to the `0xf6` terminator, and count the 16-byte records the block
header's `data_offset` names. Two paths to the same number, so the check is not
circular.
**Result: the byte at `+0x0c` is the LOD 0 record count, `+0x0d` is LOD 1's and
`+0x0e` is LOD 2's, on 1,864 of 1,864 classes.** On every level the three
counts agree, including the classes where a LOD is absent -- a zero
`packets[lod]` gives a zero count, matching the byte.
This closes the file's oldest lie about the tie header: the comment in
`TieClassHeader::parse` said "0x0c: the 64 light-slot normals offset. Read past,
not decoded", inherited from R&C1's layout where the word *is* an offset. Here
it is not an offset at all -- it is three counts -- and R&C1 carries the same
quadruple at `+0x20`. The earlier note was the right action on the wrong
information: skipping an unknown field is correct, and the field was only
unknown because R&C1's map was applied to R&C2's bytes.
`TieClassHeader::packet_counts` carries the three, and
`tie_class_header_counts_records_per_lod` asserts the agreement.
**The fourth byte, `+0x0f`, is unidentified.** It ranges 1..15 across the disc.
The obvious reading, following R&C1's `packet_count[3]` being `texture_count`,
is a material count, and it is **not** the shader total: the sum of
`shader_count` over a class's records disagrees on 1,827 of 1,864 classes.
Left unnamed rather than guessed at -- a count that matches on 2% of the
population is not a count.
## Tie: `i16[3]` selects the texcoord encoding, which explains the residual ## Tie: `i16[3]` selects the texcoord encoding, which explains the residual
The texcoords are 12-bit fixed on 82.5% of vertices, not 100%. Grouped by the The texcoords are 12-bit fixed on 82.5% of vertices, not 100%. Grouped by the
+8
View File
@@ -275,6 +275,14 @@ Phase 1 hands over the extents; this phase works out what each one contains.
V4_8 parent arrays, weight running zero to one across each band); verifying V4_8 parent arrays, weight running zero to one across each band); verifying
them needs the **camera distance at the savestate** so the weight can be them needs the **camera distance at the savestate** so the weight can be
computed and compared against the rendered result. computed and compared against the rendered result.
- [x] The tie class header's **`+0x0c` word is the three LOD record
counts** — LOD 0 at `+0x0c`, LOD 1 at `+0x0d`, LOD 2 at `+0x0e` — verified
on **1,864 of 1,864** classes by reading the counts independently from
the blocks' own record tables. This was filed as "R&C1's normals offset,
read past, not decoded", which was the right action on the wrong map:
R&C1 carries the same quadruple at `+0x20` and it is not an offset on
R&C2. The fourth byte is unidentified and is *not* the shader total
(disagrees on 1,827 of 1,864).
- [x] `tie` — **fully chained.** `resolve_tie_class` walks both indirections: the - [x] `tie` — **fully chained.** `resolve_tie_class` walks both indirections: the
class table is in `core_index.bin`, and the entry's `offset_in_asset_wad` class table is in `core_index.bin`, and the entry's `offset_in_asset_wad`
addresses `core_data.bin` (not `core_data.wad`). All **1,864** tie classes addresses `core_data.bin` (not `core_data.wad`). All **1,864** tie classes