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>
188 lines
5.9 KiB
Rust
188 lines
5.9 KiB
Rust
//! IXUD localized-string / subtitle table reader.
|
||
//!
|
||
//! `IXUD` entries in the language packs (`dat/movie/<lang>.pak`, the
|
||
//! `GP_MAIN_GAME_<lang>` tables, …) hold localized UTF-16**BE** strings. The
|
||
//! movie language packs use them as **subtitle cue lists**: a `SUBTITLE` header
|
||
//! followed by alternating `(text, timecode)` tokens, e.g.
|
||
//!
|
||
//! ```text
|
||
//! SUBTITLE
|
||
//! "Calm down!" 00:09.40-00:11.00
|
||
//! "Why are you taking Margras away?" 00:18.50-00:19.70
|
||
//! ```
|
||
//!
|
||
//! Reference-style tracks store a message key (`MSG_DEMO_240`) plus a single
|
||
//! start timecode instead of inline text; the actual string then lives in a
|
||
//! separate global table (not resolved here).
|
||
//!
|
||
//! ## Binary layout (as far as needed)
|
||
//! ```text
|
||
//! 0x00 4 Magic "IXUD"
|
||
//! 0x04 4 version (1)
|
||
//! 0x08 4 schema/type hash (constant 0x6CC83E70)
|
||
//! .. .. record directory { key_hash u32, offset u32, len u32 } × n
|
||
//! .. .. UTF-16BE string pool, NUL-terminated entries
|
||
//! ```
|
||
//! We don't need the directory to *present* the track — decoding the pool and
|
||
//! pairing tokens after the `SUBTITLE` header is enough and robust.
|
||
|
||
/// Magic at the start of every IXUD entry.
|
||
pub const IXUD_MAGIC: [u8; 4] = *b"IXUD";
|
||
|
||
/// One subtitle cue: a start time (seconds), optional end time, and the text
|
||
/// (or a `MSG_*` reference key for reference-style tracks).
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct Cue {
|
||
pub start: f32,
|
||
pub end: Option<f32>,
|
||
pub text: String,
|
||
}
|
||
|
||
/// A decoded subtitle track.
|
||
#[derive(Debug, Clone, Default, PartialEq)]
|
||
pub struct Subtitle {
|
||
pub cues: Vec<Cue>,
|
||
}
|
||
|
||
impl Subtitle {
|
||
/// True when the track carries no inline text — every cue is a `MSG_*`
|
||
/// reference whose string lives in a separate global table.
|
||
pub fn is_reference_only(&self) -> bool {
|
||
!self.cues.is_empty() && self.cues.iter().all(|c| c.text.starts_with("MSG_"))
|
||
}
|
||
}
|
||
|
||
/// Whether `bytes` is an IXUD entry.
|
||
pub fn is_ixud(bytes: &[u8]) -> bool {
|
||
bytes.len() >= 4 && bytes[0..4] == IXUD_MAGIC
|
||
}
|
||
|
||
/// Parse an IXUD entry as a subtitle track. Returns `None` if not IXUD.
|
||
/// Malformed pools yield as many well-formed cues as can be recovered.
|
||
pub fn parse(bytes: &[u8]) -> Option<Subtitle> {
|
||
if !is_ixud(bytes) {
|
||
return None;
|
||
}
|
||
|
||
// Decode the whole payload as UTF-16BE and split into NUL-terminated,
|
||
// printable runs. The binary header/directory decodes to control/CJK-range
|
||
// junk that we skip by seeking to the `SUBTITLE` marker.
|
||
let tokens = utf16be_tokens(bytes);
|
||
let start = tokens
|
||
.iter()
|
||
.position(|t| t.ends_with("SUBTITLE"))
|
||
.map(|i| i + 1)
|
||
.unwrap_or(0);
|
||
let rest = &tokens[start..];
|
||
|
||
// Pair (text, timecode). A token that parses as a timecode closes the cue
|
||
// opened by the preceding text token; unpaired tokens are skipped so a
|
||
// single glitch doesn't desync the rest.
|
||
let mut cues = Vec::new();
|
||
let mut i = 0;
|
||
while i + 1 < rest.len() {
|
||
if let Some((s, e)) = parse_timing(&rest[i + 1]) {
|
||
cues.push(Cue {
|
||
start: s,
|
||
end: e,
|
||
text: rest[i].clone(),
|
||
});
|
||
i += 2;
|
||
} else {
|
||
i += 1;
|
||
}
|
||
}
|
||
Some(Subtitle { cues })
|
||
}
|
||
|
||
/// Split the payload (interpreted as UTF-16BE) into printable, NUL-delimited
|
||
/// tokens. Control chars terminate the current token.
|
||
fn utf16be_tokens(bytes: &[u8]) -> Vec<String> {
|
||
let mut tokens = Vec::new();
|
||
let mut cur = String::new();
|
||
for pair in bytes.chunks_exact(2) {
|
||
let u = u16::from_be_bytes([pair[0], pair[1]]);
|
||
match char::from_u32(u as u32) {
|
||
Some(c) if !c.is_control() => cur.push(c),
|
||
_ => {
|
||
if !cur.is_empty() {
|
||
tokens.push(std::mem::take(&mut cur));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if !cur.is_empty() {
|
||
tokens.push(cur);
|
||
}
|
||
tokens
|
||
}
|
||
|
||
/// Parse `MM:SS.ss` (`" 9:04.40"` style, minutes may be blank/space-padded) or
|
||
/// a `start-end` range into seconds.
|
||
fn parse_timing(s: &str) -> Option<(f32, Option<f32>)> {
|
||
let one = |p: &str| -> Option<f32> {
|
||
let (mm, ss) = p.trim().split_once(':')?;
|
||
let m: f32 = if mm.trim().is_empty() {
|
||
0.0
|
||
} else {
|
||
mm.trim().parse().ok()?
|
||
};
|
||
let sec: f32 = ss.trim().parse().ok()?;
|
||
Some(m * 60.0 + sec)
|
||
};
|
||
match s.split_once('-') {
|
||
Some((a, b)) => Some((one(a)?, Some(one(b)?))),
|
||
None => Some((one(s)?, None)),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Build a synthetic IXUD payload: an 8-byte header stand-in + UTF-16BE
|
||
/// NUL-terminated tokens.
|
||
fn synth(tokens: &[&str]) -> Vec<u8> {
|
||
let mut b = IXUD_MAGIC.to_vec();
|
||
b.extend_from_slice(&[0, 0, 0, 1, 0x6C, 0xC8, 0x3E, 0x70]); // version + schema
|
||
for t in tokens {
|
||
for u in t.encode_utf16() {
|
||
b.extend_from_slice(&u.to_be_bytes());
|
||
}
|
||
b.extend_from_slice(&[0, 0]); // NUL
|
||
}
|
||
b
|
||
}
|
||
|
||
#[test]
|
||
fn inline_subtitle_track() {
|
||
let b = synth(&[
|
||
"SUBTITLE",
|
||
"Calm down!",
|
||
"00:09.40-00:11.00",
|
||
"Let go of me!",
|
||
"00:13.10-00:14.50",
|
||
]);
|
||
let sub = parse(&b).unwrap();
|
||
assert_eq!(sub.cues.len(), 2);
|
||
assert_eq!(sub.cues[0].text, "Calm down!");
|
||
assert_eq!(sub.cues[0].start, 9.4);
|
||
assert_eq!(sub.cues[0].end, Some(11.0));
|
||
assert!(!sub.is_reference_only());
|
||
}
|
||
|
||
#[test]
|
||
fn reference_track() {
|
||
let b = synth(&["SUBTITLE", "MSG_DEMO_240", "00:01.00", "MSG_DEMO_241", "00:03.20"]);
|
||
let sub = parse(&b).unwrap();
|
||
assert_eq!(sub.cues.len(), 2);
|
||
assert_eq!(sub.cues[0].end, None);
|
||
assert!(sub.is_reference_only());
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_non_ixud() {
|
||
assert!(parse(b"IDXD\0\0\0\0").is_none());
|
||
}
|
||
}
|