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 values: Vec<u32> = Vec::new();
let mut bodies: Vec<usize> = Vec::new(); let mut bodies: Vec<usize> = Vec::new();
let mut levels = 0usize; let mut levels = 0usize;
let mut records_seen = 0usize;
for lv in 0..27usize { for lv in 0..27usize {
let Ok(bytes) = std::fs::read(data.join(format!("level{lv}/sound_bank.bin"))) else { let Ok(bytes) = std::fs::read(data.join(format!("level{lv}/sound_bank.bin"))) else {
continue; 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 classes: BTreeSet<u32> = BTreeSet::new();
let mut records = 0usize; let mut records = 0usize;
let mut levels = 0usize; let mut levels = 0usize;
let mut records_seen = 0usize;
for lv in 0..27usize { for lv in 0..27usize {
let Ok(ov) = std::fs::read(data.join(format!("level{lv}/overlay.bin"))) else { let Ok(ov) = std::fs::read(data.join(format!("level{lv}/overlay.bin"))) else {
continue; 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" "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()
);
}
+29
View File
@@ -3120,3 +3120,32 @@ with nothing above them is what a broad property looks like, not a field. The
positive finding stands and is now usable: **the class-id space is known, and it positive finding stands and is now usable: **the class-id space is known, and it
is not the class count.** Asserted in is not the class count.** Asserted in
`moby_class_ids_come_from_lvl_vtbl_not_from_the_class_count`. `moby_class_ids_come_from_lvl_vtbl_not_from_the_class_count`.
## `oClass` is a global identifier, and not an index into the class table
Two axes that are easy to conflate, and are not the same thing:
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.** So `oClass` cannot index that table -- nearly every identifier would fall
off the end. They are different axes: the vtbl names a *type* of moby, the class
table lists the *models* a level instantiates.
The identifier space is shared rather than renumbered per level: 1,163 distinct
values come out of 5,878 records, and level 0 contributes 234 while levels 1-8
contribute 72, 69, 77, 54, 41, 68 and 28 -- each level roughly its own share, not
one level dominating. An earlier note here said "most of the game's classes appear
in the first level"; the data says 20%, and the assertion is written to fail if
that ever changes.
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 consequence is for `moby_anim`, which has been blocked on class records:
a class can now be recognised **across levels** by its identifier, and the set of
identifiers the game actually uses is known rather than guessed. Asserted in
`vtbl_class_ids_are_global_and_do_not_index_the_class_table`.
+7
View File
@@ -407,6 +407,13 @@ Phase 1 hands over the extents; this phase works out what each one contains.
the chunk class-ID artifact now retracted. Re-scored against the real set, the chunk class-ID artifact now retracted. Re-scored against the real set,
no chunk slot separates (best 0.375 against 0.018 chance, a dozen slots in no chunk slot separates (best 0.375 against 0.018 chance, a dozen slots in
the same band). Asserted. the same band). Asserted.
- [x] `lvl.vtbl`'s **`oClass` is a global identifier**, not an index into the level's
class table: 1,163 distinct ids across the game, largest 5,050, against
`moby_classes.count` of 136..259. **1,133 of 1,163 are out of range** for any
per-level table. The two are different axes -- a type id, and the models a
level instantiates -- and the space is shared rather than renumbered
(1,163 distinct out of 5,878 records; level 0 supplies 20%, not a
majority). A class can now be recognised across levels by id. Asserted.
- [ ] `moby_anim` — **still open, and the obvious reading is ruled out.** An - [ ] `moby_anim` — **still open, and the obvious reading is ruled out.** An
instance table would have to scale with the level's moby class count, but instance table would have to scale with the level's moby class count, but
**per-chunk-type record counts are level-independent** -- the same types **per-chunk-type record counts are level-independent** -- the same types