diff --git a/crates/gc-extract/tests/disc.rs b/crates/gc-extract/tests/disc.rs index a67606b..857b301 100644 --- a/crates/gc-extract/tests/disc.rs +++ b/crates/gc-extract/tests/disc.rs @@ -5893,3 +5893,94 @@ fn vtbl_class_ids_are_global_and_do_not_index_the_class_table() { ids.len() ); } + +/// `lvl.camvtbl` and `lvl.sndvtbl` decode at the record widths the decomp +/// documents, and neither leaves a partial record behind. +/// +/// With `lvl.vtbl` (section 3, three-word records, covered separately) these are +/// the other two dispatch tables the level program carries. Their shapes: + +/// section width record +/// camvtbl 5 camera id, InitCamera, ActivateCamera, UpdateCamera, +/// ExitCamera +/// sndvtbl 2 sound id, UpdateSound_ +/// +/// Measured over all 27 levels: camera tables are 40 to 52 words (8 to 10 +/// records), sound tables 0 to 8 words (0 to 4 records), and both divide by +/// their record width with **zero** remainder. That is the load-bearing check -- +/// a wrong width would leave a remainder on most levels rather than none. +/// +/// The identifier spaces are small and global: **14 distinct camera ids** (max +/// `0x1B`) and **6 distinct sound ids** (max `0x07`), against 217 and 32 records +/// respectively, so ids repeat across levels rather than being renumbered -- the +/// same sharing as the moby table. +#[test] +fn camera_and_sound_dispatch_tables_decode_at_their_documented_widths() { + 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::overlay_reloc::parse_overlay_sections; + use std::collections::BTreeSet; + + // (section, record width, label) + let tables = [(4usize, 5usize, "camvtbl"), (5, 2, "sndvtbl")]; + let mut out = Vec::new(); + for (idx, width, label) in tables { + let mut ids: BTreeSet = BTreeSet::new(); + let mut records = 0usize; + let mut remainder_seen = 0usize; + let mut levels = 0usize; + for lv in 0..27usize { + let Ok(ov) = std::fs::read(data.join(format!("level{lv}/overlay.bin"))) else { + continue; + }; + let secs = parse_overlay_sections(&ov).expect("sections"); + assert!(secs.len() > idx, "level {lv}: only {} sections", secs.len()); + let words = secs[idx].data.len() / 4; + if words % width != 0 { + remainder_seen += 1; + } + for r in 0..words / width { + let base = r * width * 4; + let id = u32::from_le_bytes(secs[idx].data[base..base + 4].try_into().unwrap()); + if id == u32::MAX { + break; + } + ids.insert(id); + records += 1; + } + levels += 1; + } + assert_eq!(levels, 27, "{label}: only {levels} levels parsed"); + assert_eq!( + remainder_seen, 0, + "{label}: {remainder_seen} levels do not divide by a {width}-word record" + ); + let max = *ids.iter().next_back().unwrap(); + eprintln!( + "{label}: {records} records, {} distinct ids, max {max:#x}", + ids.len() + ); + assert!(records > 0, "{label}: no records"); + assert!( + ids.len() < records, + "{label}: ids are not shared between levels" + ); + out.push((label, ids.len(), max, records)); + } + + assert_eq!(out[0].1, 14, "distinct camera id count moved"); + assert_eq!(out[0].2, 0x1B, "the largest camera id moved"); + assert_eq!(out[1].1, 6, "distinct sound id count moved"); + assert_eq!(out[1].2, 0x07, "the largest sound id moved"); +} diff --git a/docs/formats/core-data-regions.md b/docs/formats/core-data-regions.md index 457b196..dc710de 100644 --- a/docs/formats/core-data-regions.md +++ b/docs/formats/core-data-regions.md @@ -3149,3 +3149,50 @@ The useful consequence is for `moby_anim`, which has been blocked on class recor 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`. + + +## All three dispatch tables now decode + +The level program carries three dispatch tables in its own sections. With the +Phase 3 section parser they are all readable, and each divides exactly by its +record width: + + section file width record + vtbl lvl.vtbl 3 oClass, UpdateMoby_, auxiliary + camvtbl lvl.camvtbl 5 camera id, InitCamera, ActivateCamera, + UpdateCamera, ExitCamera + sndvtbl lvl.sndvtbl 2 sound id, UpdateSound_ + + camvtbl 217 records, 14 distinct ids, max 0x1B + sndvtbl 32 records, 6 distinct ids, max 0x07 + +The load-bearing check is the **zero remainder**: a wrong record width would leave +a partial record on most levels rather than none, and it leaves none on any of the +27 for either table. Section sizes across the levels are 40..52 words for the +camera tables and 0..8 for the sound tables. + +Level 0's camera records, so the shape is visible rather than inferred: + + [0] 0x0 0x4263b0 0x4233f8 0x4263b8 0x426478 + [1] 0x4 0x426c78 0x427008 0x4266e8 0x4270c0 + [2] 0x5 0x427ad8 0x427628 0x427ae0 0x427b38 + +and its sound records: + + [0] 0x0 0x42ac18 + [1] 0x1 0x42ad98 + [2] 0x3 0x42b038 + +Both identifier spaces are **global and shared**: 217 camera records carry only 14 +distinct ids and 32 sound records only 6, so levels re-use ids rather than +renumbering -- the same sharing as the moby table's 1,163 distinct out of 5,822. + +This is what makes the tables usable rather than merely present. A per-level +dispatch entry is now addressable by a game-wide id, and the handler addresses are +function entries in that level's `.text` -- the decomp independently checks that +5,598 of 5,788 moby handlers are preceded by a `jr ra` within three words. + +The camera and sound handler addresses fall inside section 6 of the same overlay, +which the working disassembler can read directly. + +Asserted in `camera_and_sound_dispatch_tables_decode_at_their_documented_widths`. diff --git a/docs/roadmap.md b/docs/roadmap.md index 197839d..4277f54 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] **All three dispatch tables decode.** `lvl.vtbl` (3 words), `lvl.camvtbl` + (5) and `lvl.sndvtbl` (2) each divide their sections **exactly**, with zero + remainder on all 27 levels -- which is the check that would fail if a record + width were wrong. 217 camera records carry 14 distinct global ids (max + `0x1B`), 32 sound records carry 6 (max `0x07`), so both spaces are shared + across levels rather than renumbered. Handler addresses are function + entries in the same overlay's `.text`, which is now readable. 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