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.
371 lines
12 KiB
Rust
371 lines
12 KiB
Rust
//! 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());
|
|
}
|
|
}
|