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