//! `T8aD` — the game's 2D UI/HUD texture format. //! //! 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 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 //! ``` //! //! 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"; /// A decoded T8aD surface as tightly-packed RGBA8 (row-major, top-left origin). #[derive(Debug, Clone, PartialEq, Eq)] pub struct T8adImage { pub width: u32, pub height: u32, pub rgba: Vec, } /// Whether `bytes` starts with the T8aD magic. pub fn is_t8ad(bytes: &[u8]) -> bool { bytes.len() >= 4 && bytes[0..4] == T8AD_MAGIC } #[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) as usize; let height = be32(bytes, 0x18) as usize; if !(1..=4096).contains(&width) || !(1..=4096).contains(&height) { return None; } 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; } 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; } } } } Some(T8adImage { width: width as u32, height: height as u32, rgba, }) } #[cfg(test)] mod tests { use super::*; /// 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 { 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()); // 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 } b } #[test] fn decodes_argb_to_rgba() { let b = synth(4, 2); let img = parse(&b).expect("decodes"); assert_eq!((img.width, img.height), (4, 2)); assert_eq!(img.rgba.len(), 4 * 2 * 4); // pixel 0: A=0,R=0x24,G=0x63,B=0xB2 → RGBA = 24 63 B2 00 assert_eq!(&img.rgba[0..4], &[0x24, 0x63, 0xB2, 0x00]); // pixel 1: A=1 → alpha byte assert_eq!(&img.rgba[4..8], &[0x24, 0x63, 0xB2, 0x01]); } #[test] 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()); // truncated pixel data → None let b = synth(64, 64); assert!(parse(&b[..200]).is_none()); assert!(parse(b"IDXD\0\0\0\0").is_none()); } }