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:
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user