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:
@@ -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};
|
||||
|
||||
78
crates/sylpheed-formats/src/lsta.rs
Normal file
78
crates/sylpheed-formats/src/lsta.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
131
crates/sylpheed-formats/src/ratc.rs
Normal file
131
crates/sylpheed-formats/src/ratc.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -154,10 +154,26 @@ pub enum PakContent {
|
||||
},
|
||||
/// Decoded PNG image (RGBA8), shown via an egui texture.
|
||||
Png(ImageRgba),
|
||||
/// A decoded T8aD 2D texture.
|
||||
T8ad(ImageRgba),
|
||||
/// An LSTA sprite list — inline T8aD frames.
|
||||
Lsta(Vec<ImageRgba>),
|
||||
/// A RATC bundle — its listed children (T8aD children carry a decoded image).
|
||||
Ratc(Vec<RatcEntry>),
|
||||
/// Plain-text / XML payload, with its encoding label.
|
||||
Text { text: String, encoding: String },
|
||||
}
|
||||
|
||||
/// One child in a presented RATC bundle.
|
||||
#[derive(Clone)]
|
||||
pub struct RatcEntry {
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub size: usize,
|
||||
/// Decoded image when the child is a (decodable) T8aD within the pixel budget.
|
||||
pub image: Option<ImageRgba>,
|
||||
}
|
||||
|
||||
/// A plain RGBA8 image handed to the UI for an egui texture.
|
||||
#[derive(Clone)]
|
||||
pub struct ImageRgba {
|
||||
@@ -166,6 +182,19 @@ pub struct ImageRgba {
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ImageRgba {
|
||||
fn from_t8ad(img: sylpheed_formats::T8adImage) -> Self {
|
||||
Self {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
rgba: img.rgba,
|
||||
}
|
||||
}
|
||||
fn pixels(&self) -> usize {
|
||||
(self.width as usize) * (self.height as usize)
|
||||
}
|
||||
}
|
||||
|
||||
/// The parsed IDXD detail behind one entry, shown in the master-detail pane.
|
||||
#[derive(Clone)]
|
||||
pub struct PakDetail {
|
||||
@@ -253,9 +282,9 @@ pub struct PakView {
|
||||
pub rows: Vec<PakRow>,
|
||||
pub selected: Option<usize>,
|
||||
pub error: Option<String>,
|
||||
/// egui-side cache: (entry hash, texture) for the shown image — a PNG entry
|
||||
/// or a rasterized font sample.
|
||||
pub img_tex: Option<(u32, egui::TextureHandle)>,
|
||||
/// egui-side cache: (entry hash, textures) for the shown entry's image(s) —
|
||||
/// one for PNG/T8aD/font-sample, many for LSTA/RATC.
|
||||
pub img_tex: Option<(u32, Vec<egui::TextureHandle>)>,
|
||||
}
|
||||
|
||||
/// Total decompressed bytes we're willing to decode when labelling a pack's
|
||||
@@ -819,6 +848,11 @@ fn build_pak_rows(index: &[u8], data: Vec<u8>) -> Result<Vec<PakRow>, String> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const PAK_TEXT_CAP: usize = 512 * 1024;
|
||||
|
||||
/// Cap on decoded child-image texels per LSTA/RATC entry (~8 M texels = 32 MB
|
||||
/// RGBA). Further children past it are listed but not decoded to an image.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const PAK_IMAGE_TEXEL_BUDGET: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// Classify a (non-IDXD) decompressed entry into presentable content: subtitle
|
||||
/// track, embedded font, PNG image, or plain text/XML. Runs off-thread.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -853,6 +887,58 @@ fn classify_content(payload: &[u8]) -> PakContent {
|
||||
});
|
||||
}
|
||||
}
|
||||
if sylpheed_formats::t8ad::is_t8ad(payload) {
|
||||
if let Some(img) = sylpheed_formats::t8ad::parse(payload) {
|
||||
return PakContent::T8ad(ImageRgba::from_t8ad(img));
|
||||
}
|
||||
}
|
||||
if sylpheed_formats::lsta::is_lsta(payload) {
|
||||
if let Some(frames) = sylpheed_formats::lsta::parse(payload) {
|
||||
let mut budget = PAK_IMAGE_TEXEL_BUDGET;
|
||||
let out: Vec<ImageRgba> = frames
|
||||
.into_iter()
|
||||
.map(ImageRgba::from_t8ad)
|
||||
.take_while(|f| {
|
||||
budget = budget.saturating_sub(f.pixels());
|
||||
budget > 0
|
||||
})
|
||||
.collect();
|
||||
if !out.is_empty() {
|
||||
return PakContent::Lsta(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
if sylpheed_formats::ratc::is_ratc(payload) {
|
||||
if let Some(children) = sylpheed_formats::ratc::parse(payload) {
|
||||
let mut budget = PAK_IMAGE_TEXEL_BUDGET;
|
||||
let entries: Vec<RatcEntry> = children
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
// Decode T8aD children up to the pixel budget; list the rest.
|
||||
let mut image = None;
|
||||
if c.kind == "T8aD" && budget > 0 {
|
||||
if let Some(img) = payload
|
||||
.get(c.offset..c.offset + c.size)
|
||||
.and_then(sylpheed_formats::t8ad::parse)
|
||||
.map(ImageRgba::from_t8ad)
|
||||
{
|
||||
budget = budget.saturating_sub(img.pixels());
|
||||
image = Some(img);
|
||||
}
|
||||
}
|
||||
RatcEntry {
|
||||
name: c.name,
|
||||
kind: c.kind,
|
||||
size: c.size,
|
||||
image,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if !entries.is_empty() {
|
||||
return PakContent::Ratc(entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
if payload.starts_with(b"<?xml") || vfs::identify_format(payload) == vfs::FileFormat::Text {
|
||||
let (mut text, encoding) = vfs::decode_text(payload);
|
||||
if text.len() > PAK_TEXT_CAP {
|
||||
|
||||
@@ -461,6 +461,9 @@ fn draw_pak_browser(ui: &mut egui::Ui, pak: &mut PakView) {
|
||||
draw_font_detail(ui, row, info, sample.as_ref(), img_tex)
|
||||
}
|
||||
PakContent::Png(img) => draw_png_detail(ui, row, img, img_tex),
|
||||
PakContent::T8ad(img) => draw_t8ad_detail(ui, row, img, img_tex),
|
||||
PakContent::Lsta(frames) => draw_lsta_detail(ui, row, frames, img_tex),
|
||||
PakContent::Ratc(children) => draw_ratc_detail(ui, row, children, img_tex),
|
||||
PakContent::Text { text, encoding } => {
|
||||
draw_text_detail(ui, row, text, encoding)
|
||||
}
|
||||
@@ -485,6 +488,9 @@ fn row_kind(row: &crate::iso_loader::PakRow) -> String {
|
||||
}
|
||||
}
|
||||
PakContent::Png(img) => format!("PNG {}×{}", img.width, img.height),
|
||||
PakContent::T8ad(img) => format!("T8aD {}×{}", img.width, img.height),
|
||||
PakContent::Lsta(f) => format!("LSTA · {} sprite(s)", f.len()),
|
||||
PakContent::Ratc(c) => format!("RATC · {} item(s)", c.len()),
|
||||
PakContent::Text { .. } => "text".into(),
|
||||
PakContent::None => row.identity.clone(),
|
||||
}
|
||||
@@ -574,29 +580,44 @@ fn draw_subtitle_detail(
|
||||
});
|
||||
}
|
||||
|
||||
/// Cache an `ImageRgba` as an egui texture (keyed by entry hash) and draw it,
|
||||
/// scaled to fit the available width (capped by `max_scale`). Shared by the PNG
|
||||
/// and font-sample views. Never touches egui's global font system.
|
||||
/// Per-entry egui texture cache: `(entry hash, one texture per shown image)`.
|
||||
type ImgCache = Option<(u32, Vec<egui::TextureHandle>)>;
|
||||
|
||||
/// Ensure `cache` holds egui textures for `imgs` under `hash`, rebuilding when
|
||||
/// the selected entry changes. All image content comes from off-thread decodes;
|
||||
/// this never touches egui's global font system.
|
||||
fn ensure_textures(ui: &egui::Ui, hash: u32, imgs: &[&ImageRgba], cache: &mut ImgCache) {
|
||||
if cache.as_ref().map(|(h, _)| *h) != Some(hash) {
|
||||
let texs = imgs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, img)| {
|
||||
let color = egui::ColorImage::from_rgba_unmultiplied(
|
||||
[img.width as usize, img.height as usize],
|
||||
&img.rgba,
|
||||
);
|
||||
ui.ctx().load_texture(
|
||||
format!("pak_img_{hash:08x}_{i}"),
|
||||
color,
|
||||
egui::TextureOptions::LINEAR,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
*cache = Some((hash, texs));
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache + draw a single `ImageRgba`, scaled to fit the available width (capped
|
||||
/// by `max_scale`). Shared by the PNG / T8aD / font-sample views.
|
||||
fn show_cached_image(
|
||||
ui: &mut egui::Ui,
|
||||
hash: u32,
|
||||
img: &ImageRgba,
|
||||
cache: &mut Option<(u32, egui::TextureHandle)>,
|
||||
cache: &mut ImgCache,
|
||||
max_scale: f32,
|
||||
) {
|
||||
if cache.as_ref().map(|(h, _)| *h) != Some(hash) {
|
||||
let color = egui::ColorImage::from_rgba_unmultiplied(
|
||||
[img.width as usize, img.height as usize],
|
||||
&img.rgba,
|
||||
);
|
||||
let tex = ui.ctx().load_texture(
|
||||
format!("pak_img_{hash:08x}"),
|
||||
color,
|
||||
egui::TextureOptions::LINEAR,
|
||||
);
|
||||
*cache = Some((hash, tex));
|
||||
}
|
||||
if let Some((_, tex)) = cache.as_ref() {
|
||||
ensure_textures(ui, hash, &[img], cache);
|
||||
if let Some(tex) = cache.as_ref().and_then(|(_, t)| t.first()) {
|
||||
let scale = (ui.available_width() / img.width.max(1) as f32).min(max_scale);
|
||||
let (dw, dh) = (img.width as f32 * scale, img.height as f32 * scale);
|
||||
ui.add(egui::Image::new(egui::load::SizedTexture::new(
|
||||
@@ -613,7 +634,7 @@ fn draw_font_detail(
|
||||
row: &crate::iso_loader::PakRow,
|
||||
info: &sylpheed_formats::FontInfo,
|
||||
sample: Option<&ImageRgba>,
|
||||
img_tex: &mut Option<(u32, egui::TextureHandle)>,
|
||||
img_tex: &mut ImgCache,
|
||||
) {
|
||||
ui.heading(if info.family.is_empty() {
|
||||
"Font"
|
||||
@@ -641,7 +662,7 @@ fn draw_png_detail(
|
||||
ui: &mut egui::Ui,
|
||||
row: &crate::iso_loader::PakRow,
|
||||
img: &ImageRgba,
|
||||
img_tex: &mut Option<(u32, egui::TextureHandle)>,
|
||||
img_tex: &mut ImgCache,
|
||||
) {
|
||||
ui.heading(format!("PNG image {}×{}", img.width, img.height));
|
||||
ui.label(format!("hash {:08x}", row.hash));
|
||||
@@ -649,6 +670,104 @@ fn draw_png_detail(
|
||||
show_cached_image(ui, row.hash, img, img_tex, 2.0);
|
||||
}
|
||||
|
||||
/// A decoded T8aD 2D texture. Colours are not yet verified against the game.
|
||||
fn draw_t8ad_detail(
|
||||
ui: &mut egui::Ui,
|
||||
row: &crate::iso_loader::PakRow,
|
||||
img: &ImageRgba,
|
||||
img_tex: &mut ImgCache,
|
||||
) {
|
||||
ui.heading(format!("T8aD 2D texture {}×{}", img.width, img.height));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!("hash {:08x}", row.hash));
|
||||
ui.separator();
|
||||
ui.weak("colours unverified");
|
||||
});
|
||||
ui.separator();
|
||||
show_cached_image(ui, row.hash, img, img_tex, 2.0);
|
||||
}
|
||||
|
||||
/// An LSTA sprite list — the inline T8aD frames in a grid.
|
||||
fn draw_lsta_detail(
|
||||
ui: &mut egui::Ui,
|
||||
row: &crate::iso_loader::PakRow,
|
||||
frames: &[ImageRgba],
|
||||
img_tex: &mut ImgCache,
|
||||
) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("LSTA sprite list");
|
||||
ui.separator();
|
||||
ui.label(format!("{} sprite(s)", frames.len()));
|
||||
ui.separator();
|
||||
ui.weak("colours unverified");
|
||||
});
|
||||
ui.separator();
|
||||
let refs: Vec<&ImageRgba> = frames.iter().collect();
|
||||
ensure_textures(ui, row.hash, &refs, img_tex);
|
||||
const CELL: f32 = 150.0;
|
||||
egui::Grid::new("lsta_sprites")
|
||||
.num_columns(3)
|
||||
.spacing([10.0, 10.0])
|
||||
.show(ui, |ui| {
|
||||
for (i, frame) in frames.iter().enumerate() {
|
||||
if let Some(tex) = img_tex.as_ref().and_then(|(_, t)| t.get(i)) {
|
||||
let aspect = frame.height as f32 / frame.width.max(1) as f32;
|
||||
ui.add(egui::Image::new(egui::load::SizedTexture::new(
|
||||
tex.id(),
|
||||
[CELL, CELL * aspect],
|
||||
)));
|
||||
}
|
||||
if (i + 1) % 3 == 0 {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A RATC bundle — a list of its named children, with thumbnails for the
|
||||
/// decoded T8aD ones.
|
||||
fn draw_ratc_detail(
|
||||
ui: &mut egui::Ui,
|
||||
row: &crate::iso_loader::PakRow,
|
||||
children: &[crate::iso_loader::RatcEntry],
|
||||
img_tex: &mut ImgCache,
|
||||
) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("RATC bundle");
|
||||
ui.separator();
|
||||
ui.label(format!("{} item(s)", children.len()));
|
||||
ui.separator();
|
||||
ui.weak("colours unverified");
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
let refs: Vec<&ImageRgba> = children.iter().filter_map(|c| c.image.as_ref()).collect();
|
||||
ensure_textures(ui, row.hash, &refs, img_tex);
|
||||
|
||||
let mut img_i = 0;
|
||||
for c in children {
|
||||
ui.horizontal(|ui| {
|
||||
if c.image.is_some() {
|
||||
if let Some(tex) = img_tex.as_ref().and_then(|(_, t)| t.get(img_i)) {
|
||||
let sz = tex.size_vec2();
|
||||
let scale = (48.0 / sz.y.max(1.0)).min(1.0);
|
||||
ui.add(egui::Image::new(egui::load::SizedTexture::new(
|
||||
tex.id(),
|
||||
[sz.x * scale, sz.y * scale],
|
||||
)));
|
||||
}
|
||||
img_i += 1;
|
||||
}
|
||||
ui.vertical(|ui| {
|
||||
let name = if c.name.is_empty() { "(unnamed)" } else { &c.name };
|
||||
ui.strong(name);
|
||||
ui.weak(format!("{} · {} bytes", c.kind, c.size));
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain-text / XML entry in a read-only monospace box.
|
||||
fn draw_text_detail(
|
||||
ui: &mut egui::Ui,
|
||||
|
||||
Reference in New Issue
Block a user