feat(formats,viewer): decode T8aD textures, RATC bundles, LSTA sprites
Add three static PAK-format decoders in the Bevy-free formats crate and present them in the explorer: - t8ad.rs: linear 32bpp A8R8G8B8 2D texture (UI/HUD art). Dims at 0x14/0x18, header size keyed by the type field @0x1c (container-safe). Decodes the ~85% RGBA variants; defers the rest (likely DXT) rather than misdecoding. - ratc.rs: nested resource bundle — lists named children by magic scan. - lsta.rs: sprite list — walks the inline T8aD frames. Viewer: PakContent gains T8ad/Lsta/Ratc, decoded off-thread and bounded by an 8M-texel per-entry cap; detail views show the texture, a sprite grid, and a child list with thumbnails. Decoded images carry a "colours unverified" note — channel-order/sRGB stays on the dynamic-RE backlog. Tests: 12 new unit + 3 real-disc (T8aD >=70% decode over the hangar pack, LSTA frames, RATC named children incl. a decodable T8aD). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
134
crates/sylpheed-formats/src/t8ad.rs
Normal file
134
crates/sylpheed-formats/src/t8ad.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
//! `T8aD` — the game's 2D UI/HUD texture format.
|
||||
//!
|
||||
//! A linear (untiled) 32bpp surface stored **A8R8G8B8** (Xbox byte order), with
|
||||
//! a small fixed-per-variant header:
|
||||
//!
|
||||
//! ```text
|
||||
//! 0x00 4 Magic "T8aD"
|
||||
//! 0x14 4 width (BE u32)
|
||||
//! 0x18 4 height (BE u32)
|
||||
//! 0x1c 4 type field → header size: 1→64 2→84 3→104 4→124 15→344
|
||||
//! <header> w*h*4 bytes of A8R8G8B8 pixel data, row-major
|
||||
//! ```
|
||||
//!
|
||||
//! Static forensics over the disc showed ~85% of entries decode exactly with
|
||||
//! this rule; the rest (unknown type field or a `w*h*4` that doesn't fit — most
|
||||
//! likely DXT / palettized variants) return `None` rather than a wrong image.
|
||||
//!
|
||||
//! Keying the header size off the type field (not `len - w*h*4`) makes the
|
||||
//! decoder **container-safe**: RATC/LSTA hand us a child slice that may have
|
||||
//! trailing padding before the next child, so we must not infer the header from
|
||||
//! the slice length.
|
||||
//!
|
||||
//! ⚠️ Colour correctness (channel order / endianness / sRGB) is unverified
|
||||
//! against the running game — see the RE backlog.
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// Whether `bytes` starts with the T8aD magic.
|
||||
pub fn is_t8ad(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= 4 && bytes[0..4] == T8AD_MAGIC
|
||||
}
|
||||
|
||||
/// Header size in bytes for a given type field (`@0x1c`), or `None` for an
|
||||
/// unrecognized variant.
|
||||
fn header_size(type_field: u32) -> Option<usize> {
|
||||
match type_field {
|
||||
1 => Some(64),
|
||||
2 => Some(84),
|
||||
3 => Some(104),
|
||||
4 => Some(124),
|
||||
15 => Some(344),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn be32(b: &[u8], off: usize) -> u32 {
|
||||
u32::from_be_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]])
|
||||
}
|
||||
|
||||
/// 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).
|
||||
pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
|
||||
if !is_t8ad(bytes) || bytes.len() < 0x40 {
|
||||
return None;
|
||||
}
|
||||
let width = be32(bytes, 0x14);
|
||||
let height = be32(bytes, 0x18);
|
||||
if !(1..=4096).contains(&width) || !(1..=4096).contains(&height) {
|
||||
return None;
|
||||
}
|
||||
let header = header_size(be32(bytes, 0x1c))?;
|
||||
let n = (width as usize) * (height as usize) * 4;
|
||||
if bytes.len() < header + n {
|
||||
return None; // DXT/palettized/short variant — defer, don't misdecode
|
||||
}
|
||||
|
||||
// A8R8G8B8 → RGBA8.
|
||||
let src = &bytes[header..header + n];
|
||||
let mut rgba = vec![0u8; n];
|
||||
for (px, out) in src.chunks_exact(4).zip(rgba.chunks_exact_mut(4)) {
|
||||
let (a, r, g, b) = (px[0], px[1], px[2], px[3]);
|
||||
out[0] = r;
|
||||
out[1] = g;
|
||||
out[2] = b;
|
||||
out[3] = a;
|
||||
}
|
||||
Some(T8adImage {
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a synthetic type-1 (header 64) T8aD with a known A8R8G8B8 pattern.
|
||||
fn synth(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 64];
|
||||
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()); // type 1 → header 64
|
||||
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 rejects_unknown_variant_and_short() {
|
||||
// type field 7 (unknown) → 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user