From 7143ea18fe775528d07c45301463e0f1d460a848 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 18 Jul 2026 11:55:25 +0200 Subject: [PATCH] [formats] Fully crack T8aD: decode the header's per-tile offset table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- crates/sylpheed-cli/src/main.rs | 379 ++++------------------------ crates/sylpheed-formats/src/t8ad.rs | 195 +++++++------- 2 files changed, 147 insertions(+), 427 deletions(-) diff --git a/crates/sylpheed-cli/src/main.rs b/crates/sylpheed-cli/src/main.rs index 9560542..cb57f89 100644 --- a/crates/sylpheed-cli/src/main.rs +++ b/crates/sylpheed-cli/src/main.rs @@ -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 { - 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 Option> { - 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> { - 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 { - (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 = 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::() { - 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::() { - 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::() { - 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(()) } diff --git a/crates/sylpheed-formats/src/t8ad.rs b/crates/sylpheed-formats/src/t8ad.rs index 2669e0b..85da32a 100644 --- a/crates/sylpheed-formats/src/t8ad.rs +++ b/crates/sylpheed-formats/src/t8ad.rs @@ -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 -//!
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 +//! 16 per-tile header (flags + tile w/h), then: +//! 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 { - 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 { 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 { - 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 { - 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());