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

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