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:
MechaCat02
2026-07-11 16:49:50 +02:00
parent 10425aba8c
commit e1ca95c0af
7 changed files with 656 additions and 22 deletions

View File

@@ -31,6 +31,15 @@ pub mod ixud;
// Embedded font (OTF / TTF / ttcf) metadata
pub mod font;
// T8aD 2D UI/HUD texture
pub mod t8ad;
// RATC nested resource bundle
pub mod ratc;
// LSTA sprite list (inline T8aD frames)
pub mod lsta;
// XISO reading is not available in-browser
#[cfg(not(target_arch = "wasm32"))]
pub mod xiso;
@@ -45,6 +54,8 @@ pub mod audio;
pub use font::FontInfo;
pub use idxd::{IdxdError, IdxdObject};
pub use ixud::{Cue, Subtitle};
pub use ratc::RatcChild;
pub use t8ad::T8adImage;
pub use pak::{PakArchive, PakEntry, PakError};
pub use texture::{X360Texture, X360TextureFormat};
pub use vfs::{GameAssets, VfsError};

View File

@@ -0,0 +1,78 @@
//! `LSTA` — a sprite list: a header followed by N inline [`T8aD`](crate::t8ad)
//! frames concatenated back-to-back.
//!
//! A `count` lives at `@0x04`, but a few entries disagree with the actual frame
//! count, so we walk by the `T8aD` magic instead (robust) and decode each frame
//! from its slice up to the next frame (or end).
use crate::t8ad::{self, T8adImage, T8AD_MAGIC};
/// Magic at the start of an LSTA sprite list.
pub const LSTA_MAGIC: [u8; 4] = *b"LSTA";
/// Whether `bytes` is an LSTA list.
pub fn is_lsta(bytes: &[u8]) -> bool {
bytes.len() >= 4 && bytes[0..4] == LSTA_MAGIC
}
/// Decode all inline T8aD frames. Frames that don't decode (unsupported T8aD
/// variant) are skipped. Returns `None` only for non-LSTA input.
pub fn parse(bytes: &[u8]) -> Option<Vec<T8adImage>> {
if !is_lsta(bytes) {
return None;
}
// Offsets of each inline T8aD frame (search past the 4-byte magic).
let mut offs = Vec::new();
let mut i = 4;
while i + 4 <= bytes.len() {
if bytes[i..i + 4] == T8AD_MAGIC {
offs.push(i);
i += 4;
} else {
i += 1;
}
}
let mut frames = Vec::with_capacity(offs.len());
for (idx, &off) in offs.iter().enumerate() {
let next = offs.get(idx + 1).copied().unwrap_or(bytes.len());
if let Some(img) = t8ad::parse(&bytes[off..next]) {
frames.push(img);
}
}
Some(frames)
}
#[cfg(test)]
mod tests {
use super::*;
fn t8ad_frame(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());
b.extend_from_slice(&vec![0x80u8; (w * h * 4) as usize]);
b
}
#[test]
fn walks_inline_frames() {
let mut b = LSTA_MAGIC.to_vec();
b.extend_from_slice(&2u32.to_be_bytes());
b.extend_from_slice(&[0u8; 15]); // header remainder
b.extend_from_slice(&t8ad_frame(4, 4));
b.extend_from_slice(&t8ad_frame(2, 3));
let frames = parse(&b).unwrap();
assert_eq!(frames.len(), 2);
assert_eq!((frames[0].width, frames[0].height), (4, 4));
assert_eq!((frames[1].width, frames[1].height), (2, 3));
}
#[test]
fn rejects_non_lsta() {
assert!(parse(b"T8aD....").is_none());
}
}

View File

