diff --git a/crates/sylpheed-formats/src/lsta.rs b/crates/sylpheed-formats/src/lsta.rs index 8065d61..5166b1a 100644 --- a/crates/sylpheed-formats/src/lsta.rs +++ b/crates/sylpheed-formats/src/lsta.rs @@ -48,12 +48,21 @@ pub fn parse(bytes: &[u8]) -> Option> { mod tests { 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 { - let mut b = vec![0u8; 64]; + 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()); + 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 } diff --git a/crates/sylpheed-formats/src/t8ad.rs b/crates/sylpheed-formats/src/t8ad.rs index 85da32a..32c973b 100644 --- a/crates/sylpheed-formats/src/t8ad.rs +++ b/crates/sylpheed-formats/src/t8ad.rs @@ -1,20 +1,28 @@ //! `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). +//! 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) +//! 0x14 4 width (BE u32) the full surface //! 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 +//! 0x1c 4 rectangle count (BE u32) — NOT ceil(w/256)*ceil(h/256) +//! 0x2c count*4 offset table: absolute byte offset of each rectangle +//! 16 rectangle header, four BE u32: dst X, dst Y, width, height +//! 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 @@ -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. -const TILE: usize = 256; -const TILE_HDR: usize = 16; +/// 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` 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. +/// `None` if any rectangle fails to fit the surface or the file — never guesses. pub fn parse(bytes: &[u8]) -> Option { if !is_t8ad(bytes) || bytes.len() < 0x40 { return None; @@ -64,41 +65,45 @@ pub fn parse(bytes: &[u8]) -> Option { 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 { + let rects = be32(bytes, 0x1c) as usize; + if rects == 0 || rects > 4096 { return None; } const TABLE: usize = 0x2c; - if bytes.len() < TABLE + tiles * 4 { + if bytes.len() < TABLE + rects * 4 { return None; } + // Anything no rectangle covers stays transparent. 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; - } + 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; } } } @@ -123,7 +128,11 @@ mod tests { 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 + // 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 } @@ -143,8 +152,8 @@ mod tests { } #[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. + 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); @@ -155,9 +164,9 @@ mod tests { 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]); + 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(&[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 let img = parse(&b).expect("decodes"); assert_eq!((img.width, img.height), (300, 1)); @@ -166,10 +175,11 @@ mod tests { } #[test] - fn rejects_wrong_tilecount_and_short() { - // tile-count field that isn't ceil(w/256)*ceil(h/256) → None + 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); - 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()); // truncated pixel data → None let b = synth(64, 64); diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index edeb4bd..a25bf8b 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -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 | | 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 ❔ | -| 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 | | 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_.tbl` / `VOICE_` with six documented exceptions. The record's ~104 **script ids** are ❔ — positional pairing drifts by three because the IDXD pool dedupes repeated values |