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.
488 lines
15 KiB
Rust
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
|
|
}
|
|
}
|