Files
Going-Commando/crates/gc-extract/src/main.rs
T
ReaperOfVeriod 8b98d388f7 Show region bounds in probe, which exposed an unnamed 6.2 MB region
Printing the region table next to the header fields makes the gap visible
without having to remember to consult core_layout, and it paid for itself
immediately.

On level 0 the collision region comes out 6216960 bytes, from 2615232 to
the first moby class at 8832192. Collision's own header declares a payload
of 903,248. So roughly 6.2 MB sits between the collision header and the
first moby class, and almost none of it is collision.

The successor rule is correct -- a region does end where the next one
begins -- but "the next" is not yet known there. collision..moby is
really collision + <something unnamed> .. moby. The name is wrong, not
the bound.

Candidates for the space, rough order of likelihood: moby and tie model
animation data, which is what moby_anim would need, then volumes, then
per-class runtime structures. Tfrag texture texels are already placed and
sit earlier in the lump.

Worth resolving before starting moby_anim, since that format is the most
likely tenant. Until then, any probe reading between the collision header
and the first moby class is reading something the region table does not
describe.
2026-10-11 03:03:46 +02:00

488 lines
15 KiB
Rust

//! `gc-extract` — the disc pipeline command line.
//!
//! Three subcommands, in the order they are normally used:
//!
//! ```text
//! gc-extract identify <disc.iso>
//! gc-extract unpack <disc.iso> <out-dir> [--table <table.tsv>]
//! gc-extract verify <out-dir> <table.tsv>
//! ```
//!
//! Only your own legally obtained disc is read. Nothing is written inside the
//! repository.
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use gc_extract::{identify, unpack, verify};
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(String::as_str) {
Some("identify") => run_identify(&args[1..]),
Some("unpack") => run_unpack(&args[1..]),
Some("verify") => run_verify(&args[1..]),
// Scratch helper used while reversing the level formats: decompress a
// byte range of an already-unpacked file with the WAD decoder.
Some("wad") => run_wad(&args[1..]),
Some("dump-texture") => run_dump_texture(&args[1..]),
Some("probe") => run_probe(&args[1..]),
Some("--help") | Some("-h") | Some("help") | None => {
usage();
ExitCode::SUCCESS
}
Some(other) => {
eprintln!("unknown subcommand `{other}`\n");
usage();
ExitCode::FAILURE
}
}
}
/// Group digits for readability. Rust's format syntax has no thousands
/// separator, so do it here rather than sprinkle the logic through the prints.
fn thousands(n: u64) -> String {
let s = n.to_string();
let mut out = String::with_capacity(s.len() + s.len() / 3);
for (i, c) in s.chars().enumerate() {
if i > 0 && (s.len() - i) % 3 == 0 {
out.push('_');
}
out.push(c);
}
out
}
fn usage() {
println!(
"gc-extract — Ratchet & Clank: Going Commando disc pipeline
identify <disc.iso>
Report serial, version and video mode, and hash the boot ELF.
unpack <disc.iso> <out-dir> [--table <file.tsv>]
Decompress every WAD on the disc into <out-dir>.
With --table, also write a SHA-1 table (sizes and hashes only).
verify <out-dir> <table.tsv>
Check <out-dir> against a previously written table.
probe <unpacked-dir> <level> [max-rows]
Dump a level's core header and class tables through the same parser the
rest of the project uses. Reach for this before reading core_index.bin
by hand: the index interleaves single-word region offsets with
count/offset pairs, and miscounting one shifts every field after it.
dump-texture <unpacked-dir> <level> <index> [mip] <out.ppm>
Decode one tfrag texture to a binary PPM, so a decode can be eyeballed
against the disc rather than trusted. Textures are 8-bit indexed; a wrong
CLUT remap is obvious and a wrong alpha scale is not, so look at it.
You supply the disc. Nothing here is redistributed with the project."
);
}
fn run_identify(args: &[String]) -> ExitCode {
if args.is_empty() {
eprintln!("usage: gc-extract identify <disc.iso>");
return ExitCode::FAILURE;
}
let path = Path::new(&args[0]);
match identify(path) {
Ok(info) => {
println!("Disc identification");
println!("{}", "=".repeat(64));
println!(" file {}", info.path);
println!(" size {} bytes", thousands(info.image_size));
println!(" volume id {}", info.volume_id);
println!(" serial {}", info.serial);
println!(
" version {}",
if info.version.is_empty() {
"(absent)"
} else {
&info.version
}
);
println!(
" video mode {}",
if info.vmode.is_empty() {
"(absent)"
} else {
&info.vmode
}
);
println!();
println!("Boot executable");
println!("{}", "-".repeat(64));
println!(
" size {} bytes",
thousands(info.boot_elf_size as u64)
);
println!(" SHA-1 {}", info.boot_elf_sha1);
println!();
println!("{}", "=".repeat(64));
println!(" {}", info.verdict());
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
fn run_unpack(args: &[String]) -> ExitCode {
if args.len() < 2 {
eprintln!("usage: gc-extract unpack <disc.iso> <out-dir> [--table <file.tsv>]");
return ExitCode::FAILURE;
}
let iso = PathBuf::from(&args[0]);
let out = PathBuf::from(&args[1]);
let mut table_path: Option<PathBuf> = None;
let mut i = 2;
while i < args.len() {
match args[i].as_str() {
"--table" => {
i += 1;
match args.get(i) {
Some(p) => table_path = Some(PathBuf::from(p)),
None => {
eprintln!("--table needs a path");
return ExitCode::FAILURE;
}
}
}
other => {
eprintln!("unexpected argument `{other}`");
return ExitCode::FAILURE;
}
}
i += 1;
}
let ident = match identify::identify(&iso) {
Ok(i) => i,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
println!("Disc: {} {} ({})", ident.serial, ident.version, ident.vmode);
let started = std::time::Instant::now();
println!("Unpacking into {} ...", out.display());
match unpack::unpack(&iso, &out) {
Ok(files) => {
let total: u64 = files.iter().map(|f| f.size).sum();
println!(
" {} files, {} bytes, in {:.1}s",
files.len(),
thousands(total),
started.elapsed().as_secs_f64()
);
if let Some(tp) = table_path {
let entries: Vec<(String, u64, String)> = files
.iter()
.map(|f| (f.path.clone(), f.size, f.sha1.clone()))
.collect();
let text = verify::render_table(&entries);
if let Err(e) = std::fs::write(&tp, text) {
eprintln!("error: cannot write {}: {e}", tp.display());
return ExitCode::FAILURE;
}
println!(" table written to {}", tp.display());
}
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
/// Decode one tfrag texture of one level and write it as a binary PPM.
///
/// A visual check matters here: every step of the decode is a lookup, so a
/// wrong CLUT remap, a wrong mip offset or a wrong pixel base still produces
/// a well-formed image — just the wrong one.
fn run_dump_texture(args: &[String]) -> ExitCode {
if args.len() < 5 {
eprintln!("usage: gc-extract dump-texture <unpacked-dir> <level> <index> [mip] <out.ppm>");
return ExitCode::FAILURE;
}
let dir = std::path::Path::new(&args[0]).join(format!("level{}", args[1]));
let index: usize = match args[2].parse() {
Ok(v) => v,
Err(e) => {
eprintln!("bad texture index: {e}");
return ExitCode::FAILURE;
}
};
// The optional mip argument sits between the index and the output path.
let (mip, out) = if args.len() >= 6 {
match args[3].parse() {
Ok(v) => (v, &args[5]),
Err(e) => {
eprintln!("bad mip level: {e}");
return ExitCode::FAILURE;
}
}
} else {
(0, &args[4])
};
let core = match std::fs::read(dir.join("core_data.bin")) {
Ok(d) => d,
Err(e) => {
eprintln!("cannot read core_data.bin: {e}");
return ExitCode::FAILURE;
}
};
let idx = match std::fs::read(dir.join("core_index.bin")) {
Ok(d) => d,
Err(e) => {
eprintln!("cannot read core_index.bin: {e}");
return ExitCode::FAILURE;
}
};
let gs = match std::fs::read(dir.join("gs_ram.bin")) {
Ok(d) => d,
Err(e) => {
eprintln!("cannot read gs_ram.bin: {e}");
return ExitCode::FAILURE;
}
};
let core_header = match gc_formats::level_core::LevelCoreHeader::parse(&idx) {
Ok(h) => h,
Err(e) => {
eprintln!("core header: {e}");
return ExitCode::FAILURE;
}
};
let table = match gc_formats::level_core::texture_table(&idx, core_header.tfrag_textures) {
Ok(t) => t,
Err(e) => {
eprintln!("texture table: {e}");
return ExitCode::FAILURE;
}
};
let entry = match table.get(index) {
Some(e) => *e,
None => {
eprintln!("texture {index} is outside the {} entry table", table.len());
return ExitCode::FAILURE;
}
};
let base = core_header.textures_base.max(0) as usize;
let mips = match gc_formats::texture::mip_images(&core[base..], &gs, &entry) {
Ok(m) => m,
Err(e) => {
eprintln!("mip chain: {e}");
return ExitCode::FAILURE;
}
};
let img = match mips.get(mip) {
Some(m) => m,
None => {
eprintln!("mip {mip} is outside the {} level chain", mips.len());
return ExitCode::FAILURE;
}
};
let tex = match img.decode() {
Ok(t) => t,
Err(e) => {
eprintln!("decode: {e}");
return ExitCode::FAILURE;
}
};
// Binary PPM, P6, RGB over a magenta background so alpha is visible.
let mut out_bytes = format!("P6\n{} {}\n255\n", tex.width, tex.height).into_bytes();
for px in tex.rgba.chunks_exact(4) {
let rgb = if px[3] == 0 {
[255, 0, 255]
} else {
[px[0], px[1], px[2]]
};
out_bytes.extend_from_slice(&rgb);
}
if let Err(e) = std::fs::write(out, &out_bytes) {
eprintln!("cannot write {out}: {e}");
return ExitCode::FAILURE;
}
println!(
"wrote {out}: {}x{} texture {index} mip {mip}, {} x {} pixels, {} colours",
tex.width, tex.height, entry.width, entry.height, entry.palette
);
ExitCode::SUCCESS
}
fn run_probe(args: &[String]) -> ExitCode {
if args.len() < 2 {
eprintln!("usage: gc-extract probe <unpacked-dir> <level> [max-rows]");
return ExitCode::FAILURE;
}
let dir = Path::new(&args[0]);
let level: u32 = match args[1].parse() {
Ok(v) => v,
Err(e) => {
eprintln!("bad level: {e}");
return ExitCode::FAILURE;
}
};
let max_rows: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(8);
let p = match gc_extract::probe_level(dir, level) {
Ok(p) => p,
Err(e) => {
eprintln!("error: {e:#}");
return ExitCode::FAILURE;
}
};
println!(
"level{level}: core_data {} bytes, core_index {} bytes",
p.core_len, p.index_len
);
println!("core header");
println!("{}", "-".repeat(64));
for (k, v) in &p.header {
println!(" {k:<22} {v:>14}");
}
println!();
println!("core_data regions (end = next region's start, never core_data.len())");
println!("{}", "-".repeat(64));
for r in &p.regions {
let (lo, hi) = (r.start, r.start + p.core_len);
let _ = (lo, hi);
match r.end {
Some(e) => println!(
" {:<12} {:>10} .. {:<10} {:>10} bytes",
r.name,
r.start,
e,
e - r.start
),
None => println!(
" {:<12} {:>10} .. {:<10} {:>10}",
r.name, r.start, "?", "end unresolved"
),
}
}
for t in &p.classes {
let sane = t.entries.iter().filter(|e| e.in_core(p.core_len)).count();
println!();
println!(
"{} classes: {} entries at stride 0x{:x}, {sane} with geometry in core_data",
t.name,
t.entries.len(),
t.stride
);
println!("{}", "-".repeat(64));
println!(
" {:>4} {:>12} {:>8} {:>10} {:>10}",
"idx", "geom_offset", "o_class", "unknown_8", "unknown_c"
);
for e in t.entries.iter().take(max_rows) {
println!(
" {:>4} {:>12} {:>8} {:>10} {:>10}{}",
e.index,
e.geometry_offset,
e.o_class,
e.unknown_8,
e.unknown_c,
if e.in_core(p.core_len) {
""
} else {
" <- outside core_data"
}
);
}
if t.entries.len() > max_rows {
println!(" ... {} more", t.entries.len() - max_rows);
}
}
ExitCode::SUCCESS
}
fn run_wad(args: &[String]) -> ExitCode {
if args.len() < 4 {
eprintln!("usage: gc-extract wad <file> <offset> <size> <out>");
return ExitCode::FAILURE;
}
let data = match std::fs::read(&args[0]) {
Ok(d) => d,
Err(e) => {
eprintln!("error: {}", e);
return ExitCode::FAILURE;
}
};
let off: usize = match args[1].parse() {
Ok(v) => v,
Err(e) => {
eprintln!("error: bad offset: {e}");
return ExitCode::FAILURE;
}
};
let sz: usize = match args[2].parse() {
Ok(v) => v,
Err(e) => {
eprintln!("error: bad size: {e}");
return ExitCode::FAILURE;
}
};
if off + sz > data.len() {
eprintln!(
"error: range {off}..{} is outside the {} byte file",
off + sz,
data.len()
);
return ExitCode::FAILURE;
}
match gc_formats::wad::decompress(&data[off..off + sz]) {
Ok(out) => {
if let Err(e) = std::fs::write(&args[3], &out) {
eprintln!("error: cannot write {}: {e}", args[3]);
return ExitCode::FAILURE;
}
println!("{} bytes -> {} bytes -> {}", sz, out.len(), args[3]);
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
fn run_verify(args: &[String]) -> ExitCode {
if args.len() < 2 {
eprintln!("usage: gc-extract verify <out-dir> <table.tsv>");
return ExitCode::FAILURE;
}
let dir = PathBuf::from(&args[0]);
let table = match std::fs::read_to_string(&args[1]) {
Ok(t) => t,
Err(e) => {
eprintln!("error: cannot read {}: {e}", args[1]);
return ExitCode::FAILURE;
}
};
let expected = verify::parse_table(&table);
if expected.is_empty() {
eprintln!("error: {} contained no usable rows", args[1]);
return ExitCode::FAILURE;
}
let report = verify::verify(&dir, &expected);
println!("{}", report.summary());
if report.ok() {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}