residual is smaller than I said Testing the candidate I raised last iteration rather than carrying it. Blending bit-0x02 sprites additively moves every measure the wrong way -- whole-frame mean diff +0.55 to +1.04, swoosh-band mean +1.83 to +3.98, band edge-correlation 0.6971 down to 0.5578. So the bit is real and independent but does not select an additive blend. I reverted the experiment and kept the word as T8adImage::flags, documented and not acted on; the render is byte-identical to before. Second refutation: the swoosh is not displaced. Shifting the band over plus or minus 80 by 8 pixels peaks sharply at zero, 0.7342, falling to 0.22 at 24 px. So the pivot story is dead twice over -- inert at scale 100, and no displacement to explain anyway. And I have restated the residual, because earlier sections overstated it. The +16 to +34 band tiles I quoted were measured WITHOUT --primitives. With the dim drawn the band's average is nearly right at +1.83; what is wrong is its structure, tiles running -38.6 then +33.8 and cancelling. Six candidates eliminated now and none confirmed. One caveat I owe the port agent about the capture I gave them: it is at t=4.0s, roughly 174 keyframe units into a screen whose elements have keyframes out to t=600. I judged "settled" from mean luminance, which cannot see a thin sprite still moving. It is settled for the bulk of the screen and not proven settled for every element -- which is a live alternative explanation for a structural difference in exactly the band the sweeps cross. METHOD: cargo build passing does not mean cargo test compiles. Adding the field built the library in 1.48s and broke two test-only struct literals; cargo test failed with exit 101.
203 lines
8.4 KiB
Rust
203 lines
8.4 KiB
Rust
//! `T8aD` — the game's 2D UI/HUD texture format.
|
||
//!
|
||
//! A 32bpp **A8R8G8B8** (Xbox byte order) surface stored as a list of
|
||
//! **arbitrary sub-rectangles**, each with its own destination origin and size.
|
||
//! Reversed 2026-07-17 (verified against the running game's title screen) and
|
||
//! **corrected 2026-08-11**, when the per-tile header turned out to carry the
|
||
//! rectangle's placement rather than being opaque flags.
|
||
//!
|
||
//! ```text
|
||
//! 0x00 4 Magic "T8aD"
|
||
//! 0x14 4 width (BE u32) the full surface
|
||
//! 0x18 4 height (BE u32)
|
||
//! 0x1c 4 rectangle count (BE u32) — NOT ceil(w/256)*ceil(h/256)
|
||
//! 0x2c count*4 offset table: absolute byte offset of each rectangle
|
||
//! <off> 16 rectangle header, four BE u32: dst X, dst Y, width, height
|
||
//! <off+16> width * height * 4 bytes of A8R8G8B8 pixels, row-major
|
||
//! ```
|
||
//!
|
||
//! Most surfaces happen to be stored as full-width 256-tall bands, which is why
|
||
//! treating the file as a 256×256 grid decoded 96 % of the disc correctly. It is
|
||
//! not the model, though: a dialogue strip declares 524×63 and stores **one**
|
||
//! 173×25 rectangle at (175,20), and `pdmes010` stores two — (59,6,256,54) and
|
||
//! (315,6,149,54), the second beginning exactly `16 + 256*54*4` bytes after the
|
||
//! first. Anything the rectangles do not cover stays transparent.
|
||
//!
|
||
//! 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<u8>,
|
||
/// The header word at `+0x04`. A flag word; **bit `0x02`** is a candidate
|
||
/// blend selector — it separates the title's effect sprites from its ordinary
|
||
/// ones exactly, and disc-wide it toggles independently of the rest of the
|
||
/// word in 27.1 % of 19 216 sprites. See
|
||
/// `docs/re/structures/ui-paint-order-key.md`.
|
||
///
|
||
/// ⚠️ **Additive was tested and REFUTED.** Blending bit-`0x02` sprites
|
||
/// additively moved every measure against the title capture the wrong way:
|
||
/// whole-frame mean diff +0.55 → +1.04, swoosh-band mean +1.83 → +3.98, band
|
||
/// edge-correlation 0.6971 → 0.5578. The bit is real and independent, but it
|
||
/// does not select an additive blend. Carried, not acted on.
|
||
pub flags: u32,
|
||
}
|
||
|
||
/// 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.
|
||
/// Bytes of per-rectangle header before its pixels: dst X, dst Y, w, h.
|
||
const RECT_HDR: usize = 16;
|
||
|
||
/// Decode a T8aD surface from a slice whose first bytes ARE the magic. Returns
|
||
/// `None` if any rectangle fails to fit the surface or the file — never guesses.
|
||
pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
|
||
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 rects = be32(bytes, 0x1c) as usize;
|
||
if rects == 0 || rects > 4096 {
|
||
return None;
|
||
}
|
||
const TABLE: usize = 0x2c;
|
||
if bytes.len() < TABLE + rects * 4 {
|
||
return None;
|
||
}
|
||
|
||
// Anything no rectangle covers stays transparent.
|
||
let mut rgba = vec![0u8; width * height * 4];
|
||
for r in 0..rects {
|
||
let at = be32(bytes, TABLE + r * 4) as usize;
|
||
if at + RECT_HDR > bytes.len() {
|
||
return None;
|
||
}
|
||
let dx = be32(bytes, at) as usize;
|
||
let dy = be32(bytes, at + 4) as usize;
|
||
let rw = be32(bytes, at + 8) as usize;
|
||
let rh = be32(bytes, at + 12) as usize;
|
||
// Never guess: a rectangle must fit the surface and its pixels the file.
|
||
if rw == 0 || rh == 0 || dx + rw > width || dy + rh > height {
|
||
return None;
|
||
}
|
||
let pixels = at + RECT_HDR;
|
||
if pixels + rw * rh * 4 > bytes.len() {
|
||
return None;
|
||
}
|
||
for row in 0..rh {
|
||
let mut s = pixels + row * rw * 4;
|
||
let mut d = ((dy + row) * width + dx) * 4;
|
||
for _ in 0..rw {
|
||
// 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 {
|
||
flags: be32(bytes, 4),
|
||
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<u8> {
|
||
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
|
||
// rectangle header: dst (0,0), size w×h
|
||
b.extend_from_slice(&0u32.to_be_bytes());
|
||
b.extend_from_slice(&0u32.to_be_bytes());
|
||
b.extend_from_slice(&w.to_be_bytes());
|
||
b.extend_from_slice(&h.to_be_bytes());
|
||
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_rectangles_via_offset_table() {
|
||
// 300×1 → 2 rectangles: (0,0) 256×1 red, then (256,0) 44×1 blue.
|
||
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());
|
||
for v in [0u32, 0, 256, 1] { b.extend_from_slice(&v.to_be_bytes()) } // dst(0,0) 256×1
|
||
b.extend_from_slice(&[0xFF, 0xFF, 0, 0].repeat(256)); // A,R,G,B red
|
||
for v in [256u32, 0, 44, 1] { b.extend_from_slice(&v.to_be_bytes()) } // dst(256,0) 44×1
|
||
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_out_of_range_rect_and_short() {
|
||
// a rectangle that does not fit the declared surface → None, never guess
|
||
let mut b = synth(2, 2);
|
||
let at = 0x2c + 4;
|
||
b[at + 8..at + 12].copy_from_slice(&99u32.to_be_bytes()); // width 99 > 2
|
||
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());
|
||
}
|
||
}
|