Files
Sylpheed/crates/sylpheed-formats/src/t8ad.rs
Sylpheed RE agent 4cee06c07f re: additive blending refuted, the swoosh is not displaced, and the
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.
2026-08-28 22:00:55 +00:00

203 lines
8.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `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());
}
}