[formats] Fully crack T8aD: decode the header's per-tile offset table

Follow-up to the 256×256-tiling reversal — the residual boundary seams on
≥2×2-tile textures are gone. Decoding the file header revealed the exact
layout (no oracle capture needed):

  0x00  44        base header
  0x1c  4         tile count = ceil(w/256) * ceil(h/256)
  0x2c  tiles*4   BE-u32 absolute offset of each row-major 256×256 tile
  <off> 16        per-tile header, then tile_w*tile_h*4 A8R8G8B8 pixels

The old seams came from ignoring the offset table and the 16-byte per-tile
header (contiguous-packing drifted 16 bytes per tile). Small textures decoded
before only by luck: one tile puts pixels at 44+4+16 = 64, the old type-1
"header size". Now the 8AX title background, the prselect_win1 window frame,
and preff04 all decode pixel-perfect (verified against the title screen).

- t8ad.rs: parse() walks the offset table; dropped detile_256 + the
  header-size-by-type table. Non-tilecount entries (field != ceil*ceil) return
  None (likely DXT/other, deferred). New multi-tile round-trip test.
- sylpheed-cli: pak textures decodes via parse(); XDUMPHDR=1 dumps the base
  header + offset table for RE. Removed the now-obsolete XTILE/XSCORE/XDESTRIP
  experiment knobs and the reconstruct_tiles/TV-sweep helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-18 11:55:25 +02:00
parent 4ba723b9a5
commit 7143ea18fe
2 changed files with 147 additions and 427 deletions

View File

