From 94819f4a79bf6f345d606c532e4c0536fcc637e2 Mon Sep 17 00:00:00 2001 From: ReaperOfVeriod Date: Wed, 14 Oct 2026 03:36:03 +0200 Subject: [PATCH] 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. --- crates/gc-extract/tests/disc.rs | 113 ++++++++++++++++++++++++++++++ docs/formats/core-data-regions.md | 29 ++++++++ docs/roadmap.md | 7 ++ 3 files changed, 149 insertions(+) diff --git a/crates/gc-extract/tests/disc.rs b/crates/gc-extract/tests/disc.rs index c227c1d..a67606b 100644 --- a/crates/gc-extract/tests/disc.rs +++ b/crates/gc-extract/tests/disc.rs @@ -4045,6 +4045,7 @@ fn sound_bank_sblk_head_is_constant_apart_from_one_word() { let mut values: Vec = Vec::new(); let mut bodies: Vec = 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 = 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 = 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() + ); +} diff --git a/docs/formats/core-data-regions.md b/docs/formats/core-data-regions.md index e4633e1..457b196 100644 --- a/docs/formats/core-data-regions.md +++ b/docs/formats/core-data-regions.md @@ -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 is not the class count.** Asserted in `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`. diff --git a/docs/roadmap.md b/docs/roadmap.md index 38df58a..197839d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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, no chunk slot separates (best 0.375 against 0.018 chance, a dozen slots in 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 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