[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

@@ -122,6 +122,20 @@ enum PakCommands {
/// Entry name-hash, e.g. `0x7c96296c` /// Entry name-hash, e.g. `0x7c96296c`
hash: String, hash: String,
}, },
/// Decode every T8aD texture in the pak (direct, RATC-nested, and LSTA
/// frames) to PNG — our decoder's output, for A/B against the running game.
Textures {
/// Path to the `.pak` index
pak: PathBuf,
/// Output directory for the PNGs (created if missing)
output: PathBuf,
/// Print per-texture size classification (linear-fit vs tiled-fit).
#[arg(long)]
verbose: bool,
/// Experiment: treat T8aD pixel data as Xenos-tiled and de-tile it.
#[arg(long)]
detile: bool,
},
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@@ -199,8 +213,11 @@ async fn main() -> Result<()> {
} }
}, },
Commands::Pak { cmd } => match cmd { Commands::Pak { cmd } => match cmd {
PakCommands::List { pak, idxd_only } => cmd_pak_list(&pak, idxd_only), PakCommands::List { pak, idxd_only } => cmd_pak_list(&pak, idxd_only),
PakCommands::Dump { pak, hash } => cmd_pak_dump(&pak, &hash), PakCommands::Dump { pak, hash } => cmd_pak_dump(&pak, &hash),
PakCommands::Textures { pak, output, verbose, detile } => {
cmd_pak_textures(&pak, &output, verbose, detile)
}
}, },
} }
} }
@@ -833,3 +850,459 @@ fn cmd_pak_dump(pak: &Path, hash_str: &str) -> Result<()> {
} }
Ok(()) Ok(())
} }
// ── pak textures ─────────────────────────────────────────────────────────────
/// Turn a child name into a filesystem-safe fragment.
fn safe_name(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
c
} else {
'_'
}
})
.collect()
}
#[derive(Default)]
struct TexStats {
written: usize,
skipped: usize,
linear_fit: usize,
tiled_fit: usize,
neither: usize,
}
fn be32_at(b: &[u8], off: usize) -> u32 {
u32::from_be_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]])
}
fn t8ad_header_size(type_field: u32) -> Option<usize> {
match type_field {
1 => Some(64),
2 => Some(84),
3 => Some(104),
4 => Some(124),
15 => Some(344),
_ => None,
}
}
/// Reconstruct an image whose storage is 2D raster tiles of `tw×th` in ROW-major
/// tile order, each raster internally, with **partial edge tiles** (the last
/// column/row may be narrower/shorter — total storage is exactly `w*h`). Storage
/// pixel offset of (x,y):
/// sum_{r<ty} w*rowH(r) + tx*tw*rowH(ty) + yin*tileW(tx) + xin
fn reconstruct_tiles(rgba: &[u8], w: usize, h: usize, tw: usize, th: usize, col: bool) -> Option<Vec<u8>> {
reconstruct_tiles_pad(rgba, w, h, tw, th, col, false)
}
/// As `reconstruct_tiles`, but when `pad`, every tile is stored at its FULL
/// `tw×th` size (the surface height/width is padded up to a whole number of
/// tiles in storage), rather than the edge tiles being clipped.
fn reconstruct_tiles_pad(rgba: &[u8], w: usize, h: usize, tw: usize, th: usize, col: bool, pad: bool) -> Option<Vec<u8>> {
if tw == 0 || th == 0 || tw > w && th > h {
return None;
}
if pad {
let cols = w.div_ceil(tw);
let mut out = vec![0u8; w * h * 4];
for y in 0..h {
for x in 0..w {
let (tx, ty) = (x / tw, y / th);
let (xin, yin) = (x % tw, y % th);
let tile = if col {
tx * h.div_ceil(th) + ty
} else {
ty * cols + tx
};
let src = (tile * tw * th + yin * tw + xin) * 4;
let dst = (y * w + x) * 4;
if src + 4 <= rgba.len() {
out[dst..dst + 4].copy_from_slice(&rgba[src..src + 4]);
}
}
}
return Some(out);
}
let row_h = |ty: usize| th.min(h - ty * th);
let tile_w = |tx: usize| tw.min(w - tx * tw);
// Prefix sum of full-width tile-rows (for row-major storage order).
let rows = h.div_ceil(th);
let mut row_start = vec![0usize; rows + 1];
for r in 0..rows {
row_start[r + 1] = row_start[r] + w * row_h(r);
}
let mut out = vec![0u8; w * h * 4];
for y in 0..h {
for x in 0..w {
let (tx, ty) = (x / tw, y / th);
let (xin, yin) = (x % tw, y % th);
let src_px = if col {
// Column-major tile order (down-then-right); full columns before
// tx are tw*h each, tiles above in this column are tile_w*th.
tx * tw * h + tile_w(tx) * (ty * th + yin) + xin
} else {
// Row-major tile order (right-then-down).
row_start[ty] + tx * tw * row_h(ty) + yin * tile_w(tx) + xin
};
let src = src_px * 4;
let dst = (y * w + x) * 4;
if src + 4 <= rgba.len() {
out[dst..dst + 4].copy_from_slice(&rgba[src..src + 4]);
}
}
}
Some(out)
}
/// Total variation of an RGBA image: sum of abs differences (RGB) between
/// horizontally and vertically adjacent pixels. Lower = smoother = more natural.
fn total_variation(rgba: &[u8], w: usize, h: usize) -> u64 {
let mut tv = 0u64;
let px = |x: usize, y: usize, c: usize| rgba[(y * w + x) * 4 + c] as i64;
for y in 0..h {
for x in 0..w {
for c in 0..3 {
if x + 1 < w {
tv += (px(x, y, c) - px(x + 1, y, c)).unsigned_abs();
}
if y + 1 < h {
tv += (px(x, y, c) - px(x, y + 1, c)).unsigned_abs();
}
}
}
}
tv
}
/// Sweep candidate tile sizes/orders, rank by total variation, print the best.
fn sweep_tile_layouts(rgba: &[u8], w: u32, h: u32, stem: &str) {
let (wu, hu) = (w as usize, h as usize);
let divisors = |n: usize| -> Vec<usize> {
(1..=n).filter(|d| n % d == 0).collect()
};
let _ = divisors;
let baseline = total_variation(rgba, wu, hu);
let mut results: Vec<(u64, usize, usize, bool)> = Vec::new();
let tws = [128usize, 256];
// Fine range of tile heights (incl. non-divisors — reconstruct handles partial rows).
let ths = [
64usize, 96, 112, 128, 144, 160, 176, 192, 200, 208, 216, 224, 232, 240, 248, 256, 264, 272,
288, 320, 384, 512,
];
for &tw in tws.iter().filter(|&&t| t < wu) {
for &th in ths.iter().filter(|&&t| t <= hu) {
for col in [false] {
if let Some(img) = reconstruct_tiles(rgba, wu, hu, tw, th, col) {
results.push((total_variation(&img, wu, hu), tw, th, col));
}
}
}
}
results.sort_by_key(|r| r.0);
println!(" {} {}x{} baseline TV {}", stem, w, h, baseline);
for (tv, tw, th, col) in results.iter().take(8) {
println!(
" TV {:>12} tile {}x{} {} ({:.1}% of baseline)",
tv,
tw,
th,
if *col { "col" } else { "row" },
100.0 * *tv as f64 / baseline as f64
);
}
}
/// Classify one T8aD slice by whether its available payload matches a linear
/// (`w*h*4`) or Xenos-tiled (`align32(w)*align32(h)*4`) surface, optionally
/// de-tiling it, and write a PNG. `stem` labels the output file.
fn emit_t8ad(
slice: &[u8],
hash: u32,
stem: &str,
output: &Path,
detile: bool,
verbose: bool,
stats: &mut TexStats,
) -> Result<()> {
use sylpheed_formats::texture::{detile as xenos_detile, X360TextureFormat};
if slice.len() < 0x40 || &slice[0..4] != b"T8aD" {
return Ok(());
}
let w = be32_at(slice, 0x14);
let h = be32_at(slice, 0x18);
let type_field = be32_at(slice, 0x1c);
if !(1..=4096).contains(&w) || !(1..=4096).contains(&h) {
return Ok(());
}
let header = match t8ad_header_size(type_field) {
Some(x) => x,
None => {
stats.skipped += 1;
return Ok(());
}
};
let avail = slice.len().saturating_sub(header);
let linear_need = (w as usize) * (h as usize) * 4;
let align32 = |v: u32| ((v + 31) / 32) * 32;
let tiled_need = (align32(w) as usize) * (align32(h) as usize) * 4;
let tag = if avail >= tiled_need && tiled_need != linear_need {
stats.tiled_fit += 1;
"TILED-fit"
} else if avail >= linear_need {
stats.linear_fit += 1;
"linear-fit"
} else {
stats.neither += 1;
"?short"
};
if verbose {
println!(
" {:08x} {:<34} {:>4}x{:<4} ty{:<2} hdr{:<3} avail {:>8} lin {:>8} tiled {:>8} [{}]",
hash, stem, w, h, type_field, header, avail, linear_need, tiled_need, tag
);
}
// Produce RGBA. When --detile, run the shared Xenos de-tiler over the
// A8R8G8B8 texels first (zero-padding the source up to the tiled surface
// size, since the payload is stored exactly w*h*4), then channel-swap.
let want_detile = detile && tiled_need != linear_need;
let argb: Vec<u8> = if want_detile {
let mut src = slice[header..].to_vec();
src.resize(tiled_need, 0);
match xenos_detile(&src, w, h, X360TextureFormat::A8R8G8B8) {
Ok(v) => v,
Err(_) => {
stats.skipped += 1;
return Ok(());
}
}
} else if avail >= linear_need {
slice[header..header + linear_need].to_vec()
} else {
stats.skipped += 1;
return Ok(());
};
// Default path: de-tile from 256×256 row-major tiles (the production decode).
// Skipped when an experiment env var is driving an alternative reconstruction.
let experiment = ["XTILE", "XDESTRIP", "XDETILE_W", "XREINTERPRET_PITCH", "XSCORE"]
.iter()
.any(|k| std::env::var(k).is_ok());
let argb = if experiment || want_detile {
argb
} else {
sylpheed_formats::t8ad::detile_256(&argb, w as usize, h as usize)
};
// A8R8G8B8 → RGBA8.
let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4];
for (px, out) in argb.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;
out[2] = b;
out[3] = a;
}
// Debug: treat the whole payload as a DW-wide Xbox-tiled surface (XDETILE_W=256):
// reinterpret the linear stream as DW×(N/DW), zero-pad to the 32-aligned tiled
// size, and run the exact Xenos de-tiler. Output is emitted at DW×(N/DW).
if let Ok(v) = std::env::var("XDETILE_W") {
if let Ok(dw) = v.parse::<u32>() {
if dw > 0 {
use sylpheed_formats::texture::{detile as xd, X360TextureFormat as XF};
let total = (rgba.len() / 4) as u32;
let dh = total / dw;
let pad_w = ((dw + 31) / 32) * 32;
let pad_h = ((dh + 31) / 32) * 32;
let mut src = rgba.clone();
src.resize((pad_w as usize) * (pad_h as usize) * 4, 0);
if let Ok(det) = xd(&src, dw, dh, XF::A8R8G8B8) {
let out = output.join(format!("{hash:08x}_{stem}_detW{dw}_{dw}x{dh}.png"));
image::save_buffer(&out, &det, dw, dh, image::ExtendedColorType::Rgba8).ok();
stats.written += 1;
return Ok(());
}
}
}
}
// Debug: reassemble wide textures stored as vertical strips of a fixed
// width (XDESTRIP=256). Strips are concatenated in storage; strip s spans
// output columns [s*sw, s*sw+cur_sw). All strips are full width except the
// last (remainder). Pixel(x,y) lives at storage[s*sw*h + y*cur_sw + xin].
if let Ok(v) = std::env::var("XDESTRIP") {
if let Ok(sw) = v.parse::<u32>() {
if sw > 0 && w > sw {
let (wu, hu, swu) = (w as usize, h as usize, sw as usize);
let mut fixed = vec![0u8; wu * hu * 4];
for y in 0..hu {
for x in 0..wu {
let s = x / swu;
let xin = x % swu;
let cur_sw = swu.min(wu - s * swu);
let src = (s * swu * hu + y * cur_sw + xin) * 4;
let dst = (y * wu + x) * 4;
if src + 4 <= rgba.len() {
fixed[dst..dst + 4].copy_from_slice(&rgba[src..src + 4]);
}
}
}
rgba = fixed;
}
}
}
// Debug: sweep tile (tw,th) × {row,col} order, score each reconstruction by
// total variation (natural images are smooth → low TV), print the ranking.
if std::env::var("XSCORE").is_ok() && w > 400 && h > 300 {
sweep_tile_layouts(&rgba, w, h, stem);
return Ok(());
}
// Debug: 2D raster-tile de-tiler. XTILE="tw,th[,c]" — tiles are tw×th, stored
// in row-major (or column-major with `c`) tile order, each raster internally.
// Requires w%tw==0 & h%th==0.
if let Ok(v) = std::env::var("XTILE") {
let parts: Vec<&str> = v.split(',').collect();
let tw: usize = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
let th: usize = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
let col = parts.iter().any(|p| *p == "c");
let pad = parts.iter().any(|p| *p == "p");
if tw > 0 && th > 0 {
if let Some(fixed) =
reconstruct_tiles_pad(&rgba, w as usize, h as usize, tw, th, col, pad)
{
rgba = fixed;
}
}
}
// Debug: reinterpret the linear pixel stream at an arbitrary pitch to probe
// the storage layout of wide textures (XREINTERPRET_PITCH=256 etc.).
let (mut ow, mut oh) = (w, h);
if let Ok(p) = std::env::var("XREINTERPRET_PITCH") {
if let Ok(pitch) = p.parse::<u32>() {
if pitch > 0 {
let total = (rgba.len() / 4) as u32;
ow = pitch;
oh = total / pitch;
rgba.truncate((ow as usize) * (oh as usize) * 4);
}
}
}
let suffix = if want_detile { "_detiled" } else { "" };
let out = output.join(format!("{hash:08x}_{stem}_{ow}x{oh}{suffix}.png"));
image::save_buffer(&out, &rgba, ow, oh, image::ExtendedColorType::Rgba8)
.with_context(|| format!("writing PNG {}", out.display()))?;
stats.written += 1;
Ok(())
}
/// Decode every T8aD in a pak (direct entries, RATC-nested children, LSTA
/// frames) to PNG — our decoder's exact output — for A/B against the game.
fn cmd_pak_textures(pak: &Path, output: &Path, verbose: bool, detile: bool) -> Result<()> {
use sylpheed_formats::{lsta, ratc, t8ad};
let arc = PakArchive::open(pak).with_context(|| format!("opening {}", pak.display()))?;
std::fs::create_dir_all(output)
.with_context(|| format!("creating {}", output.display()))?;
println!(
"{} {}{}",
"Textures".green().bold(),
pak.display().to_string().cyan(),
output.display().to_string().cyan(),
);
let mut stats = TexStats::default();
for e in arc.entries() {
let payload = match arc.read(e) {
Ok(p) => p,
Err(_) => continue,
};
let hash = e.name_hash;
// Direct T8aD entry.
if t8ad::is_t8ad(&payload) {
emit_t8ad(&payload, hash, "direct", output, detile, verbose, &mut stats)?;
continue;
}
// LSTA sprite list = N inline T8aD frames (walk by magic, emit each).
if lsta::is_lsta(&payload) {
let mut off = 0usize;
let mut idx = 0usize;
while let Some(pos) = payload[off..]
.windows(4)
.position(|w| w == &t8ad::T8AD_MAGIC)
{
let start = off + pos;
let next = payload[start + 4..]
.windows(4)
.position(|w| w == &t8ad::T8AD_MAGIC)
.map(|p| start + 4 + p)
.unwrap_or(payload.len());
emit_t8ad(
&payload[start..next],
hash,
&format!("lsta{idx:03}"),
output,
detile,
verbose,
&mut stats,
)?;
idx += 1;
off = next;
}
continue;
}
// RATC bundle: decode its T8aD children (named, e.g. `foo.t32`).
if ratc::is_ratc(&payload) {
if let Some(children) = ratc::parse(&payload) {
for (i, child) in children.iter().enumerate() {
if child.kind != "T8aD" {
continue;
}
let end = (child.offset + child.size).min(payload.len());
if child.offset >= end {
continue;
}
let stem = if child.name.is_empty() {
format!("child{i:03}")
} else {
safe_name(&child.name)
};
emit_t8ad(
&payload[child.offset..end],
hash,
&stem,
output,
detile,
verbose,
&mut stats,
)?;
}
}
continue;
}
}
println!(
"\n {} PNG(s) written, {} undecodable | fit: {} linear, {} tiled, {} short",
stats.written.to_string().green(),
stats.skipped.to_string().yellow(),
stats.linear_fit,
stats.tiled_fit.to_string().cyan(),
stats.neither,
);
Ok(())
}

View File

@@ -1,7 +1,14 @@
//! `T8aD` — the game's 2D UI/HUD texture format. //! `T8aD` — the game's 2D UI/HUD texture format.
//! //!
//! A linear (untiled) 32bpp surface stored **A8R8G8B8** (Xbox byte order), with //! A 32bpp surface stored **A8R8G8B8** (Xbox byte order), packed into **256×256
//! a small fixed-per-variant header: //! 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 //! ```text
//! 0x00 4 Magic "T8aD" //! 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 return None; // DXT/palettized/short variant — defer, don't misdecode
} }
// A8R8G8B8 → RGBA8. // De-tile from 256×256 row-major tiles into linear order, then A8R8G8B8 → RGBA8.
let src = &bytes[header..header + n]; let linear = detile_256(&bytes[header..header + n], width as usize, height as usize);
let mut rgba = vec![0u8; n]; 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]); let (a, r, g, b) = (px[0], px[1], px[2], px[3]);
out[0] = r; out[0] = r;
out[1] = g; 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;