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

@@ -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,