Files
Going-Commando/crates/gc-formats/src/global_wad.rs
T
ReaperOfVeriod 027373e65b Add the disc pipeline: identify, unpack and verify
Phase 1 of the roadmap. The disc is now turned into verified asset files, and
the result is checked against a committed hash table rather than against
whatever happens to be on disk.

  gc-extract identify disc.iso        SCUS_972.68 v1.01 NTSC, ELF SHA-1 matches
  gc-extract unpack   disc.iso out/   5135 files, 4,500,106,960 bytes, 10.8s
  gc-extract verify   out/ table.tsv  OK: 5135 files match

All 92 G/*.WAD containers parse. MPEG1 and MPEG2 are raw video and pass
through whole; the other 90 are indexed containers whose extents are extracted
individually.

Formats recovered and written up in docs/formats/:

- iso9660.rs. R&C2 lists its assets in the filesystem, unlike R&C1, so a
  directory walk is the primary asset index. Three details that all fail
  quietly rather than loudly: directory records store every 32-bit field twice
  (little-endian then big-endian), the CD001 signature is at sector offset 1
  rather than 0x8001, and the directory flag at record offset 25 is
  authoritative where the identifier's trailing slash is not.

- global_wad.rs. Container headers hold a table of extents. All 92 indexed
  containers use {offset in sectors, size in bytes}. Measured, not assumed: the
  sector-count reading places every entry of all 27 LEVEL<n> and all 27
  AUDIO<n> containers out of range, while the byte reading places all of them
  correctly. SCENE<n> is the one exception, a flat sector list whose extents
  are sized by the next value.

- wad.rs. Insomniac's LZ77 stream: four flag-selected packet classes, with 0-3
  trailing literal bytes per packet carried in unused low bits. This is not a
  bit stream, which is why it resists the usual LZ77 reading.

- buf.rs. A bounds-checked reader, so untrusted 2003 disc data produces typed
  errors rather than panics.

gc-extract carries the three stages and a SHA-1 implementation with FIPS 180-4
test vectors, so identification works without pulling in a crypto dependency.

Verification: 54 unit tests, plus four integration tests that run only when
GC_DISC names a disc. Those four check identification against the recorded
values, cross-check the Rust identifier against tools/identify-disc.py, parse
all 92 containers, and confirm a full unpack matches the committed table. The
committed table is 316 KB of sizes and SHA-1s for all 5,135 files: hashes only,
no game content, so it is safe to version and it turns "matches retail" into
something a test can assert.

docs/plan/disc-pipeline.md records two wrong turns, because both are easy to
repeat and both failed by producing plausible wrong output rather than an
error: the container table was read backwards, and the LZ77 stream was modelled
as a bit stream. The first survived because it happened to be correct on the
one container used for manual checking.
2026-10-09 16:08:30 +02:00

381 lines
13 KiB
Rust

//! The asset containers under `G/` on the disc.
//!
//! Each `G/*.WAD` file begins with a typed header whose only universally
//! applicable fields are:
//!
//! ```text
//! offset size field
//! 0x00 4 header size in bytes
//! 0x04 4 sector count of this container
//! 0x08.. ... a table of {offset, size} pairs, both in SECTORS
//! ```
//!
//! The pair table is the important part. Sectors chain: entry `n`'s offset plus
//! its size is entry `n+1`'s offset, which is how the extractor recovers the
//! layout without a per-container parser. The pairs begin at `0x08` and run to
//! `header_size`, giving `(header_size - 8) / 8` entries.
//!
//! The payload behind each pair is **not** WAD-compressed. Insomniac's WAD LZ77
//! container is used elsewhere on the disc (inside level cores and MPEG
//! streams); these global containers store their sections raw and let the
//! table do the addressing.
//!
//! Which pair means what depends on the container — `ARMOR.WAD` is nine armor
//! entries then six wrench entries, each with a mesh and a texture range — but
//! the geometry above holds for every global on this disc, so the extractor
//! stays generic and per-container meaning is recovered later.
//!
//! Spec: `docs/formats/global-wad.md`.
use crate::buf::{Buf, Error, ErrorKind, Result};
/// Disc logical block size. All container offsets are in these units.
pub const SECTOR_SIZE: u32 = 2048;
/// A `{offset, size}` pair, both in sectors.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SectorRange {
pub offset: i32,
pub size: i32,
}
impl SectorRange {
/// Byte offset of the extent. `None` when the range is absent.
pub fn byte_offset(&self) -> Option<u64> {
if self.size <= 0 || self.offset < 0 {
return None;
}
Some(self.offset as u64 * SECTOR_SIZE as u64)
}
/// Byte length of the extent.
pub fn byte_len(&self) -> u64 {
if self.size <= 0 {
0
} else {
self.size as u64 * SECTOR_SIZE as u64
}
}
pub fn is_present(&self) -> bool {
self.size > 0
}
}
/// A `{offset, size}` pair where the offset is in sectors and the size is in
/// **bytes**.
///
/// The MPEG containers use this form: their entries are video and subtitle
/// streams whose length is not sector-aligned, so a sector count would round
/// and lose the tail. See `docs/formats/global-wad.md`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SectorByteRange {
pub offset: i32,
pub size: i32,
}
impl SectorByteRange {
pub fn byte_offset(&self) -> Option<u64> {
if self.size <= 0 || self.offset < 0 {
return None;
}
Some(self.offset as u64 * SECTOR_SIZE as u64)
}
pub fn byte_len(&self) -> u64 {
if self.size <= 0 {
0
} else {
self.size as u64
}
}
pub fn is_present(&self) -> bool {
self.size > 0
}
}
/// How a container's table should be read.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RangeKind {
/// Both fields are sector counts.
Sectors,
/// Offset in sectors, size in bytes. Used by every indexed container on
/// this disc except the scene containers.
SectorBytes,
/// A flat array of sector numbers, not pairs. The scene containers are
/// this: each value points at a sector, and the length of each extent comes
/// from the difference to the next entry. The last entry is bounded by the
/// end of the container.
SectorList,
}
impl RangeKind {
/// Is this container's table a flat list rather than pairs?
pub fn is_list(&self) -> bool {
matches!(self, RangeKind::SectorList)
}
}
impl RangeKind {
/// Classify a container from its header size.
///
/// On this disc **every** indexed container uses the sector/byte form: a
/// table of `{offset in sectors, size in bytes}` pairs. This was measured
/// rather than assumed. For all 96 containers that carry a pair table, the
/// sector/byte reading places every entry inside the file; the
/// sector/sector reading leaves entries running past the end of the file,
/// and on the 27 level containers and the 27 audio containers it places
/// *none* of them correctly.
///
/// The header size is the reliable discriminator because it is the field
/// every container agrees on, and it distinguishes the level containers
/// (0x60) from audio (0x1018), scene (0x137c) and the rest.
pub fn for_header(header_size: u32) -> RangeKind {
match header_size {
// The per-level scene containers hold a flat sector list rather
// than pairs: 0x137c is the header size the 27 SCENE<n>.WAD files
// share, and (0x137c - 8) is not a multiple of 8, so the bytes
// cannot be read as pairs at all.
0x137c => RangeKind::SectorList,
// No container on this disc uses the sector/sector form. Kept so a
// future disc revision can be checked rather than assumed.
_ => RangeKind::SectorBytes,
}
}
/// Classify by ISO file name, for callers that only have the path.
pub fn for_container(name: &str) -> RangeKind {
let stem = name.rsplit('/').next().unwrap_or(name);
let stem = stem.trim_end_matches(".WAD").to_ascii_uppercase();
if stem.starts_with("SCENE")
&& stem.len() > 5
&& stem[5..].chars().all(|c| c.is_ascii_digit())
{
// SCENE0..SCENE26 are the per-level containers. SCENE.WAD (no
// digits) is the global one and does use pairs.
return RangeKind::SectorList;
}
RangeKind::SectorBytes
}
}
/// A parsed global container header.
#[derive(Debug, Clone)]
pub struct GlobalWad {
pub header_size: u32,
/// Declared sector count of the whole container.
pub sector_count: u32,
/// The pair table, in declaration order.
pub entries: Vec<SectorRange>,
/// How the pair table was read.
pub kind: RangeKind,
}
impl GlobalWad {
/// Parse a container header, reading the table as sector-count pairs.
pub fn parse(bytes: &[u8]) -> Result<GlobalWad> {
GlobalWad::parse_with(bytes, RangeKind::SectorBytes)
}
/// Parse a container header with an explicit range interpretation.
///
/// Both interpretations read the same 8 bytes per entry; they differ only in
/// how the size field is scaled, which is a property of the container
/// rather than of the bytes.
pub fn parse_with(bytes: &[u8], kind: RangeKind) -> Result<GlobalWad> {
let mut b = Buf::new(bytes);
let header_size = b.u32("container header size")? as usize;
let sector_count = b.u32("container sector count")?;
if header_size < 8 || header_size > bytes.len() {
return Err(Error {
offset: 0,
context: "container header",
kind: ErrorKind::InvalidLength("container header size"),
});
}
// A pair table must divide evenly into 8-byte entries; a remainder
// means we have guessed the layout wrong and should not read garbage.
// A sector list is a flat run of 4-byte values and needs no such check.
let table_bytes = header_size - 8;
if !kind.is_list() && table_bytes % 8 != 0 {
return Err(Error {
offset: 0,
context: "container header",
kind: ErrorKind::Invalid("pair table is not a whole number of entries"),
});
}
let mut entries = Vec::with_capacity(table_bytes / 8);
if kind.is_list() {
// Each value is the first sector of one extent; the size runs to the
// next value, and the last runs to the end of the container.
let mut sectors = Vec::with_capacity(table_bytes / 4);
while b.pos() + 4 <= header_size {
sectors.push(b.i32("extent sector")?);
}
let total_sectors = (bytes.len() as u32).div_ceil(SECTOR_SIZE);
for (i, &start) in sectors.iter().enumerate() {
let end = sectors.get(i + 1).copied().unwrap_or(total_sectors as i32);
entries.push(SectorRange {
offset: start,
size: end - start,
});
}
} else {
while b.pos() + 8 <= header_size {
let offset = b.i32("entry offset")?;
let size = b.i32("entry size")?;
entries.push(SectorRange { offset, size });
}
}
Ok(GlobalWad {
header_size: header_size as u32,
sector_count,
entries,
kind,
})
}
/// Entries that point at real data, as `(index, byte offset, byte length)`.
pub fn present(&self) -> impl Iterator<Item = (usize, u64, u64)> + '_ {
self.entries
.iter()
.copied()
.enumerate()
.filter_map(|(i, r)| {
let len = match self.kind {
RangeKind::Sectors => r.byte_len(),
RangeKind::SectorList => r.byte_len(),
RangeKind::SectorBytes => SectorByteRange {
offset: r.offset,
size: r.size,
}
.byte_len(),
};
let off = r.byte_offset()?;
if len == 0 {
return None;
}
Some((i, off, len))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a synthetic container: header_size, sector_count, then pairs.
fn synthetic(entries: &[(i32, i32)]) -> Vec<u8> {
let header_size = (8 + entries.len() * 8) as u32;
let mut v = Vec::new();
v.extend_from_slice(&header_size.to_le_bytes());
v.extend_from_slice(&100u32.to_le_bytes());
for (o, s) in entries {
v.extend_from_slice(&o.to_le_bytes());
v.extend_from_slice(&s.to_le_bytes());
}
// Pad the body so byte_offset() reads land inside the buffer.
v.resize(header_size as usize + 4096, 0);
v
}
#[test]
fn parses_header_and_pairs() {
let wad = GlobalWad::parse(&synthetic(&[(1, 72), (73, 51), (124, 80)])).unwrap();
assert_eq!(wad.header_size, 32);
assert_eq!(wad.sector_count, 100);
assert_eq!(wad.entries.len(), 3);
assert_eq!(
wad.entries[0],
SectorRange {
offset: 1,
size: 72
}
);
assert_eq!(wad.entries[2].offset, 124);
}
#[test]
fn sector_ranges_convert_to_bytes() {
let r = SectorRange { offset: 3, size: 2 };
assert_eq!(r.byte_offset(), Some(3 * 2048));
assert_eq!(r.byte_len(), 2 * 2048);
assert!(r.is_present());
}
#[test]
fn absent_ranges_are_not_present() {
assert!(!SectorRange { offset: 0, size: 0 }.is_present());
assert!(!SectorRange {
offset: 5,
size: -1
}
.is_present());
assert_eq!(SectorRange { offset: 5, size: 0 }.byte_offset(), None);
assert_eq!(
SectorRange {
offset: -5,
size: 10
}
.byte_offset(),
None
);
}
#[test]
fn present_filters_zero_sizes() {
let wad = GlobalWad::parse(&synthetic(&[(1, 4), (5, 0), (5, 4)])).unwrap();
let present: Vec<usize> = wad.present().map(|(i, _, _)| i).collect();
assert_eq!(present, vec![0, 2]);
}
#[test]
fn present_scales_sizes_by_the_range_kind() {
let bytes = synthetic(&[(1, 100)]);
// The default for this disc treats the second field as a byte count.
let raw = GlobalWad::parse(&bytes).unwrap();
assert_eq!(raw.present().next().unwrap().2, 100);
// The sector-count form scales it up.
let sectors = GlobalWad::parse_with(&bytes, RangeKind::Sectors).unwrap();
assert_eq!(sectors.present().next().unwrap().2, 100 * 2048);
}
#[test]
fn rejects_a_header_size_beyond_the_buffer() {
let mut v = 9999u32.to_le_bytes().to_vec();
v.resize(16, 0);
assert!(GlobalWad::parse(&v).is_err());
}
#[test]
fn rejects_a_header_size_below_the_minimum() {
let v = 4u32.to_le_bytes().to_vec();
assert!(GlobalWad::parse(&v).is_err());
}
#[test]
fn rejects_a_table_that_does_not_divide_evenly() {
// header_size 13 -> table of 5 bytes, not a multiple of 8.
let mut v = 13u32.to_le_bytes().to_vec();
v.extend_from_slice(&0u32.to_le_bytes());
v.extend_from_slice(&[0u8; 5]);
let err = GlobalWad::parse(&v).unwrap_err();
assert!(matches!(err.kind, ErrorKind::Invalid(_)), "got {err:?}");
}
#[test]
fn empty_table_is_allowed() {
let mut v = 8u32.to_le_bytes().to_vec();
v.extend_from_slice(&10u32.to_le_bytes());
v.resize(4096, 0);
let wad = GlobalWad::parse(&v).unwrap();
assert!(wad.entries.is_empty());
}
}