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
342 lines
11 KiB
Rust
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;
|