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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user