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.
381 lines
13 KiB
Rust
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());
|
|
}
|
|
}
|