Decode level textures: 8-bit indices through a CSM1 palette

Adds texture.rs and makes the pipeline write gs_ram.bin, which it had
been skipping. Nothing could be decoded before this: the CLUTs live
there.

A level texture is not pixels, it is palette indices -- width*height
bytes at core_data[textures_base + data_offset], with colour coming from
a 256-entry CLUT at gs_ram[palette * 0x100]. gs_ram is a byte range in
the level's data extent header at offset 0x10, 518,656 bytes on level 0.

Two PS2 conventions have to be honoured and neither is a design choice:
within each 32-entry CLUT group the two middle 8-entry blocks are
swapped (bits 3 and 4 of the index are exchanged, an involution), and
alpha runs 0..=0x80 rather than 0..=255 with 0x80 fully opaque.

The mip chain splits across two buffers: levels 0 and 1 are in the
texture block with level 1 immediately after level 0, and levels 2 and 3
are resident in gs_ram at mipmap*0x100 and pad*0x100. Tfrag textures are
square so every level halves evenly.

Verification is two-layered, because every step here is a table lookup
and a wrong CLUT remap still produces a well-formed image of the wrong
thing. All 1,595 tfrag textures across all 27 levels decode; 96.8% are
fully opaque and 96.3% use more than eight colours. On top of that,
`gc-extract dump-texture` writes any texture or mip level as a PPM.
Level 0 texture 0 comes back as a clean bevelled metal panel with rivets
and specular highlights, and texture 74 as a vent grille whose punched
windows are fully transparent. That tool is worth keeping: it is the
cheapest way to tell a decode that is merely well-formed from one that
is right.

The hash table is regenerated for the 27 new gs_ram.bin files, now 5,186
files and 5,685,541,143 bytes.
This commit is contained in:
2026-10-10 02:30:56 +02:00
parent 48d9506cf1
commit 29fdf89f53
8 changed files with 693 additions and 15 deletions
+124
View File
@@ -25,6 +25,7 @@ fn main() -> ExitCode {
// 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("--help") | Some("-h") | Some("help") | None => {
usage();
ExitCode::SUCCESS
@@ -65,6 +66,11 @@ fn usage() {
verify <out-dir> <table.tsv>
Check <out-dir> against a previously written table.
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."
);
}
@@ -190,6 +196,124 @@ fn run_unpack(args: &[String]) -> ExitCode {
}
}
/// 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_wad(args: &[String]) -> ExitCode {
if args.len() < 4 {
eprintln!("usage: gc-extract wad <file> <offset> <size> <out>");
+13
View File
@@ -158,6 +158,19 @@ pub fn unpack(iso_path: &Path, out_dir: &Path) -> Result<Vec<Extracted>, String>
);
parts += 1;
}
// Graphics Synthesizer RAM holds the CLUTs the tfrag
// texture table's palette indices point into, so it
// is needed to decode any level texture.
let gs = data_header.part(LevelDataPart::GsRam);
if let Ok(bytes) = gs.slice(data_extent, data_extent.len()) {
write_file(
out_dir,
&format!("{stem}/gs_ram.bin"),
bytes,
&mut written,
);
parts += 1;
}
}
Err(e) => eprintln!(" {stem}: data extent header did not parse: {e}"),
}