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

@@ -124,6 +124,81 @@ fn name_lookup_resolves_weapon_tbl() {
assert!(obj.count > 0);
}
/// Real T8aD entries decode to sane RGBA surfaces, and a good fraction of the
/// pack's T8aD entries are decodable (the RGBA-variant rule holds broadly).
#[test]
fn hangar_t8ad_textures_decode() {
skip_without_disc!(root);
let arc = PakArchive::open(root.join("dat/GP_HANGAR_ARSENAL.pak")).unwrap();
let (mut seen, mut decoded) = (0usize, 0usize);
for e in arc.entries() {
if e.comp_size > 4_000_000 {
continue;
}
let Ok(p) = arc.read(e) else { continue };
if !sylpheed_formats::t8ad::is_t8ad(&p) {
continue;
}
seen += 1;
if let Some(img) = sylpheed_formats::t8ad::parse(&p) {
decoded += 1;
assert!((1..=4096).contains(&img.width) && (1..=4096).contains(&img.height));
assert_eq!(img.rgba.len(), (img.width * img.height * 4) as usize);
}
}
assert!(seen > 100, "expected many T8aD, saw {seen}");
// The RGBA rule should cover the large majority (≈85% disc-wide).
assert!(decoded * 100 >= seen * 70, "decoded {decoded}/{seen}");
}
/// A real LSTA sprite list yields inline T8aD frames.
#[test]
fn lsta_sprite_list_decodes() {
skip_without_disc!(root);
let arc = PakArchive::open(root.join("dat/GP_HANGAR_ARSENAL.pak")).unwrap();
let mut found = false;
for e in arc.entries() {
let Ok(p) = arc.read(e) else { continue };
if !sylpheed_formats::lsta::is_lsta(&p) {
continue;
}
if let Some(frames) = sylpheed_formats::lsta::parse(&p) {
if !frames.is_empty() {
found = true;
assert!(frames.iter().all(|f| !f.rgba.is_empty()));
}
}
}
assert!(found, "no decodable LSTA found");
}
/// A real RATC bundle lists named children including a decodable T8aD.
#[test]
fn ratc_bundle_lists_children() {
skip_without_disc!(root);
let arc = PakArchive::open(root.join("dat/GP_HANGAR_ARSENAL.pak")).unwrap();
let mut ok = false;
for e in arc.entries() {
if e.comp_size > 4_000_000 {
continue;
}
let Ok(p) = arc.read(e) else { continue };
if !sylpheed_formats::ratc::is_ratc(&p) {
continue;
}
let Some(kids) = sylpheed_formats::ratc::parse(&p) else { continue };
let has_named = kids.iter().any(|c| c.name.contains('.'));
let has_t8ad = kids
.iter()
.any(|c| c.kind == "T8aD" && sylpheed_formats::t8ad::parse(&p[c.offset..c.offset + c.size]).is_some());
if !kids.is_empty() && has_named && has_t8ad {
ok = true;
break;
}
}
assert!(ok, "no RATC with named children + a decodable T8aD");
}
/// The English movie subtitle track (an `IXUD` entry) decodes to timed cues.
#[test]
fn eng_movie_subtitle_track() {