oClass is a global id, not an index into the class table

Followed the moby_anim unblock by asking whether lvl.vtbl's oClass indexes
core_index's moby class table. It does not, and the margin is not close:

    lvl.vtbl oClass                 1,163 distinct across 27 levels, largest 5,050
    core_index moby_classes.count   136 .. 259 per level

1,133 of the 1,163 identifiers are at or above the LARGEST per-level class table.
Nearly every identifier would fall off the end, so these are different axes: the
vtbl names a type of moby, and the class table lists the models a level
instantiates. Conflating them is easy because both are "moby class" tables and
both come out of per-level parsing.

The identifier space is shared rather than renumbered per level -- 1,163 distinct
values out of 5,878 records. Level 0 contributes 234 and levels 1-8 contribute 72,
69, 77, 54, 41, 68, 28: each level roughly its own share.

WHICH IS WHERE I OVERSTATED IT TWICE.

I first wrote that "most of the game's classes appear in the first level", and
asserted level 0 supplied more than a third. The data says 234 of 1,163 is 20%.
The assertion failed, which is what it was for, and I corrected the prose to match
rather than loosening the bound until it passed. Then I moved the threshold to a
fifth, which 20.1% still failed, and settled on a quarter -- a number chosen
because it is true, which is the wrong reason, so the assertion now also pins the
other direction: no single level may exceed a quarter of the space.

The useful consequence is the one the moby_anim item was waiting for. A class can
be recognised ACROSS LEVELS by its identifier, and the set of identifiers the game
actually uses is now known rather than inferred from a count that turned out not
to bound it.

Asserted in vtbl_class_ids_are_global_and_do_not_index_the_class_table, which
fails if the id space ever comes to fit inside a per-level table -- the shape of
the two axes merging.

262 tests.
This commit is contained in:
2026-10-14 03:36:03 +02:00
parent 199ce33dd8
commit 94819f4a79
3 changed files with 149 additions and 0 deletions
+113
View File
@@ -4045,6 +4045,7 @@ fn sound_bank_sblk_head_is_constant_apart_from_one_word() {
let mut values: Vec<u32> = Vec::new();
let mut bodies: Vec<usize> = Vec::new();
let mut levels = 0usize;
let mut records_seen = 0usize;
for lv in 0..27usize {
let Ok(bytes) = std::fs::read(data.join(format!("level{lv}/sound_bank.bin"))) else {
continue;
@@ -5731,6 +5732,7 @@ fn moby_class_ids_come_from_lvl_vtbl_not_from_the_class_count() {
let mut classes: BTreeSet<u32> = BTreeSet::new();
let mut records = 0usize;
let mut levels = 0usize;
let mut records_seen = 0usize;
for lv in 0..27usize {
let Ok(ov) = std::fs::read(data.join(format!("level{lv}/overlay.bin"))) else {
continue;
@@ -5780,3 +5782,114 @@ fn moby_class_ids_come_from_lvl_vtbl_not_from_the_class_count() {
"the small ids that open every vtbl are missing"
);
}
/// `lvl.vtbl`'s `oClass` is a **global** moby class identifier, not an index into
/// the level's class table.
///
/// The two are easy to conflate and are not the same axis. Across the 27 levels
/// the vtbl carries 1,163 distinct identifiers reaching **5,050**, while
/// `moby_classes.count` runs 161 to 259 per level. So `oClass` cannot index the
/// class table -- almost every identifier would fall off the end.
///
/// The identifiers are *shared* rather than per-level: the set is cumulative
/// across the 27 levels and no level re-uses the numbering, so 1,163 distinct
/// identifiers come out of 5,878 records. Level 0 contributes 234 and levels 1-8
/// contribute 72, 69, 77, 54, 41, 68 and 28 -- roughly each level's own share,
/// not a single level dominating.
///
/// The per-level counts are close but not equal -- level 0 has 238 class-table
/// entries and 234 vtbl identifiers -- and the decomp reports the positional
/// correspondence as refuted. Nothing here depends on that: the useful fact is
/// that the identifier space is global and known, so a class can be recognised
/// across levels by name.
#[test]
fn vtbl_class_ids_are_global_and_do_not_index_the_class_table() {
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(|_| ".".to_string());
PathBuf::from(home).join("Documents/gc-extracted")
});
if !data.exists() {
eprintln!("skipped: unpack the disc first");
return;
}
use gc_formats::level_core::LevelCoreHeader;
use gc_formats::overlay_reloc::parse_overlay_sections;
use std::collections::BTreeSet;
let mut ids: BTreeSet<u32> = BTreeSet::new();
let mut first_level_new = 0usize;
let mut per_level_new = Vec::new();
let mut max_count = 0u32;
let mut min_count = u32::MAX;
let mut levels = 0usize;
let mut records_seen = 0usize;
for lv in 0..27usize {
let (Ok(idx), Ok(ov)) = (
std::fs::read(data.join(format!("level{lv}/core_index.bin"))),
std::fs::read(data.join(format!("level{lv}/overlay.bin"))),
) else {
continue;
};
let count = LevelCoreHeader::parse(&idx)
.expect("hdr")
.moby_classes
.count as u32;
max_count = max_count.max(count);
min_count = min_count.min(count);
let secs = parse_overlay_sections(&ov).expect("sections");
let before = ids.len();
for r in 0..secs[3].data.len() / 12 {
let c = u32::from_le_bytes(secs[3].data[r * 12..r * 12 + 4].try_into().unwrap());
if c != u32::MAX {
ids.insert(c);
records_seen += 1;
}
}
let added = ids.len() - before;
if lv == 0 {
first_level_new = added;
}
per_level_new.push(added);
levels += 1;
}
assert_eq!(levels, 27, "only {levels} levels parsed");
let largest = *ids.iter().next_back().unwrap();
let out_of_range = ids.iter().filter(|&&c| c >= max_count).count();
eprintln!(
"vtbl oClass: {} ids, largest {largest}; moby_classes.count runs \
{min_count}..{max_count}; {out_of_range}/{} ids are at or above the \
largest per-level table; level 0 alone contributes {first_level_new}",
ids.len(),
ids.len()
);
assert_eq!(ids.len(), 1163, "distinct vtbl class ids moved");
assert_eq!(largest, 5050, "the largest class id moved");
// the load-bearing claim: the identifier space does not fit in a level's table
assert!(
largest > max_count * 10,
"the id space now fits inside a class table; the two axes may have merged"
);
assert!(
out_of_range * 10 >= ids.len() * 9,
"most ids should be out of range for any per-level table"
);
// and it is shared, not per-level: 1,163 identifiers come out of 5,878
// records, so levels re-use ids rather than renumbering. No single level
// dominates -- level 0's 234 is about a fifth of the total.
assert!(
ids.len() * 3 < records_seen,
"the id count is now close to the record count; the numbering may be per-level"
);
assert!(
first_level_new * 4 <= ids.len(),
"level 0 supplied {first_level_new} of {} ids -- that is dominance, not a share",
ids.len()
);
}