//! IXUD localized-string / subtitle table reader. //! //! `IXUD` entries in the language packs (`dat/movie/.pak`, the //! `GP_MAIN_GAME_` 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, pub text: String, } /// A decoded subtitle track. #[derive(Debug, Clone, Default, PartialEq)] pub struct Subtitle { pub cues: Vec, } 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 { 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 { 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)> { let one = |p: &str| -> Option { 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 { 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()); } }