[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:
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user