diff --git a/crates/sylpheed-cli/src/main.rs b/crates/sylpheed-cli/src/main.rs index 62c1b3d..9560542 100644 --- a/crates/sylpheed-cli/src/main.rs +++ b/crates/sylpheed-cli/src/main.rs @@ -122,6 +122,20 @@ enum PakCommands { /// Entry name-hash, e.g. `0x7c96296c` 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)] @@ -199,8 +213,11 @@ 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::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) + } }, } } @@ -833,3 +850,459 @@ fn cmd_pak_dump(pak: &Path, hash_str: &str) -> Result<()> { } 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 { + 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. +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 = 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::() { + 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<()> { + 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(()) +} diff --git a/crates/sylpheed-formats/src/t8ad.rs b/crates/sylpheed-formats/src/t8ad.rs index d87d9ae..2669e0b 100644 --- a/crates/sylpheed-formats/src/t8ad.rs +++ b/crates/sylpheed-formats/src/t8ad.rs @@ -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 { 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 { }) } +/// 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]); + } + } + } + out +} + #[cfg(test)] mod tests { use super::*;