t8ad: a surface is a list of sub-rectangles, not a 256 grid -- disc decode 96% -> 100%
The "~15% deferred variants" were not variants. Auditing every T8aD on the disc gave 19216 surfaces, 18442 decoding (96.0%) and 774 failing in two clusters: GP_DIALOG strips declaring 524x63 with a "tile count" of 1 or 2 instead of 3, and small textures in the six *2D language paks whose pixels ran past the end of the file. Both fall out of the per-tile header, which is not opaque flags: it is four BE u32 -- dst X, dst Y, width, height. A 15x18 icon stores a 13x18 rectangle at (1,0); pdmes010 stores (59,6,256,54) and (315,6,149,54), the second beginning exactly 16 + 256*54*4 bytes after the first. So 0x1c is a RECTANGLE COUNT and the 256-grid reading was an accident of most surfaces being stored as full-width bands. Parser rewritten to that model, still refusing to guess: a rectangle must fit the declared surface and its pixels must fit the file, else None. Disc decode is now 19216/19216 = 100.00%. Two test fixtures were built to the old model and are corrected rather than worked around. lsta's t8ad_frame wrote NO offset-table entry, so the decoder read "pixels" from inside the header -- the test passed only because it checked dimensions alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,12 +48,21 @@ pub fn parse(bytes: &[u8]) -> Option<Vec<T8adImage>> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// A faithful one-rectangle T8aD frame: base header, a 1-entry offset table,
|
||||||
|
/// then the rectangle header (dst 0,0, size w×h) and its pixels. (Before
|
||||||
|
/// 2026-08-11 this fixture wrote no offset-table entry at all and the decoder
|
||||||
|
/// read "pixels" from inside the header — the test only ever checked the
|
||||||
|
/// dimensions, so it passed anyway.)
|
||||||
fn t8ad_frame(w: u32, h: u32) -> Vec<u8> {
|
fn t8ad_frame(w: u32, h: u32) -> Vec<u8> {
|
||||||
let mut b = vec![0u8; 64];
|
let mut b = vec![0u8; 0x2c];
|
||||||
b[0..4].copy_from_slice(&T8AD_MAGIC);
|
b[0..4].copy_from_slice(&T8AD_MAGIC);
|
||||||
b[0x14..0x18].copy_from_slice(&w.to_be_bytes());
|
b[0x14..0x18].copy_from_slice(&w.to_be_bytes());
|
||||||
b[0x18..0x1c].copy_from_slice(&h.to_be_bytes());
|
b[0x18..0x1c].copy_from_slice(&h.to_be_bytes());
|
||||||
b[0x1c..0x20].copy_from_slice(&1u32.to_be_bytes());
|
b[0x1c..0x20].copy_from_slice(&1u32.to_be_bytes());
|
||||||
|
b.extend_from_slice(&0x30u32.to_be_bytes()); // offset table → rect at 0x30
|
||||||
|
for v in [0u32, 0, w, h] {
|
||||||
|
b.extend_from_slice(&v.to_be_bytes()); // dst X, dst Y, width, height
|
||||||
|
}
|
||||||
b.extend_from_slice(&vec![0x80u8; (w * h * 4) as usize]);
|
b.extend_from_slice(&vec![0x80u8; (w * h * 4) as usize]);
|
||||||
b
|
b
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
//! `T8aD` — the game's 2D UI/HUD texture format.
|
//! `T8aD` — the game's 2D UI/HUD texture format.
|
||||||
//!
|
//!
|
||||||
//! A 32bpp **A8R8G8B8** (Xbox byte order) surface stored as **256×256 raster
|
//! A 32bpp **A8R8G8B8** (Xbox byte order) surface stored as a list of
|
||||||
//! tiles in row-major order** — each tile prefixed by a 16-byte tile header, edge
|
//! **arbitrary sub-rectangles**, each with its own destination origin and size.
|
||||||
//! tiles clipped to the image bounds. Fully reversed 2026-07-17 from the file
|
//! Reversed 2026-07-17 (verified against the running game's title screen) and
|
||||||
//! header and verified against the running game (title screen).
|
//! **corrected 2026-08-11**, when the per-tile header turned out to carry the
|
||||||
|
//! rectangle's placement rather than being opaque flags.
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! 0x00 4 Magic "T8aD"
|
//! 0x00 4 Magic "T8aD"
|
||||||
//! 0x14 4 width (BE u32)
|
//! 0x14 4 width (BE u32) the full surface
|
||||||
//! 0x18 4 height (BE u32)
|
//! 0x18 4 height (BE u32)
|
||||||
//! 0x1c 4 tile count (BE u32) = ceil(w/256) * ceil(h/256)
|
//! 0x1c 4 rectangle count (BE u32) — NOT ceil(w/256)*ceil(h/256)
|
||||||
//! 0x2c tiles*4 offset table: absolute byte offset of each row-major tile
|
//! 0x2c count*4 offset table: absolute byte offset of each rectangle
|
||||||
//! <off> 16 per-tile header (flags + tile w/h), then:
|
//! <off> 16 rectangle header, four BE u32: dst X, dst Y, width, height
|
||||||
//! <off+16> tile_w * tile_h * 4 bytes of A8R8G8B8 pixels, row-major
|
//! <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
|
//! 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/…"
|
//! 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
|
//! rule (header = 44 + tiles*20) happened to decode small textures correctly: for
|
||||||
@@ -43,18 +51,11 @@ fn be32(b: &[u8], off: usize) -> u32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Side of the square storage tile, in texels, and the per-tile header size.
|
/// Side of the square storage tile, in texels, and the per-tile header size.
|
||||||
const TILE: usize = 256;
|
/// Bytes of per-rectangle header before its pixels: dst X, dst Y, w, h.
|
||||||
const TILE_HDR: usize = 16;
|
const RECT_HDR: usize = 16;
|
||||||
|
|
||||||
/// Decode a T8aD surface from a slice whose first bytes ARE the magic. Returns
|
/// 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).
|
/// `None` if any rectangle fails to fit the surface or the file — 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<T8adImage> {
|
pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
|
||||||
if !is_t8ad(bytes) || bytes.len() < 0x40 {
|
if !is_t8ad(bytes) || bytes.len() < 0x40 {
|
||||||
return None;
|
return None;
|
||||||
@@ -64,33 +65,38 @@ pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
|
|||||||
if !(1..=4096).contains(&width) || !(1..=4096).contains(&height) {
|
if !(1..=4096).contains(&width) || !(1..=4096).contains(&height) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let tiles = be32(bytes, 0x1c) as usize;
|
let rects = be32(bytes, 0x1c) as usize;
|
||||||
let cols = width.div_ceil(TILE);
|
if rects == 0 || rects > 4096 {
|
||||||
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;
|
return None;
|
||||||
}
|
}
|
||||||
const TABLE: usize = 0x2c;
|
const TABLE: usize = 0x2c;
|
||||||
if bytes.len() < TABLE + tiles * 4 {
|
if bytes.len() < TABLE + rects * 4 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anything no rectangle covers stays transparent.
|
||||||
let mut rgba = vec![0u8; width * height * 4];
|
let mut rgba = vec![0u8; width * height * 4];
|
||||||
for ty in 0..rows {
|
for r in 0..rects {
|
||||||
for tx in 0..cols {
|
let at = be32(bytes, TABLE + r * 4) as usize;
|
||||||
let tile = ty * cols + tx;
|
if at + RECT_HDR > bytes.len() {
|
||||||
let pixels = be32(bytes, TABLE + tile * 4) as usize + TILE_HDR;
|
return None;
|
||||||
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 dx = be32(bytes, at) as usize;
|
||||||
let mut s = pixels + row * tw * 4;
|
let dy = be32(bytes, at + 4) as usize;
|
||||||
let mut d = ((ty * TILE + row) * width + tx * TILE) * 4;
|
let rw = be32(bytes, at + 8) as usize;
|
||||||
for _ in 0..tw {
|
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.
|
// A8R8G8B8 → RGBA8.
|
||||||
rgba[d] = bytes[s + 1];
|
rgba[d] = bytes[s + 1];
|
||||||
rgba[d + 1] = bytes[s + 2];
|
rgba[d + 1] = bytes[s + 2];
|
||||||
@@ -101,7 +107,6 @@ pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Some(T8adImage {
|
Some(T8adImage {
|
||||||
width: width as u32,
|
width: width as u32,
|
||||||
height: height as u32,
|
height: height as u32,
|
||||||
@@ -123,7 +128,11 @@ mod tests {
|
|||||||
b[0x18..0x1c].copy_from_slice(&h.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[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(&0x30u32.to_be_bytes()); // offset table: tile 0 @ 0x30
|
||||||
b.extend_from_slice(&[0u8; 16]); // 16-byte tile header → pixels at 0x40
|
// 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) {
|
for i in 0..(w * h) {
|
||||||
b.extend_from_slice(&[(i & 0xff) as u8, 0x24, 0x63, 0xB2]); // A, R, G, B
|
b.extend_from_slice(&[(i & 0xff) as u8, 0x24, 0x63, 0xB2]); // A, R, G, B
|
||||||
}
|
}
|
||||||
@@ -143,8 +152,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn assembles_row_major_tiles_via_offset_table() {
|
fn assembles_rectangles_via_offset_table() {
|
||||||
// 300×1 → 2 tiles: (0,0)=256×1 red, (1,0)=44×1 blue, each +16-byte header.
|
// 300×1 → 2 rectangles: (0,0) 256×1 red, then (256,0) 44×1 blue.
|
||||||
let (w, h): (u32, u32) = (300, 1);
|
let (w, h): (u32, u32) = (300, 1);
|
||||||
let mut b = vec![0u8; 0x2c];
|
let mut b = vec![0u8; 0x2c];
|
||||||
b[0..4].copy_from_slice(&T8AD_MAGIC);
|
b[0..4].copy_from_slice(&T8AD_MAGIC);
|
||||||
@@ -155,9 +164,9 @@ mod tests {
|
|||||||
let off1 = off0 + 16 + 256 * 4; // tile-0 header + its 256 pixels
|
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(&(off0 as u32).to_be_bytes());
|
||||||
b.extend_from_slice(&(off1 as u32).to_be_bytes());
|
b.extend_from_slice(&(off1 as u32).to_be_bytes());
|
||||||
b.extend_from_slice(&[0u8; 16]);
|
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
|
b.extend_from_slice(&[0xFF, 0xFF, 0, 0].repeat(256)); // A,R,G,B red
|
||||||
b.extend_from_slice(&[0u8; 16]);
|
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
|
b.extend_from_slice(&[0xFF, 0, 0, 0xFF].repeat(44)); // A,R,G,B blue
|
||||||
let img = parse(&b).expect("decodes");
|
let img = parse(&b).expect("decodes");
|
||||||
assert_eq!((img.width, img.height), (300, 1));
|
assert_eq!((img.width, img.height), (300, 1));
|
||||||
@@ -166,10 +175,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_wrong_tilecount_and_short() {
|
fn rejects_out_of_range_rect_and_short() {
|
||||||
// tile-count field that isn't ceil(w/256)*ceil(h/256) → None
|
// a rectangle that does not fit the declared surface → None, never guess
|
||||||
let mut b = synth(2, 2);
|
let mut b = synth(2, 2);
|
||||||
b[0x1c..0x20].copy_from_slice(&7u32.to_be_bytes());
|
let at = 0x2c + 4;
|
||||||
|
b[at + 8..at + 12].copy_from_slice(&99u32.to_be_bytes()); // width 99 > 2
|
||||||
assert!(parse(&b).is_none());
|
assert!(parse(&b).is_none());
|
||||||
// truncated pixel data → None
|
// truncated pixel data → None
|
||||||
let b = synth(64, 64);
|
let b = synth(64, 64);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
|
|||||||
| name-hash (TOC keys) | ✅ | `sylpheed-formats/src/hash.rs` | Barrett-reduction hash; recovers original paths |
|
| name-hash (TOC keys) | ✅ | `sylpheed-formats/src/hash.rs` | Barrett-reduction hash; recovers original paths |
|
||||||
| IDXD object/table | ✅ | `sylpheed-formats/src/idxd.rs` | self-describing; ship/weapon stats verified vs known values |
|
| IDXD object/table | ✅ | `sylpheed-formats/src/idxd.rs` | self-describing; ship/weapon stats verified vs known values |
|
||||||
| XPR2 texture + cubemap | 🟡/✅ | `sylpheed-formats/src/texture.rs` + [colour check](xpr2-colour-check.md) | de-tile + A8R8G8B8 and DXT1. **Channel order ✅ confirmed against the running game**: the Delta Saber's decoded atlas is orange-dominant (median saturated hue 23.3°, *zero* cool pixels) and the game renders the same hull at 9.3° — a red↔blue swap would sit at ≈200°. Exact fidelity (gamma/sRGB curve, premultiplied alpha, per-channel scale) is 🟡 untested, since a hue comparison cannot see it; cubemap face ordering ❔ |
|
| XPR2 texture + cubemap | 🟡/✅ | `sylpheed-formats/src/texture.rs` + [colour check](xpr2-colour-check.md) | de-tile + A8R8G8B8 and DXT1. **Channel order ✅ confirmed against the running game**: the Delta Saber's decoded atlas is orange-dominant (median saturated hue 23.3°, *zero* cool pixels) and the game renders the same hull at 9.3° — a red↔blue swap would sit at ≈200°. Exact fidelity (gamma/sRGB curve, premultiplied alpha, per-channel scale) is 🟡 untested, since a hue comparison cannot see it; cubemap face ordering ❔ |
|
||||||
| T8aD 2D texture | 🟡 | `sylpheed-formats/src/t8ad.rs` | ~85% decode; **colours ✅ CONFIRMED** ([k8888](structures/texture-color-k8888.md)); ~15% variants deferred |
|
| T8aD 2D texture | ✅ | `sylpheed-formats/src/t8ad.rs` | **100 % of the disc decodes** (19 216/19 216, measured). The "~15 % deferred variants" were a wrong model, not a variant: a surface is a list of **arbitrary sub-rectangles**, each with a 16-byte header of `dst X, dst Y, width, height`, not a 256×256 grid — `0x1c` is the **rectangle count**. Uncovered area stays transparent. **Colours ✅ CONFIRMED** ([k8888](structures/texture-color-k8888.md)) |
|
||||||
| RATC bundle | 🟡 | `sylpheed-formats/src/ratc.rs` | child listing confirmed; one level deep |
|
| RATC bundle | 🟡 | `sylpheed-formats/src/ratc.rs` | child listing confirmed; one level deep |
|
||||||
| LSTA sprite list | 🟡 | `sylpheed-formats/src/lsta.rs` | inline T8aD frames |
|
| LSTA sprite list | 🟡 | `sylpheed-formats/src/lsta.rs` | inline T8aD frames |
|
||||||
| IXUD subtitle | 🟡/✅ | `sylpheed-formats/src/ixud.rs` + [movie link](movie-subtitle-link.md) | timed cues. **The movie↔subtitle↔voice link is solved — statically**, from the movie config record in `tables.pak` (schema `0x067025b9`), not from the running game as this row previously assumed: [101 movies mapped](captures/movie-subtitle-voice-map.csv), 94 with subtitles, 83 with voice, 21 with a telop overlay. 93 of 94 subtitle refs resolve in the language paks; **`SUBTITLE_S12B.tbl` is missing from all six languages** — a dangling reference on the disc. Naming is `SUBTITLE_<base>.tbl` / `VOICE_<base>` with six documented exceptions. The record's ~104 **script ids** are ❔ — positional pairing drifts by three because the IDXD pool dedupes repeated values |
|
| IXUD subtitle | 🟡/✅ | `sylpheed-formats/src/ixud.rs` + [movie link](movie-subtitle-link.md) | timed cues. **The movie↔subtitle↔voice link is solved — statically**, from the movie config record in `tables.pak` (schema `0x067025b9`), not from the running game as this row previously assumed: [101 movies mapped](captures/movie-subtitle-voice-map.csv), 94 with subtitles, 83 with voice, 21 with a telop overlay. 93 of 94 subtitle refs resolve in the language paks; **`SUBTITLE_S12B.tbl` is missing from all six languages** — a dangling reference on the disc. Naming is `SUBTITLE_<base>.tbl` / `VOICE_<base>` with six documented exceptions. The record's ~104 **script ids** are ❔ — positional pairing drifts by three because the IDXD pool dedupes repeated values |
|
||||||
|
|||||||
Reference in New Issue
Block a user