All three dispatch tables decode, and the check that matters is the remainder

Finished the dispatch-table work by decoding the two I had not looked at. The
level program carries three, and each divides its section EXACTLY by its record
width:

    section  file         width  record
    vtbl     lvl.vtbl         3  oClass, UpdateMoby_<oClass>, auxiliary
    camvtbl  lvl.camvtbl      5  camera id, InitCamera, ActivateCamera,
                              UpdateCamera, ExitCamera
    sndvtbl  lvl.sndvtbl      2  sound id, UpdateSound_<id>

    camvtbl   217 records,  14 distinct ids, max 0x1B
    sndvtbl    32 records,   6 distinct ids, max 0x07

Zero remainder on all 27 levels for both. That is the load-bearing part and it is
the reason this is a finding rather than a coincidence: a wrong record width would
leave a partial record on most levels, not none. Section sizes run 40..52 words
for the camera tables and 0..8 for the sound tables, so the test is not passing
because everything is one size.

Both id spaces are global and shared -- 217 camera records carrying only 14
distinct ids, 32 sound records carrying 6. Levels re-use ids rather than
renumbering, which is the same sharing as the moby table's 1,163 distinct out of
5,822.

Why this is more than parsing. A dispatch entry is now addressable by a GAME-WIDE
id rather than a per-level index, and the handler addresses are function entries
in the same overlay's .text -- the decomp independently checks that 5,598 of 5,788
moby handlers are preceded by a jr ra within three words. With the disassembler
now working on that section, a handler can be read. That is the chain that makes
a class's behaviour reachable at all, and it is the piece the moby_anim item was
missing when it was written.

Asserted in camera_and_sound_dispatch_tables_decode_at_their_documented_widths.

263 tests.
This commit is contained in:
2026-10-14 03:38:45 +02:00
parent 94819f4a79
commit d611026f07
3 changed files with 145 additions and 0 deletions
+91
View File
@@ -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_<id>
///
/// 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<u32> = 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");
}
+47
View File
@@ -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_<oClass>, auxiliary
camvtbl lvl.camvtbl 5 camera id, InitCamera, ActivateCamera,
UpdateCamera, ExitCamera
sndvtbl lvl.sndvtbl 2 sound id, UpdateSound_<id>
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`.
+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,
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