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
+1
View File
@@ -4,5 +4,6 @@ pub mod iso9660;
pub mod level_core;
pub mod level_data;
pub mod level_wad;
pub mod texture;
pub mod tfrag;
pub mod wad;
+370
View File
@@ -0,0 +1,370 @@
//! Level textures: 8-bit palette indices plus a 256-entry CLUT.
//!
//! Spec: `docs/formats/texture.md`.
//!
//! Every level texture is a rectangular grid of **palette indices**, not
//! pixels: `width * height` bytes at `core_data[textures_base + data_offset]`.
//! The colour comes from a CLUT of 256 RGBA entries (1024 bytes) in GS RAM at
//! `palette * 0x100`. Both halves are indexed by the same byte, so nothing
//! about the layout is inferred — it is a straight lookup.
//!
//! Two conventions have to be respected, and both are PS2 hardware details
//! rather than choices:
//!
//! * **CSM1 CLUT order.** Within each 32-entry group the two middle 8-entry
//! blocks are swapped. The remap is an involution, so the same function
//! converts either way.
//! * **Alpha runs 0..=0x80, not 0..=255.** `0x80` is fully opaque, and
//! anything above it clamps to opaque.
use crate::buf::{Error, ErrorKind, Result};
use crate::level_core::TextureEntry;
/// PS2 CSM1 CLUT order to linear: within each 32-entry group, swap the two
/// middle 8-entry blocks. An involution.
pub fn clut_index(i: u32) -> u32 {
if ((i >> 3) & 1) != ((i >> 4) & 1) {
i ^ 0x18
} else {
i
}
}
/// PS2 alpha 0..=0x80 to 0..=0xff. `0x80` is opaque; above that clamps.
pub fn scale_alpha(a: u8) -> u8 {
if a < 0x80 {
a * 2
} else {
0xff
}
}
/// A decoded RGBA8 image.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Texture {
pub width: u32,
pub height: u32,
/// `width * height * 4` bytes, row-major from the top.
pub rgba: Vec<u8>,
}
/// One stored image: palette indices plus the CLUT, exactly as on disc.
#[derive(Debug, Clone, Copy)]
pub struct IndexedImage<'a> {
pub width: u32,
pub height: u32,
pub indices: &'a [u8],
/// 1024 bytes, 256 RGBA entries in CSM1 order.
pub clut: &'a [u8],
}
impl IndexedImage<'_> {
/// Look every index up in the CLUT.
pub fn decode(&self) -> Result<Texture> {
decode_indexed8(self.indices, self.width, self.height, self.clut)
}
}
/// Decode `width * height` palette indices against a 256-entry CLUT.
pub fn decode_indexed8(indices: &[u8], width: u32, height: u32, clut: &[u8]) -> Result<Texture> {
let n = width as usize * height as usize;
if indices.len() < n {
return Err(Error {
offset: 0,
context: "indexed texture data too small",
kind: ErrorKind::OutOfBounds {
needed: n,
available: indices.len(),
},
});
}
if clut.len() < 1024 {
return Err(Error {
offset: 0,
context: "texture palette too small",
kind: ErrorKind::OutOfBounds {
needed: 1024,
available: clut.len(),
},
});
}
let mut pal = [[0u8; 4]; 256];
for (i, p) in pal.iter_mut().enumerate() {
let at = clut_index(i as u32) as usize * 4;
let e = &clut[at..at + 4];
*p = [e[0], e[1], e[2], scale_alpha(e[3])];
}
let mut rgba = Vec::with_capacity(n * 4);
for &ix in &indices[..n] {
rgba.extend_from_slice(&pal[ix as usize]);
}
Ok(Texture {
width,
height,
rgba,
})
}
/// The base level of a tfrag texture: `width * height` indices at `data_offset`.
pub fn base_image<'a>(
textures: &'a [u8],
gs_ram: &'a [u8],
e: &TextureEntry,
) -> Result<IndexedImage<'a>> {
let (px, clut) = pixels_and_clut(textures, gs_ram, e)?;
let (w, h) = (e.width as usize, e.height as usize);
Ok(IndexedImage {
width: w as u32,
height: h as u32,
indices: px,
clut,
})
}
/// Every mip level of a tfrag texture, base level first.
///
/// All tfrag textures are square, so the levels halve evenly. Levels 0 and 1
/// live in the texture block; levels 2 and 3 are resident in GS RAM and are
/// addressed by the entry's `mipmap` and `pad` fields.
pub fn mip_images<'a>(
textures: &'a [u8],
gs_ram: &'a [u8],
e: &TextureEntry,
) -> Result<Vec<IndexedImage<'a>>> {
let (px0, clut) = pixels_and_clut(textures, gs_ram, e)?;
let (w, h) = (e.width as usize, e.height as usize);
let levels = e.ty as usize;
if !(1..=4).contains(&levels) {
return Err(Error {
offset: 0,
context: "tfrag texture level count outside 1..=4",
kind: ErrorKind::InvalidLength("tfrag texture level count"),
});
}
let sub = |data: &'a [u8], at: usize, need: usize, what: &'static str| -> Result<&'a [u8]> {
let end = at
.checked_add(need)
.filter(|&e| e <= data.len())
.ok_or(Error {
offset: at,
context: what,
kind: ErrorKind::OutOfBounds {
needed: need,
available: data.len().saturating_sub(at),
},
})?;
Ok(&data[at..end])
};
let mut out = Vec::with_capacity(levels);
for level in 0..levels {
let (lw, lh) = (w >> level, h >> level);
if lw == 0 || lh == 0 {
return Err(Error {
offset: 0,
context: "mip level smaller than one texel",
kind: ErrorKind::InvalidLength("mip level size"),
});
}
let need = lw * lh;
let indices = match level {
0 => px0,
1 => sub(
textures,
e.data_offset as usize + w * h,
need,
"mip 1 pixels",
)?,
2 if e.mipmap >= 0 => sub(gs_ram, e.mipmap as usize * 0x100, need, "mip 2 pixels")?,
3 if e.pad >= 0 => sub(gs_ram, e.pad as usize * 0x100, need, "mip 3 pixels")?,
_ => {
return Err(Error {
offset: 0,
context: "mip level without a gs_ram block",
kind: ErrorKind::InvalidLength("mip level"),
})
}
};
out.push(IndexedImage {
width: lw as u32,
height: lh as u32,
indices,
clut,
});
}
Ok(out)
}
/// Decode the whole mip chain.
pub fn decode_tfrag_mips(textures: &[u8], gs_ram: &[u8], e: &TextureEntry) -> Result<Vec<Texture>> {
mip_images(textures, gs_ram, e)?
.iter()
.map(IndexedImage::decode)
.collect()
}
fn pixels_and_clut<'a>(
textures: &'a [u8],
gs_ram: &'a [u8],
e: &TextureEntry,
) -> Result<(&'a [u8], &'a [u8])> {
if e.width <= 0 || e.height <= 0 || e.data_offset < 0 || e.palette < 0 {
return Err(Error {
offset: 0,
context: "texture entry has no pixels",
kind: ErrorKind::Invalid("texture entry has no pixels"),
});
}
let need = e.width as usize * e.height as usize;
let end = (e.data_offset as usize)
.checked_add(need)
.filter(|&x| x <= textures.len())
.ok_or(Error {
offset: e.data_offset as usize,
context: "texture pixels run past the texture block",
kind: ErrorKind::OutOfBounds {
needed: need,
available: textures.len().saturating_sub(e.data_offset.max(0) as usize),
},
})?;
let clut_at = e.palette as usize * 0x100;
let clut_end = clut_at
.checked_add(1024)
.filter(|&x| x <= gs_ram.len())
.ok_or(Error {
offset: clut_at,
context: "texture palette runs past gs_ram",
kind: ErrorKind::OutOfBounds {
needed: 1024,
available: gs_ram.len().saturating_sub(clut_at),
},
})?;
Ok((
&textures[e.data_offset as usize..end],
&gs_ram[clut_at..clut_end],
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clut_order_swaps_the_middle_blocks() {
// Bits 3 and 4 differ inside a 32-entry group, so the remap applies.
assert_eq!(clut_index(0x00), 0x00);
assert_eq!(clut_index(0x08), 0x10);
assert_eq!(clut_index(0x10), 0x08);
assert_eq!(clut_index(0x1f), 0x1f);
// Outside that pattern the index is unchanged.
assert_eq!(clut_index(0x07), 0x07);
assert_eq!(clut_index(0x18), 0x18);
// The remap is an involution, so it round-trips.
for i in 0..256u32 {
assert_eq!(clut_index(clut_index(i)), i);
}
}
#[test]
fn alpha_is_scaled_from_eight_bit_halves() {
assert_eq!(scale_alpha(0), 0);
assert_eq!(scale_alpha(0x40), 0x80);
assert_eq!(scale_alpha(0x7f), 0xfe);
assert_eq!(scale_alpha(0x80), 0xff);
assert_eq!(scale_alpha(0xff), 0xff);
}
/// A 2x1 image whose two texels select distinct CLUT entries.
fn fixture() -> (Vec<u8>, Vec<u8>) {
let mut clut = vec![0u8; 1024];
for i in 0..256u32 {
let at = clut_index(i) as usize * 4;
clut[at] = i as u8;
clut[at + 1] = 255 - i as u8;
clut[at + 2] = 7;
clut[at + 3] = 0x80;
}
(vec![3, 200], clut)
}
#[test]
fn decodes_indices_through_the_clut() {
let (idx, clut) = fixture();
let t = decode_indexed8(&idx, 2, 1, &clut).unwrap();
assert_eq!(t.rgba.len(), 8);
// Entry 3 is red, entry 200 has a flipped red and green.
assert_eq!(&t.rgba[0..4], &[3, 252, 7, 0xff]);
assert_eq!(&t.rgba[4..8], &[200, 55, 7, 0xff]);
}
#[test]
fn short_data_is_an_error_not_a_panic() {
let (_idx, clut) = fixture();
assert!(decode_indexed8(&[0, 1], 4, 4, &clut).is_err());
assert!(decode_indexed8(&[0; 16], 2, 2, &clut[..512]).is_err());
assert!(decode_indexed8(&[0; 3], 2, 1, &clut).is_ok());
}
#[test]
fn a_square_entry_halves_through_its_mip_chain() {
// 64x64 base, mip 1 immediately after it, mips 2 and 3 in gs_ram.
let mut textures = vec![0u8; 64 * 64 * 2];
for (i, b) in textures.iter_mut().enumerate() {
*b = (i % 251) as u8;
}
let mut gs = vec![0u8; 0x4000];
for (i, b) in gs.iter_mut().enumerate().take(0x3000) {
*b = (i % 253) as u8;
}
let e = TextureEntry {
data_offset: 0,
width: 64,
height: 64,
ty: 4,
palette: 8,
mipmap: 16,
pad: 24,
};
let mips = mip_images(&textures, &gs, &e).unwrap();
assert_eq!(mips.len(), 4);
let sizes: Vec<(u32, u32)> = mips.iter().map(|m| (m.width, m.height)).collect();
assert_eq!(sizes, [(64, 64), (32, 32), (16, 16), (8, 8)]);
// Level 0 is the base block; level 1 follows it immediately.
assert_eq!(mips[0].indices, &textures[0..64 * 64]);
assert_eq!(mips[1].indices, &textures[64 * 64..64 * 64 + 32 * 32]);
// Levels 2 and 3 come from gs_ram at `mipmap * 0x100` and `pad * 0x100`.
assert_eq!(mips[2].indices, &gs[16 * 0x100..16 * 0x100 + 16 * 16]);
assert_eq!(mips[3].indices, &gs[24 * 0x100..24 * 0x100 + 8 * 8]);
}
#[test]
fn an_absent_high_mip_is_an_error() {
let textures = vec![0u8; 64 * 64];
let gs = vec![0u8; 0x4000];
let e = TextureEntry {
data_offset: 0,
width: 64,
height: 64,
ty: 4,
palette: 8,
mipmap: 16,
pad: -1,
};
assert!(mip_images(&textures, &gs, &e).is_err());
}
#[test]
fn a_palette_past_gs_ram_is_an_error() {
let textures = vec![0u8; 64 * 64];
let gs = vec![0u8; 0x100];
let e = TextureEntry {
data_offset: 0,
width: 64,
height: 64,
ty: 3,
palette: 900,
mipmap: -1,
pad: -1,
};
assert!(base_image(&textures, &gs, &e).is_err());
}
}