Files
Sylpheed/crates/sylpheed-formats/src/ixud.rs
Sylpheed RE agent 80a45bfbd7 re: wire the IXUD record table into the crate — captions go 537 to 8800 of 8800
ixud.rs now has an IdxdObject-shaped reader, IxudObject, and build_caption_text
reads captions as FIELDS instead of pairing them with whatever token follows in
the pool.

  build_demo_text      token adjacency   134 ids   537 lines
  build_caption_text   token adjacency  3721      8074
  build_caption_text   record fields    4085      8800  = all of them

Verified over the whole disc by tests/ixud_records_disc.rs: 1104/1104 objects
parse, 1476/1476 records and 628165/628165 named fields reproduce their
ixud_hash, 48 positional, zero failures. The header word at 0x08 is record 0's
hash, asserted per object -- there is no schema field, exactly as for IDXD. The
module doc described a 12-byte record directory and a "schema/type hash"; both
were wrong and are corrected.

I also have to correct my own number from the previous commit. "1.3% of the
game's text" counted OCCURRENCES: each family lives in 24-45 IXUD blocks and
the same key repeats across them. Distinct text-bearing MSG_* keys number 8800,
not 44579, and every one has the <id>_<page>_<line> shape. So the real coverage
was 537/8800 = 6.1%, and I overstated the gap about fivefold. Direction right,
magnitude wrong.

The DEMO control is the sharpest evidence for the change: token adjacency finds
537 lines there, the field reader 541. It was dropping lines even in the one
family it was written for -- which is why the test now asserts "must not lose
lines" rather than "must be identical".

Same lesson twice in one session: pool adjacency is a consequence of how
records are written, not a rule of the format.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-26 02:55:26 +00:00

342 lines
11 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 — ✅ decoded and verified disc-wide
//!
//! ```text
//! 0x00 4 Magic "IXUD"
//! 0x04 4 record_count n
//! 0x08 16*n records { u32 name_hash, u32 name_off, u32 field_begin, u32 field_end }
//! .. 4 field_count m
//! .. 12*m fields { u32 key, u32 name_off, u32 value_off }
//! .. 4 pool_size (in CHARS)
//! .. .. UTF-16BE string pool
//! ```
//!
//! **Every offset is in 16-bit chars, not bytes** — `pool_base + 2*off`. That
//! includes record and field *names*, not only values, and it is what makes the
//! `STR + 2*strsize == filesize` identity hold.
//!
//! `name_hash` is [`crate::hash::ixud_hash`] of the record's own name, and a
//! field's `key` is the same hash of its name; `name_off == 0xFFFF_FFFF` marks a
//! positional field whose key is a literal index. Verified over every IXUD object
//! on the disc: **1104/1104** objects parse, **1476/1476** records and
//! **628 165/628 165** named fields reproduce their stored hash.
//!
//! ❌ The layout this comment used to describe — a 12-byte record directory and a
//! "schema/type hash" at `0x08` — was wrong in the same way IDXD's was. There is
//! no schema field: the word at `0x08` is record 0's `name_hash`. See
//! `docs/re/structures/idxd-container.md`.
//!
//! The cue reader below still works on the string pool directly, which is fine
//! for presenting a track; [`IxudObject`] is the addressable route.
/// 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());
}
}
/// A parsed IXUD object: its records and their fields, addressable by name.
///
/// The counterpart of [`crate::IdxdObject`] for wide strings. Prefer this to the
/// token-pairing readers when you need a *specific* field: pool adjacency is a
/// consequence of how records are written, not a rule of the format.
#[derive(Debug, Clone)]
pub struct IxudObject {
/// The word at `0x08` — record 0's `name_hash`, not a schema id.
pub first_record_hash: u32,
records: Vec<IxudRecord>,
}
/// One named record of an [`IxudObject`].
#[derive(Debug, Clone)]
pub struct IxudRecord {
/// The record's name, decoded from UTF-16BE.
pub name: String,
/// `ixud_hash(name)`, as stored.
pub name_hash: u32,
/// This record's fields, in on-disc order.
pub fields: Vec<IxudField>,
}
/// One field of an [`IxudRecord`].
#[derive(Debug, Clone)]
pub struct IxudField {
/// `ixud_hash(name)` when named, else a literal positional index.
pub key: u32,
/// The field's own name, when it has one.
pub name: Option<String>,
/// The field's value.
pub value: String,
}
impl IxudRecord {
/// The field named `name` (matched by `ixud_hash`), if present.
pub fn field(&self, name: &str) -> Option<&IxudField> {
let key = crate::hash::ixud_hash_str(name);
self.fields.iter().find(|f| f.key == key)
}
/// The value of the field named `name`.
pub fn get(&self, name: &str) -> Option<&str> {
self.field(name).map(|f| f.value.as_str())
}
}
impl IxudObject {
/// Parse an IXUD object. `None` when the layout does not check out — the
/// trailing `pool_size` identity makes that self-verifying.
pub fn parse(b: &[u8]) -> Option<Self> {
if b.len() < 12 || b[0..4] != IXUD_MAGIC {
return None;
}
let be32 = |o: usize| u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
let n = be32(4) as usize;
if n == 0 || n > b.len() / 16 {
return None;
}
let field_count_at = 8usize.checked_add(n.checked_mul(16)?)?;
if field_count_at + 4 > b.len() {
return None;
}
let m = be32(field_count_at) as usize;
let fields_at = field_count_at + 4;
let pool_size_at = fields_at.checked_add(m.checked_mul(12)?)?;
let pool = pool_size_at.checked_add(4)?;
if pool > b.len() {
return None;
}
// pool_size counts CHARS, so this identity also proves the char scaling.
if (be32(pool_size_at) as usize).checked_mul(2)? != b.len() - pool {
return None;
}
let wstr = |off: u32| -> Option<String> {
if off == NO_NAME {
return None;
}
let start = pool.checked_add((off as usize).checked_mul(2)?)?;
let mut units = Vec::new();
let mut i = start;
while i + 1 < b.len() {
let u = u16::from_be_bytes([b[i], b[i + 1]]);
if u == 0 {
return Some(String::from_utf16_lossy(&units));
}
units.push(u);
i += 2;
}
None
};
let mut records = Vec::with_capacity(n);
for i in 0..n {
let r = 8 + 16 * i;
let (begin, end) = (be32(r + 8) as usize, be32(r + 12) as usize);
if begin > end || end > m {
return None;
}
let mut fields = Vec::with_capacity(end - begin);
for j in begin..end {
let f = fields_at + 12 * j;
fields.push(IxudField {
key: be32(f),
name: wstr(be32(f + 4)),
value: wstr(be32(f + 8))?,
});
}
records.push(IxudRecord {
name: wstr(be32(r + 4))?,
name_hash: be32(r),
fields,
});
}
Some(Self {
first_record_hash: be32(8),
records,
})
}
/// Every record, in on-disc order (ascending `name_hash`).
pub fn records(&self) -> &[IxudRecord] {
&self.records
}
/// The record named `name` (matched by `ixud_hash`).
pub fn record(&self, name: &str) -> Option<&IxudRecord> {
let key = crate::hash::ixud_hash_str(name);
self.records.iter().find(|r| r.name_hash == key)
}
}
/// A field `name_off` of `0xFFFF_FFFF` marks a positional field.
const NO_NAME: u32 = 0xFFFF_FFFF;