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.
This commit is contained in:
2026-10-09 16:08:30 +02:00
parent a02f79a6d1
commit 027373e65b
21 changed files with 8372 additions and 97 deletions
+266
View File
@@ -0,0 +1,266 @@
//! Stage 3: verify extracted files against a committed hash table.
//!
//! The table records sizes and SHA-1 values only — never content. Its purpose
//! is to let a test assert "this parse matches retail" rather than "this parse
//! matches whatever happened to be on my disk". See `AGENTS.md`.
use std::collections::BTreeMap;
use std::path::Path;
/// One expected file: path, size in bytes, SHA-1.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Expected {
pub path: String,
pub size: u64,
pub sha1: String,
}
/// Outcome of verifying a directory.
#[derive(Debug, Default)]
pub struct VerifyReport {
pub checked: usize,
pub missing: Vec<String>,
pub size_mismatch: Vec<(String, u64, u64)>,
pub hash_mismatch: Vec<(String, String, String)>,
/// Files present in the directory but absent from the table.
pub unexpected: Vec<String>,
}
impl VerifyReport {
pub fn ok(&self) -> bool {
self.missing.is_empty()
&& self.size_mismatch.is_empty()
&& self.hash_mismatch.is_empty()
&& self.unexpected.is_empty()
}
/// A short human-readable summary.
pub fn summary(&self) -> String {
if self.ok() {
return format!("OK: {} files match the recorded table.", self.checked);
}
let mut lines = vec![format!(
"FAILED: {} files checked, {} problems",
self.checked,
self.missing.len()
+ self.size_mismatch.len()
+ self.hash_mismatch.len()
+ self.unexpected.len()
)];
for p in self.missing.iter().take(10) {
lines.push(format!(" missing: {p}"));
}
for (p, want, got) in self.size_mismatch.iter().take(10) {
lines.push(format!(" size: {p}: expected {want}, got {got}"));
}
for (p, want, got) in self.hash_mismatch.iter().take(10) {
lines.push(format!(" hash: {p}: expected {want}, got {got}"));
}
for p in self.unexpected.iter().take(10) {
lines.push(format!(" unexpected: {p}"));
}
lines.join("\n")
}
}
/// Parse a table from TSV lines of `sha1<TAB>size<TAB>path`.
pub fn parse_table(text: &str) -> Vec<Expected> {
text.lines()
.filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
.filter_map(|line| {
let mut parts = line.split('\t');
let sha1 = parts.next()?.trim().to_string();
let size: u64 = parts.next()?.trim().parse().ok()?;
let path = parts.next()?.trim().to_string();
if sha1.len() != 40 || path.is_empty() {
return None;
}
Some(Expected { path, size, sha1 })
})
.collect()
}
/// Render entries as a TSV table, sorted by path.
pub fn render_table(entries: &[(String, u64, String)]) -> String {
let mut sorted: Vec<_> = entries.to_vec();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
let mut out = String::new();
for (path, size, sha1) in sorted {
out.push_str(&format!("{sha1}\t{size}\t{path}\n"));
}
out
}
/// Verify `dir` against `expected`.
pub fn verify(dir: &Path, expected: &[Expected]) -> VerifyReport {
let mut report = VerifyReport::default();
let present = walk(dir);
for e in expected {
report.checked += 1;
match std::fs::read(dir.join(&e.path)) {
Ok(data) => {
if data.len() as u64 != e.size {
report
.size_mismatch
.push((e.path.clone(), e.size, data.len() as u64));
continue;
}
let got = crate::identify::sha1_hex(&data);
if got != e.sha1 {
report
.hash_mismatch
.push((e.path.clone(), e.sha1.clone(), got));
}
}
Err(_) => report.missing.push(e.path.clone()),
}
}
let expected_set: BTreeMap<&str, ()> = expected.iter().map(|e| (e.path.as_str(), ())).collect();
for p in present {
if !expected_set.contains_key(p.as_str()) {
report.unexpected.push(p);
}
}
report
}
/// List files under `dir`, as `/`-separated relative paths, sorted.
fn walk(dir: &Path) -> Vec<String> {
let mut out = Vec::new();
collect(dir, dir, &mut out);
out.sort();
out
}
fn collect(root: &Path, dir: &Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect(root, &path, out);
} else if let Ok(rel) = path.strip_prefix(root) {
out.push(rel.to_string_lossy().replace('\\', "/"));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_table_and_skips_comments() {
let text = concat!(
"# a comment\n",
"da39a3ee5e6b4b0d3255bfef95601890afd80709\t0\tempty.bin\n",
"a9993e364706816aba3e25717850c26c9cd0d89d\t3\tabc.bin\n",
"\n",
);
let t = parse_table(text);
assert_eq!(t.len(), 2);
assert_eq!(t[0].path, "empty.bin");
assert_eq!(t[0].size, 0);
assert_eq!(t[1].path, "abc.bin");
assert_eq!(t[1].size, 3);
}
#[test]
fn ignores_malformed_rows() {
let text = "notasha1\t3\tx.bin\n\
a9993e364706816aba3e25717850c26c9cd0d89d\tnope\tx.bin\n\
a9993e364706816aba3e25717850c26c9cd0d89d\t3\t";
assert!(parse_table(text).is_empty());
}
#[test]
fn round_trips_a_rendered_table() {
let entries = vec![
(
"b.bin".to_string(),
2u64,
"a9993e364706816aba3e25717850c26c9cd0d89d".to_string(),
),
(
"a.bin".to_string(),
1u64,
"da39a3ee5e6b4b0d3255bfef95601890afd80709".to_string(),
),
];
let text = render_table(&entries);
assert!(
text.lines().next().unwrap().starts_with("da39a3ee"),
"sorted by path"
);
assert_eq!(parse_table(&text).len(), 2);
}
#[test]
fn verify_detects_a_missing_file() {
let dir = std::env::temp_dir().join(format!("gc-verify-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let expected = vec![Expected {
path: "nope.bin".to_string(),
size: 3,
sha1: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string(),
}];
let report = verify(&dir, &expected);
assert!(!report.ok());
assert_eq!(report.missing, vec!["nope.bin".to_string()]);
assert!(report.summary().contains("missing"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn verify_detects_a_hash_mismatch() {
let dir = std::env::temp_dir().join(format!("gc-verify-hash-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.bin"), b"xyz").unwrap();
let expected = vec![Expected {
path: "a.bin".to_string(),
size: 3,
// SHA-1 of "abc", not "xyz".
sha1: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string(),
}];
let report = verify(&dir, &expected);
assert_eq!(report.hash_mismatch.len(), 1);
assert!(report.summary().contains("hash"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn verify_detects_an_unexpected_file() {
let dir = std::env::temp_dir().join(format!("gc-verify-extra-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("extra.bin"), b"q").unwrap();
let report = verify(&dir, &[]);
assert_eq!(report.unexpected, vec!["extra.bin".to_string()]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn verify_passes_a_correct_directory() {
let dir = std::env::temp_dir().join(format!("gc-verify-ok-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.bin"), b"abc").unwrap();
let expected = vec![Expected {
path: "a.bin".to_string(),
size: 3,
sha1: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string(),
}];
let report = verify(&dir, &expected);
assert!(report.ok(), "{}", report.summary());
let _ = std::fs::remove_dir_all(&dir);
}
}