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:
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user