@@ -129,12 +129,9 @@ enum PakCommands {
pak: PathBuf,
/// Output directory for the PNGs (created if missing)
output: PathBuf,
/// Print per-texture size classification (linear-fit vs tiled-fit).
/// Print per-texture dimensions + tile count.
#[arg(long)]
verbose: bool,
/// Experiment: treat T8aD pixel data as Xenos-tiled and de-tile it.
#[arg(long)]
detile: bool,
},
}
@@ -215,8 +212,8 @@ async fn main() -> Result<()> {
Commands::Pak { cmd } => match cmd {
PakCommands::List { pak, idxd_only } => cmd_pak_list(&pak, idxd_only),
PakCommands::Dump { pak, hash } => cmd_pak_dump(&pak, &hash),
PakCommands::Textures { pak, output, verbose, detile } => {
cmd_pak_textures(&pak, &output, verbose, detile)
PakCommands::Textures { pak, output, verbose } => {
cmd_pak_textures(&pak, &output, verbose)
}
},
}
@@ -870,344 +867,76 @@ fn safe_name(s: &str) -> String {
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.
/// Decode one T8aD slice (whose first bytes are the magic) to PNG. With
/// `verbose`, prints its dimensions + tile count; the `XDUMPHDR` env var dumps
/// the raw header (base + offset table) for format RE.
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};
use sylpheed_formats::t8ad;
if slice.len() < 0x40 || &slice[0..4] != b"T8aD" {
if !t8ad::is_t8ad(slice) || slice.len() < 0x40 {
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 (w, h, tiles) = (
be32_at(slice, 0x14),
be32_at(slice, 0x18),
be32_at(slice, 0x1c),
);
// Debug: dump the header — 44-byte base + the `tiles`-entry u32 offset table.
if std::env::var("XDUMPHDR").is_ok() {
println!("\n{stem} {w}x{h} tiles={tiles}");
print!(" base[0x00..0x2c]:");
for i in (0..44).step_by(4) {
print!(" {:08x}", be32_at(slice, i));
}
};
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;
print!("\n offsets:");
for t in 0..(tiles as usize).min(64) {
if 0x2c + t * 4 + 4 <= slice.len() {
print!(" {}", be32_at(slice, 0x2c + t * 4));
}
}
println!();
return Ok(());
}
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
);
println!(" {hash:08x} {stem:<34} {w:>4}x{h:<4} tiles {tiles}");
}
// 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(());
}
match t8ad::parse(slice) {
Some(img) => {
let out =
output.join(format!("{hash:08x}_{stem}_{}x{}.png", img.width, img.height));
image::save_buffer(
&out,
&img.rgba,
img.width,
img.height,
image::ExtendedColorType::Rgba8,
)
.with_context(|| format!("writing PNG {}", out.display()))?;
stats.written += 1;
}
} 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;
None => stats.skipped += 1,
}
// 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<()> {
fn cmd_pak_textures(pak: &Path, output: &Path, verbose: bool) -> Result<()> {
use sylpheed_formats::{lsta, ratc, t8ad};
let arc = PakArchive::open(pak).with_context(|| format!("opening {}", pak.display()))?;
@@ -1232,7 +961,7 @@ fn cmd_pak_textures(pak: &Path, output: &Path, verbose: bool, detile: bool) -> R
// Direct T8aD entry.
if t8ad::is_t8ad(&payload) {
emit_t8ad(&payload, hash, "direct", output, detile, verbose, &mut stats)?;
emit_t8ad(&payload, hash, "direct", output, verbose, &mut stats)?;
continue;
}
@@ -1255,7 +984,6 @@ fn cmd_pak_textures(pak: &Path, output: &Path, verbose: bool, detile: bool) -> R
hash,
&format!("lsta{idx:03}"),
output,
detile,
verbose,
&mut stats,
)?;
@@ -1281,15 +1009,7 @@ fn cmd_pak_textures(pak: &Path, output: &Path, verbose: bool, detile: bool) -> R
} else {
safe_name(&child.name)
};
emit_t8ad(
&payload[child.offset..end],
hash,
&stem,
output,
detile,
verbose,
&mut stats,
)?;
emit_t8ad(&payload[child.offset..end], hash, &stem, output, verbose, &mut stats)?;
}
}
continue;
@@ -1297,12 +1017,9 @@ fn cmd_pak_textures(pak: &Path, output: &Path, verbose: bool, detile: bool) -> R
}
println!(
"\n {} PNG(s) written, {} undecodable | fit: {} linear, {} tiled, {} short",
"\n {} PNG(s) written, {} undecodable (non-tilecount variants — likely DXT)",
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,34 +1,25 @@
//! `T8aD` — the game's 2D UI/HUD texture format.
//!
//! 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:
//! A 32bpp **A8R8G8B8** (Xbox byte order) surface stored as **256×256 raster
//! tiles in row-major order** each tile prefixed by a 16-byte tile header, edge
//! tiles clipped to the image bounds. Fully reversed 2026-07-17 from the file
//! header and verified against the running game (title screen).
//!
//! ```text
//! 0x00 4 Magic "T8aD"
//! 0x14 4 width (BE u32)
//! 0x18 4 height (BE u32)
//! 0x1c 4 type field → header size: 1→64 2→84 3→104 4→124 15→344
//! <header> w*h*4 bytes of A8R8G8B8 pixel data, row-major
//! 0x00 4 Magic "T8aD"
//! 0x14 4 width (BE u32)
//! 0x18 4 height (BE u32)
//! 0x1c 4 tile count (BE u32) = ceil(w/256) * ceil(h/256)
//! 0x2c tiles*4 offset table: absolute byte offset of each row-major tile
//! <off> 16 per-tile header (flags + tile w/h), then:
//! <off+16> tile_w * tile_h * 4 bytes of A8R8G8B8 pixels, row-major
//! ```
//!
//! Static forensics over the disc showed ~85% of entries decode exactly with
//! this rule; the rest (unknown type field or a `w*h*4` that doesn't fit — most
//! likely DXT / palettized variants) return `None` rather than a wrong image.
//!
//! Keying the header size off the type field (not `len - w*h*4`) makes the
//! decoder **container-safe**: RATC/LSTA hand us a child slice that may have
//! trailing padding before the next child, so we must not infer the header from
//! the slice length.
//!
//! ⚠️ Colour correctness (channel order / endianness / sRGB) is unverified
//! against the running game — see the RE backlog.
//! Surfaces ≤256px wide are a single tile column, so the first tile's pixels sit
//! at `44 + tiles*4 + 16 = 64` — which is why the old "type→header size 64/84/…"
//! rule (header = 44 + tiles*20) happened to decode small textures correctly: for
//! one tile it lands on the same pixel start. Wide textures were garbled because
//! the offset table + 16-byte per-tile headers weren't accounted for.
/// Magic at the start of every T8aD surface.
pub const T8AD_MAGIC: [u8; 4] = *b"T8aD";
@@ -46,104 +37,93 @@ pub fn is_t8ad(bytes: &[u8]) -> bool {
bytes.len() >= 4 && bytes[0..4] == T8AD_MAGIC
}
/// Header size in bytes for a given type field (`@0x1c`), or `None` for an
/// unrecognized variant.
fn 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,
}
}
#[inline]
fn be32(b: &[u8], off: usize) -> u32 {
u32::from_be_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]])
}
/// Side of the square storage tile, in texels, and the per-tile header size.
const TILE: usize = 256;
const TILE_HDR: usize = 16;
/// Decode a T8aD surface from a slice whose first bytes ARE the magic. Returns
/// `None` for non-T8aD input or a variant we can't decode as RGBA (never guesses).
///
/// Layout (reversed from the header + verified against the running game):
/// a 44-byte base header, then a `tiles`-entry big-endian u32 **offset table** at
/// `0x2c`, where `tiles` = the field at `0x1c` = `ceil(w/256) * ceil(h/256)`.
/// Each entry is the absolute byte offset of a **row-major** 256×256 tile; every
/// tile is a 16-byte tile header followed by `tile_w*tile_h*4` A8R8G8B8 pixels,
/// edge tiles clipped to the image bounds.
pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
if !is_t8ad(bytes) || bytes.len() < 0x40 {
return None;
}
let width = be32(bytes, 0x14);
let height = be32(bytes, 0x18);
let width = be32(bytes, 0x14) as usize;
let height = be32(bytes, 0x18) as usize;
if !(1..=4096).contains(&width) || !(1..=4096).contains(&height) {
return None;
}
let header = header_size(be32(bytes, 0x1c))?;
let n = (width as usize) * (height as usize) * 4;
if bytes.len() < header + n {
return None; // DXT/palettized/short variant — defer, don't misdecode
let tiles = be32(bytes, 0x1c) as usize;
let cols = width.div_ceil(TILE);
let rows = height.div_ceil(TILE);
// The field at 0x1c must be the tile count; otherwise it's a variant we don't
// decode (e.g. DXT / palettized) — defer rather than misdecode.
if tiles == 0 || tiles != cols * rows {
return None;
}
const TABLE: usize = 0x2c;
if bytes.len() < TABLE + tiles * 4 {
return None;
}
// 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 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;
out[2] = b;
out[3] = a;
}
Some(T8adImage {
width,
height,
rgba,
})
}
/// 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]);
let mut rgba = vec![0u8; width * height * 4];
for ty in 0..rows {
for tx in 0..cols {
let tile = ty * cols + tx;
let pixels = be32(bytes, TABLE + tile * 4) as usize + TILE_HDR;
let tw = TILE.min(width - tx * TILE);
let th = TILE.min(height - ty * TILE);
if pixels + tw * th * 4 > bytes.len() {
return None; // truncated / not the layout we expect
}
for row in 0..th {
let mut s = pixels + row * tw * 4;
let mut d = ((ty * TILE + row) * width + tx * TILE) * 4;
for _ in 0..tw {
// A8R8G8B8 → RGBA8.
rgba[d] = bytes[s + 1];
rgba[d + 1] = bytes[s + 2];
rgba[d + 2] = bytes[s + 3];
rgba[d + 3] = bytes[s];
s += 4;
d += 4;
}
}
}
}
out
Some(T8adImage {
width: width as u32,
height: height as u32,
rgba,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a synthetic type-1 (header 64) T8aD with a known A8R8G8B8 pattern.
/// Build a synthetic single-tile T8aD (`w,h ≤ 256`) with a known A8R8G8B8
/// pattern: base header + 1-entry offset table + 16-byte tile header + pixels.
fn synth(w: u32, h: u32) -> Vec<u8> {
let mut b = vec![0u8; 64];
assert!(w <= 256 && h <= 256);
let mut b = vec![0u8; 0x2c];
b[0..4].copy_from_slice(&T8AD_MAGIC);
b[0x14..0x18].copy_from_slice(&w.to_be_bytes());
b[0x18..0x1c].copy_from_slice(&h.to_be_bytes());
b[0x1c..0x20].copy_from_slice(&1u32.to_be_bytes()); // type 1 → header 64
b[0x1c..0x20].copy_from_slice(&1u32.to_be_bytes()); // 1 tile
b.extend_from_slice(&0x30u32.to_be_bytes()); // offset table: tile 0 @ 0x30
b.extend_from_slice(&[0u8; 16]); // 16-byte tile header → pixels at 0x40
for i in 0..(w * h) {
b.extend_from_slice(&[(i & 0xff) as u8, 0x24, 0x63, 0xB2]); // A, R, G, B
}
@@ -163,8 +143,31 @@ mod tests {
}
#[test]
fn rejects_unknown_variant_and_short() {
// type field 7 (unknown) → None
fn assembles_row_major_tiles_via_offset_table() {
// 300×1 → 2 tiles: (0,0)=256×1 red, (1,0)=44×1 blue, each +16-byte header.
let (w, h): (u32, u32) = (300, 1);
let mut b = vec![0u8; 0x2c];
b[0..4].copy_from_slice(&T8AD_MAGIC);
b[0x14..0x18].copy_from_slice(&w.to_be_bytes());
b[0x18..0x1c].copy_from_slice(&h.to_be_bytes());
b[0x1c..0x20].copy_from_slice(&2u32.to_be_bytes()); // 2 tiles
let off0 = 0x2c + 2 * 4; // after the 2-entry table
let off1 = off0 + 16 + 256 * 4; // tile-0 header + its 256 pixels
b.extend_from_slice(&(off0 as u32).to_be_bytes());
b.extend_from_slice(&(off1 as u32).to_be_bytes());
b.extend_from_slice(&[0u8; 16]);
b.extend_from_slice(&[0xFF, 0xFF, 0, 0].repeat(256)); // A,R,G,B red
b.extend_from_slice(&[0u8; 16]);
b.extend_from_slice(&[0xFF, 0, 0, 0xFF].repeat(44)); // A,R,G,B blue
let img = parse(&b).expect("decodes");
assert_eq!((img.width, img.height), (300, 1));
assert_eq!(&img.rgba[0..4], &[0xFF, 0, 0, 0xFF]); // tile 0 → red
assert_eq!(&img.rgba[256 * 4..256 * 4 + 4], &[0, 0, 0xFF, 0xFF]); // tile 1 → blue
}
#[test]
fn rejects_wrong_tilecount_and_short() {
// tile-count field that isn't ceil(w/256)*ceil(h/256) → None
let mut b = synth(2, 2);
b[0x1c..0x20].copy_from_slice(&7u32.to_be_bytes());
assert!(parse(&b).is_none());