feat(viewer): present subtitles, fonts, PNG + text in the PAK browser

Decode and present four more PAK item types in the pack browser's detail
pane. All content is decoded off-thread in build_pak_rows and attached to
each PakRow, then rendered with egui's own texture facilities — the Bevy
texture pipeline and egui's global font system are untouched.

formats crate (Bevy-free, reusable by CLI):
- ixud: parse IXUD localized-string tables as subtitle cue lists (UTF-16BE
  pool, SUBTITLE header + (text, timecode) pairs -> Vec<Cue{start,end,text}>).
- font: OTF/TTF/ttcf metadata (family / faces / glyphs) via ttf-parser.

viewer:
- PakRow gains `content: PakContent` (Subtitle | Font | Png | Text | None);
  classify_content fills it for IXUD / fonts / PNG / xml+text within the
  existing decode budget. Font samples are pre-rasterized off-thread with
  ab_glyph into an image (ImageRgba) — ab_glyph returns Option/Result at every
  step, so a bad font yields no sample instead of panicking (the earlier
  egui set_fonts approach crashed the app on atlas rebuild).
- draw_pak_browser dispatches on content: subtitle cue table, font metadata +
  sample image, PNG image, scrollable text; else the existing IDXD detail.
  Borrow-split PakView instead of cloning the (now heavy) rows each frame;
  one hash-keyed egui texture cache serves both PNG and font-sample images.

Subtitle<->movie auto-matching is deferred: movie filenames don't hash to the
IXUD keys and no cross-reference table was found (the link is an internal
MSG_DEMO id), so the movie player gets no subtitle track yet.

Tests: ixud unit tests; disc tests for the real English subtitle track
(cues + timecodes), eng/deu localization, font metadata, and off-thread
rasterization of the real font.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-11 15:19:31 +02:00
parent 91bd2543f4
commit e17bc0216d
9 changed files with 721 additions and 47 deletions

View File

@@ -124,6 +124,45 @@ fn name_lookup_resolves_weapon_tbl() {
assert!(obj.count > 0);
}
/// The English movie subtitle track (an `IXUD` entry) decodes to timed cues.
#[test]
fn eng_movie_subtitle_track() {
skip_without_disc!(root);
let arc = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap();
// S04A's track — the longest inline English one (starts "Calm down!").
let bytes = arc.read_by_hash(0x73d2_2a3b).expect("entry present").unwrap();
let sub = sylpheed_formats::ixud::parse(&bytes).expect("IXUD parses as subtitle");
assert!(sub.cues.len() > 50, "cues={}", sub.cues.len());
assert!(!sub.is_reference_only());
assert_eq!(sub.cues[0].text, "Calm down!");
assert!((sub.cues[0].start - 9.4).abs() < 0.01);
assert_eq!(sub.cues[0].end, Some(11.0));
}
/// The subtitle tracks are genuinely localized: the same entry hash differs
/// between the English and German packs.
#[test]
fn subtitles_are_localized() {
skip_without_disc!(root);
let eng = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap();
let deu = PakArchive::open(root.join("dat/movie/deu.pak")).unwrap();
let e = eng.read_by_hash(0x73d2_2a3b).unwrap().unwrap();
let d = deu.read_by_hash(0x73d2_2a3b).unwrap().unwrap();
assert_ne!(e, d, "eng/deu subtitle payloads should differ");
}
/// The English movie pack's embedded subtitle font parses to sane metadata.
#[test]
fn eng_movie_font_metadata() {
skip_without_disc!(root);
let arc = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap();
let bytes = arc.read_by_hash(0x5cd0_fca6).expect("entry present").unwrap();
assert!(sylpheed_formats::font::is_font(&bytes));
let info = sylpheed_formats::font::parse_info(&bytes).expect("font parses");
assert!(info.glyphs > 0, "glyphs={}", info.glyphs);
assert!(!info.family.is_empty(), "family should be readable");
}
/// The Acheron backdrop is a TXCM world cubemap: 6 faces, and the cube path's
/// face 0 matches the (validated) 2D `from_xpr2` face-0 decode.
#[test]