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