@@ -0,0 +1,131 @@
//! `RATC` — a nested resource bundle.
//!
//! After a small header, a RATC holds named children — `foo.t32` (T8aD
//! textures), `foo.rat` (nested RATC), fonts, PNG — each immediately preceded by
//! its ASCII name string. The children are self-locating by their 4-char magic,
//! so we list them by scanning for those magics and pairing each with the name
//! run that precedes it. This is reliable for *listing* (names / types / sizes
//! are literal bytes); decoding a child's pixels is delegated to that child's
//! own parser ([`crate::t8ad`]).
/// A child resource inside a RATC bundle.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RatcChild {
/// Name string preceding the child (e.g. `prselectbtn_g04b.t32`), or empty.
pub name: String,
/// Child magic tag: `T8aD`, `RATC`, `ttcf`, `png`, …
pub kind: String,
/// Byte offset of the child (its magic) within the RATC payload.
pub offset: usize,
/// Byte length of the child, up to the next child (or end of payload).
pub size: usize,
}
/// Magic at the start of a RATC bundle.
pub const RATC_MAGIC: [u8; 4] = *b"RATC";
/// Whether `bytes` is a RATC bundle.
pub fn is_ratc(bytes: &[u8]) -> bool {
bytes.len() >= 4 && bytes[0..4] == RATC_MAGIC
}
/// Recognized child magics and their display tag.
fn child_kind(m: &[u8]) -> Option<&'static str> {
match m {
b"T8aD" => Some("T8aD"),
b"RATC" => Some("RATC"),
b"ttcf" => Some("ttc"),
b"\x89PNG" => Some("png"),
_ => None,
}
}
/// List the children of a RATC bundle (the self-magic at offset 0 is skipped).
pub fn parse(bytes: &[u8]) -> Option<Vec<RatcChild>> {
if !is_ratc(bytes) {
return None;
}
// Offsets of every recognized child magic (skip the self RATC at 0).
let mut offs: Vec<(usize, &'static str)> = Vec::new();
let mut i = 4;
while i + 4 <= bytes.len() {
if let Some(kind) = child_kind(&bytes[i..i + 4]) {
offs.push((i, kind));
i += 4;
} else {
i += 1;
}
}
let mut children = Vec::with_capacity(offs.len());
for (idx, &(off, kind)) in offs.iter().enumerate() {
let next = offs.get(idx + 1).map(|&(o, _)| o).unwrap_or(bytes.len());
children.push(RatcChild {
name: name_before(bytes, off),
kind: kind.to_string(),
offset: off,
size: next.saturating_sub(off),
});
}
Some(children)
}
/// The nearest name string preceding `off`: the *last* printable run (len ≥ 3)
/// in the 96 bytes before the child magic. A few record-header bytes usually sit
/// between the name and the magic, so an exact-adjacency scan isn't enough.
fn name_before(bytes: &[u8], off: usize) -> String {
let start = off.saturating_sub(96);
let window = &bytes[start..off];
let mut best = String::new();
let mut run_start: Option<usize> = None;
let flush = |from: usize, to: usize, best: &mut String| {
if to - from >= 3 {
*best = String::from_utf8_lossy(&window[from..to]).trim().to_string();
}
};
for (i, &c) in window.iter().enumerate() {
if (0x20..=0x7e).contains(&c) {
run_start.get_or_insert(i);
} else if let Some(s) = run_start.take() {
flush(s, i, &mut best);
}
}
if let Some(s) = run_start {
flush(s, window.len(), &mut best);
}
best
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lists_named_children() {
let mut b = RATC_MAGIC.to_vec();
b.extend_from_slice(&[0u8; 28]); // header padding
b.extend_from_slice(b"logo.t32");
let t8_off = b.len();
b.extend_from_slice(b"T8aD");
b.extend_from_slice(&[0u8; 40]); // some child bytes
b.extend_from_slice(b"sub.rat");
let ratc_off = b.len();
b.extend_from_slice(b"RATC");
b.extend_from_slice(&[0u8; 8]);
let kids = parse(&b).unwrap();
assert_eq!(kids.len(), 2);
assert_eq!(kids[0].kind, "T8aD");
assert_eq!(kids[0].name, "logo.t32");
assert_eq!(kids[0].offset, t8_off);
assert_eq!(kids[0].size, ratc_off - t8_off);
assert_eq!(kids[1].kind, "RATC");
assert_eq!(kids[1].name, "sub.rat");
}
#[test]
fn rejects_non_ratc() {
assert!(parse(b"T8aD....").is_none());
}
}

View 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());
}
}