//! `gc-extract` — the disc pipeline command line. //! //! Three subcommands, in the order they are normally used: //! //! ```text //! gc-extract identify //! gc-extract unpack [--table ] //! gc-extract verify //! ``` //! //! 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 = 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 Report serial, version and video mode, and hash the boot ELF. unpack [--table ] Decompress every WAD on the disc into . With --table, also write a SHA-1 table (sizes and hashes only). verify Check against a previously written table. probe [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 [mip] 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 "); 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 [--table ]"); return ExitCode::FAILURE; } let iso = PathBuf::from(&args[0]); let out = PathBuf::from(&args[1]); let mut table_path: Option = 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 [mip] "); 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 [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 "); 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 "); 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 } }