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");
}