Parse the level container header, naming each level's four extents

The first typed structure read from real data. Every one of the 27 level
containers begins with the same 0x60-byte header, which turns the generic pair
table into named assets: data, sound_bank, gameplay and occlusion, plus three
chunk descriptors.

Two things about this header are worth stating because both are silent failures
rather than errors.

Sizes here are sector counts, not byte counts. The generic G/ pair table on the
same disc uses byte counts, so both readings type-check and the wrong one is off
by a factor of 2048. The tiling check settles it: read as sectors, the four
extents tile every one of the 27 containers exactly; read as bytes they overlap
each other.

Level id is not container order. LEVEL21.WAD reports id 30, so a level was cut
from the disc and its number left a gap. Anything keying off file order -- a
save slot, the planet select screen -- would mislabel that planet. There is also
a second trap in the same area: the ISO filesystem sorts names alphabetically, so
LEVEL10 precedes LEVEL2, and any tool needing order has to sort numerically.

The header's field at 0x04 is zero on all 27 levels. It is not the container
size despite looking like one, so the code reads it as a runtime field and takes
the container length from the ISO entry instead.

Measured across all 27 levels, three invariants now hold in tests: extents tile
in file order; extent coverage plus chunks reaches at least the end of the
container (21 levels land exactly on its length, 6 leave 134 to 689 sectors of
trailing data); and exactly one level's id differs from its container index. The
coverage assertion is >= rather than == on purpose -- 21 levels end one sector
past the file end because the header occupies sector 0, and asserting equality
would truncate a level's tail.

gameplay is left unparsed and that is recorded rather than hidden. It begins
"WAD" plus a per-level byte and does not decode with the WAD LZ77 decoder: all
27 levels produce plausible output for the first packet and then fail on a rule
the format's own encoder never breaks, which means the input is wrong rather
than the decoder. docs/plan/level-assets.md records what is ruled out and what
to try next.

66 unit tests, plus 7 integration tests against the real disc. The unpack hash
table is unchanged, so this adds parsing without altering the pipeline.
This commit is contained in:
2026-10-09 16:20:25 +02:00
parent 027373e65b
commit a9e43e39c1
6 changed files with 753 additions and 3 deletions
+124
View File
@@ -182,3 +182,127 @@ fn every_container_parses() {
);
}
}
/// The level container header is the first typed structure we parse from real
/// data, and the tiling invariant is the check that holds on every level rather
/// than one hand-picked sample.
#[test]
fn level_headers_tile_their_containers() {
let path = require_disc!();
let iso = gc_formats::iso9660::IsoImage::open(&path).expect("ISO should open");
let mut checked = 0;
for entry in iso.files.iter().filter(|e| e.name.starts_with("G/LEVEL")) {
let data = gc_formats::iso9660::read_at(&path, entry.lba, 0x60).expect("read header");
let h = gc_formats::level_wad::LevelHeader::parse(&data)
.unwrap_or_else(|e| panic!("{}: {e}", entry.name));
assert_eq!(h.header_size, 0x60, "{}", entry.name);
assert_eq!(
h.runtime_sector, 0,
"{}: runtime sector field should be 0 on disc",
entry.name
);
// Every present extent must start where the previous one ended.
let extents = h.ordered_extents();
for w in extents.windows(2) {
let (pn, p) = w[0];
let (nn, n) = w[1];
assert_eq!(
p.offset + p.size,
n.offset,
"{}: gap or overlap between {} and {}",
entry.name,
pn.name(),
nn.name()
);
}
// Nothing may fall outside the container. The last extent may end one
// sector past the file end through sector rounding, hence `+ 1`.
let container_sectors = (entry.size / 2048) as i32;
for (name, r) in &extents {
assert!(
r.offset >= 0 && r.offset + r.size <= container_sectors + 1,
"{}: {} extent {:?} is outside the container ({container_sectors} sectors)",
entry.name,
name.name(),
r
);
}
checked += 1;
}
assert_eq!(checked, 27, "expected 27 level containers");
}
/// Level ids are not file order: `LEVEL21.WAD` reports 30. Anything that keys
/// off the container index would silently mislabel that planet.
#[test]
fn level_ids_are_not_container_indices() {
let path = require_disc!();
let iso = gc_formats::iso9660::IsoImage::open(&path).expect("ISO should open");
// The ISO filesystem lists names alphabetically, so LEVEL10 sorts before
// LEVEL2. Order by the numeric suffix, which is what the container names
// mean.
let mut levels: Vec<(usize, &gc_formats::iso9660::DirEntry)> = iso
.files
.iter()
.filter(|e| e.name.starts_with("G/LEVEL"))
.map(|e| {
let n: usize = e
.name
.trim_start_matches("G/LEVEL")
.trim_end_matches(".WAD")
.parse()
.expect("level container names end in a number");
(n, e)
})
.collect();
levels.sort_by_key(|(n, _)| *n);
let mut mismatches = Vec::new();
for (index, (_, entry)) in levels.iter().enumerate() {
let data = gc_formats::iso9660::read_at(&path, entry.lba, 0x60).expect("read header");
let h = gc_formats::level_wad::LevelHeader::parse(&data).expect("parse");
if h.id as usize != index {
mismatches.push(format!(
"{}: id {} but is container {index}",
entry.name, h.id
));
}
}
// Exactly one level is out of step: the gap left by a removed level.
assert_eq!(
mismatches.len(),
1,
"expected exactly one level whose id differs from its container index, got {mismatches:?}"
);
assert!(
mismatches[0].contains("LEVEL21"),
"unexpected mismatch: {mismatches:?}"
);
}
/// Sizes in the level header are sector counts. If they were byte counts, the
/// extents could not tile a container this size, so this also guards against
/// "fixing" the parser to read them as bytes.
#[test]
fn level_extent_sizes_are_sector_counts() {
let path = require_disc!();
let iso = gc_formats::iso9660::IsoImage::open(&path).expect("ISO should open");
let entry = iso.find("G/LEVEL0.WAD").expect("level 0");
let data = gc_formats::iso9660::read_at(&path, entry.lba, 0x60).expect("read header");
let h = gc_formats::level_wad::LevelHeader::parse(&data).expect("parse");
// Sound bank occupies sectors 1..735 and data 735..7219.
assert_eq!(h.sound_bank.offset, 1);
assert_eq!(h.sound_bank.size, 734);
assert_eq!(h.data.offset, 735);
// Read as bytes, 734 would be under four sectors and the tiling would break.
assert_eq!(h.sound_bank.byte_len(), 734 * 2048);
}