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
19 KiB
Movie subtitles & the movie ↔ mission ↔ text chain
Reverse-engineered 2026-07-19 (static, from the extracted disc). The full chain that links a cutscene movie to its on-screen subtitle text is now closed.
Files involved
dat/movie/*.wmv— the cutscene videos. Named by mission (see below).dat/movie/<lang>.pak+<lang>.p00— per-language subtitle timing tracks (eng,jpn,deu,fra,esp,ita) plus the caption font.dat/GP_MAIN_GAME_<L>.pak+.p00— per-language caption TEXT (E=Eng,J=Jpn,D=Deu,F=Fra,I=Ita,S=Esp).dat/tables.pakentryADVERTISE_MOVIE(hash0x5B983A08) — the master movie manifest: all 101.wmvnames in mission-progression order, each bound to its subtitle track and (optionally) its voice bank (see below).
Movie filename → mission
Purely from the filename:
| Pattern | Meaning |
|---|---|
S<NN><P>.wmv |
Stage NN story cutscene, part P (A/B/C…) — e.g. S02C = stage 2, 3rd story scene |
RT<NN><P>.wmv |
Stage NN radio / briefing transmission, part P (_1/_2 = split clips) |
hokyu_<LS|DS>_s<NN><P>.wmv |
Stage NN resupply scene (hokyu = 補給). LS/DS = the two resupply-ship variants |
ADV.wmv |
Intro / title movie |
97 movies total: 27 story, 50 radio, 19 resupply, 1 intro. (Some referenced stages — s24, s27, RT16 — exist as keys but the .wmv isn't in this extract.)
Subtitle timing track — <lang>.pak
IPFB archive (IPFB, BE-u32 count, 16-byte header; TOC of
[name_hash u32][offset u32][size u32] triples, sorted by hash, into .p00).
- Track key =
name_hash("subtitle_<movie_basename>.tbl")(the hash lowercases internally, so basename case is irrelevant). This is the movie→track link. Verified:subtitle_S00A.tbl→0x6F2D9663,subtitle_hokyu_DS_s02A.tbl→0x3662B1F8,subtitle_RT01C_1.tbl→0x756F69FB. - The archive also holds RATC pre-rendered title-card / number textures
(
pwterop_s01a1.t32,pwrt_rt01_str.t32,pwnum0-9.t32) + one TrueType font. - Each track data block is
Z1+zlib: bytes5A 31("Z1"), a small header, then a raw zlib stream (78 DA/78 9C).zlib.decompress(blob[blob.find(b"\x78\xda"):]). - Decompressed = an IXUD container. Payload (UTF-16LE) is the timing sheet:
SUBTITLE MSG_DEMO_<demo> <mm:ss.cc> MSG_DEMO_<demo> <mm:ss.cc> …. So the track says which demo-message shows when, not the text itself.
Caption text — GP_MAIN_GAME_<L>.pak
Same IPFB+.p00. Among its ~1119 entries, 32 blocks are Z1+zlib → IXUD
string containers holding the movie caption text. Layout: IXUD, u32 version,
hash@0x08, count@0x14, then (recordhash,offset,len) triples, then a UTF-16LE
string region where each line is stored as text immediately followed by its
key MSG_DEMO_<demo>_<page>_<line> (captions wrap across _00,_01, …).
537 English lines recovered. Entries 1 & 19 are the IDXD schema records
(ID, PageCount, Character=speaker e.g. TCAFSUPPLY, Face, line refs) —
no text, just structure.
Movie → voice track: the manifest binding (ADVERTISE_MOVIE)
The ADVERTISE_MOVIE manifest is also the authoritative movie→voice index.
Its string pool emits, per movie, a run led by <movie>.wmv optionally followed
by <pak>+….prt (overlay art), <pak>+SUBTITLE_<movie>.tbl, and a bare
VOICE_<token>. Grouping the pool on .wmv (records are emitted in order)
recovers movie → Option<voice_token> without decoding the IDXD record binary
(crate::movie_manifest).
The voice token is not always VOICE_<movie>, so the manifest is required —
guessing both misses real bindings and invents tracks for silent movies:
- 83 / 101 movies have a voice token. Story/radio movies use
VOICE_<movie>in<lang>\Movie\. - 5
hokyu_*resupply movies bind to in-mission radio clips — e.g.hokyu_LS_s02A → VOICE_D_450, which lives in<lang>\etc\, notMovie. AVOICE_<movie>guess would never find these. - ❌ WITHDRAWN — 18 movies have no direct voice token = 4 boot logos + 1 HD test pattern +
13
hokyu_*movies (incl.hokyu_DS_s13A). Only the manifest's direct bindings are trusted for playback.
Shared resupply voice — UNRESOLVED for unbound movies
The manifest directly binds only 5 resupply movies, each to a shared
VOICE_D_45x clip in <lang>\etc\:
| bound movie | subtitle demo | clip |
|---|---|---|
hokyu_LS_s02A |
600 | VOICE_D_450 |
hokyu_LS_s09A |
601 | VOICE_D_451 |
hokyu_DS_s02A |
602 | VOICE_D_452 |
hokyu_LS_s02H |
603 | VOICE_D_453 |
hokyu_DS_s07H |
604 | VOICE_D_454 |
The resupply cutscenes clearly share voice recordings (only the video varies
per mission), so the 13 unbound hokyu_* movies must reuse one of these — but
the correct join key is not yet known:
- Keying by subtitle demo id (so
hokyu_DS_s13A, demo 602 →VOICE_D_452) was tried and is WRONG — it plays the wrong recording in-game. Do not use. - Only
VOICE_D_450..454exist (no 44x/45x neighbours). Decoded durations are suspicious —450=2.8s,451=1.6s,452=2.2s, but453=0.14s,454=0.43s — far too short for the spoken line. ❌ The guess that follows was REFUTED 2026-08-25: these banks are not multi-subwave-and-missed. The RIFF-magic count equals the number of sub-waves recovered in all five banks, so nothing between or after them is lost. The audio is missing because a large region precedes the first RIFF and the decoder searches for that magic — 87 % of453and 85 % of454sit in front of it, high-entropy and not padding. See voice-bank-leading-region.
⇒ The unbound-hokyu voice mapping is open (needs either the real join key
from mission data, or a proper multi-subwave .slb decode + audio verification).
hokyu_LS_s24A/s27A have no subtitle track at all (stages absent from this
extract).
The token's sound.pak subdirectory is not fixed (Movie / etc / Voice),
so resolve it via sounds.tbl (which lists the full <lang>\…\<token>.slb path)
rather than assuming a directory. movie_manifest::resolve_voice_entry does the
full chain. Verified: all 83 resolved entries exist in sound.pak.
Caption packing quirks (parser must handle)
- Multi-line captions are split into consecutive text tokens that share one
trailing timing, e.g. S13A stores
"Look at it father"+"& beautiful isn't it"before01:14.80-01:17.60. Accumulate every text token since the last timing and join with\n; pairing strictly 1:1 silently drops all but the last line. - Overlapping spans: some tracks show two captions at once (an open-ended
radio line still up when the next range line starts). The viewer stacks every
cue active at
t(MovieSubtitles::active_cues) instead of showing only the first.
The full join
tables.pak / ADVERTISE_MOVIE → list of movies (mission order)
<movie> ─ nh("subtitle_<movie>.tbl") ─→ <lang>.pak track
track (IXUD) → [ (MSG_DEMO_<d>, timecode), … ]
MSG_DEMO_<d> → GP_MAIN_GAME_<L> → "the localized caption line(s)"
Coverage
- 92 / 97 movies have a subtitle track.
- 66 movies carry timed
MSG_DEMOcaptions — the radio (RT*) and resupply (hokyu_*) movies. These fully decode to timed text. - The 27 story (
S*) movies have a track but 0 timed captions — their text is delivered as the pre-rendered title-card textures (pwterop_*, burned styling), not MSG_DEMO lines.
Worked examples (English)
RT01C_1.wmv (Stage 1 radio, part C):
00:00.50 [14] We did it! Okay, all pilots follow my lead!
00:06.80 [15] Rhino Leader to ACROPOLIS. We made it through and we're coming
home. Roger. It's good to see you're all safe.
00:19.30 [17] Yeah, but Brandon ... Damn. There's only seven of us. …
hokyu_DS_s02A.wmv (Stage 2 resupply):
00:00.00 [602] Resupply complete. You are cleared for take-off!
Reusable extractor
tools/extract_movie_subtitles.py — parses <lang>.pak, resolves each movie's
track, cross-references GP_MAIN_GAME_<L> text, and prints per-movie timed
transcripts + the movie→mission table.
In-mission dialogue (future work)
GP_MAIN_GAME_<L>.pak is the global message store, not just movie captions:
its MSG_DEMO_* table also holds the in-mission radio/dialogue lines (same demo
id space). So the text of gameplay dialogue is already decodable with
[crate::movie_subtitle::build_demo_text]. What's missing is the trigger —
which demo id fires at which mission event — and that lives in the mission
data (mission scripts / GP_MAIN_GAME IDXD tables), not in the text pack. When
reversing mission data, look for demo-id references there to bind dialogue to
events; the IDXD "Message" schema records also carry Character (speaker) and
Face (portrait) per line.
✅ DEMO_* in a message's voice slot is NOT a sound bank
The dialogue message tables put a token in what reads as a voice slot, and 296
distinct ones are spelled DEMO_* rather than VOICE_*. They do not name audio:
| token family | distinct | resolve in sound.pak |
|---|---|---|
VOICE_* |
4443 | 4175 — 2360 under eng\Voice\, 1815 under eng\etc\ |
DEMO_* |
296 | 0 |
Zero resolve under any of the three directories (Movie, etc, Voice), and
prefixing the convention — looking up VOICE_DEMO_nnn — resolves 0 of 296
too. So the slot is not "the voice bank" but something that can hold either a
bank reference or a demo reference.
🟡 The tokens run DEMO_000 … DEMO_310, 287 distinct in [0, 310] — a dense
sequential id space, which is the shape of the demo id this file already uses in
MSG_DEMO_<demo>_<page>_<line> caption keys. I did not confirm the overlap:
those keys live in the IXUD wide-string blocks and the scan above only walked
IDXD objects, so it found 0 of them. Confirming it needs an IXUD-aware sweep.
❔ 268 of the 4443 VOICE_* tokens do not resolve either. That is a separate
gap and is not explained by the DEMO_* finding.
✅ 268 voice references on the disc name a bank that does not exist
Resolving every VOICE_* token the message tables use the way the engine does —
token → eng\sounds.tbl for its directory → sound.pak — gives 4175 resolved,
268 unresolved out of 4443 distinct tokens.
The unresolved ones are not listed in sounds.tbl at all. Nothing is listed
and then missing from the pak: of the 4175 that are listed, every one is
present. So the gap is in the manifest, not the archive.
By family, comparing the ids the scripts use against the ids sounds.tbl lists:
| family | ids used | listed | missing |
|---|---|---|---|
VOICE_A_ |
696 [0..772] |
683 | 14 |
VOICE_B_ |
391 [0..422] |
383 | 8 |
VOICE_C_ |
429 [0..489] |
366 | 63 |
VOICE_D_ |
508 [0..598] |
387 | 126 |
VOICE_E_ |
44 [0..43] |
0 | 44 — the whole family |
Two different shapes:
A/B/C/Dlose short runs scattered through an otherwise dense range —11,12,13,56,57,181,182,183,389…392. That is what cut dialogue looks like: lines written, referenced, and never recorded.VOICE_E_is absent entirely — 44 ids used, none listed, noVOICE_E_*anywhere in the manifest. A whole speaker or category, cut.
For the port: a missing voice bank is a retail condition, not a decode bug.
A reimplementation must tolerate an unresolvable VOICE_* reference and carry on,
the same way it must tolerate SUBTITLE_S12B.tbl resolving in no language.
❌ CORRECTED — most of those 268 are not cut, they are undubbed
The paragraph above reads the scattered runs as "cut dialogue". Checking the other languages refutes that for most of them.
Only two voice languages exist. tables.pak holds eng\sounds.tbl (4418
banks) and jpn\sounds.tbl (5136) and no fra / deu / esp / ita — the
other four localisations are subtitle-only.
And the English set is a strict subset of the Japanese one:
| eng | jpn | |
|---|---|---|
| banks listed | 4418 | 5136 |
| tokens resolved | 4175 | 4383 |
| tokens missing | 268 | 60 |
| banks the other language lacks | 0 | 718 |
Every id missing in Japanese is also missing in English; none is missing in Japanese but present in English. So the 268 split cleanly:
- 208 exist in Japanese and not in English — recorded in the original, never dubbed. A localisation gap, not cut content.
- 60 are absent from both — genuinely cut. That includes all 44
VOICE_E_, which is listed 0 times in either language, plus 16 others.
Per family, English against Japanese: VOICE_A_ 683/771, VOICE_B_ 383/423,
VOICE_C_ 366/474, VOICE_D_ 387/546. The 718 Japanese-only banks also include
whole families English never had — VOICE_ADAN_ (110), VOICE_ZZZZ_ (104),
VOICE_TCAF_ (41), VOICE_RHIN_ (27).
So VOICE_E_ remains the one genuinely-cut family, and the "short runs of cut
dialogue" reading was wrong: those lines are on the disc, in Japanese.
✅ What was genuinely cut — and it still has subtitles
The 60 tokens absent from both voice languages break down as:
- 45 of the
VOICE_E_family — 44 numeric ids[0..43]plusVOICE_E_012B, a lettered variant. Listed zero times in either language. - 13
VOICE_C_, clustered at 421, 423–426, 430, 432, 447–450, 470, 471 — note these sit inside the listed range[0..489], so they are interior gaps, not a truncated tail. - 2
VOICE_D_, the adjacent pair 182, 183.
These lines were written and captioned; only the audio is missing. Looking their caption keys up in the IXUD blocks returns real text:
| key | text |
|---|---|
MSG_VOICE_C_355_000_00 |
"What are you doing? Quit wasting…" |
MSG_VOICE_C_367_000_00 |
"The final defense weapon is…" |
MSG_VOICE_C_347_000_00 |
ジェネレータールームに侵入されまし |
MSG_VOICE_D_152_000_00 |
ジェネレーター、完全に沈黙! |
MSG_VOICE_E_044_000_00 |
ファイアリングタクティクス・レディ |
Three of the five sampled are still Japanese inside the English pak — captioned but never translated, which matches the untranslated entries already noted in ixud-localised-text.
⚠️ The caption id and the voice-bank id are DIFFERENT numbers
A trap for anyone pairing them. Within one message page, the bank token and the caption keys carry unrelated numbering:
Message_106 page 0: voice = VOICE_C_468
lines = MSG_VOICE_C_385_000_00 / _01 / _02
Message_129 page 0: voice = VOICE_D_182
lines = MSG_VOICE_D_152_000_00 / _01 / _02
Message_044: voice = VOICE_E_012B
ID = MSG_VOICE_E_044
VOICE_C_468 ↔ MSG_VOICE_C_385, VOICE_D_182 ↔ MSG_VOICE_D_152,
VOICE_E_012B ↔ MSG_VOICE_E_044. Same family letter, different index
space. Deriving one from the other will silently mis-pair audio and text.
✅ The crate now reads ALL of the game's caption text (was 6 %)
movie_subtitle handles the MSG_DEMO_* family — the cutscene captions. That is
the smallest of eight caption families in the English pak, and the rest have
no reader.
Counted over every IXUD block in GP_MAIN_GAME_E.pak:
| family | keys | with text | what it is |
|---|---|---|---|
MSG_ADAN |
23 236 | 9 801 | ADAN combat chatter |
MSG_RHIN |
21 196 | 8 509 | Rhino squadron chatter |
MSG_TCAF |
17 148 | 6 728 | TCAF chatter |
MSG_VOICE |
13 060 | 6 776 | in-mission scripted dialogue |
MSG_BIRD |
14 036 | 5 834 | Bird squadron chatter |
MSG_ADPL |
12 640 | 4 127 | ADAN pilots |
MSG_ACRO |
4 804 | 2 244 | Acropolis |
MSG_DEMO |
1 252 | 560 | cutscene captions — the only one read |
| total | 107 372 | 44 579 |
❌ CORRECTED — that column counts OCCURRENCES, not lines. Each family lives in 24–45 IXUD blocks and the same key repeats across them. The distinct figures:
44 579 text-bearing MSG_* field occurrences
8 800 DISTINCT keys <- the honest denominator
8 800 of those have the <id>_<page>_<line> shape (100 %)
So the real coverage was 537 of 8800 = 6.1 %, not 1.3 %. My earlier number used occurrences and overstated the gap about fivefold. The direction was right; the magnitude was not.
MSG_VOICE_* is the family the message tables reference — the dialogue whose
voice bindings are analysed above — and nothing in crates/ parses it.
✅ Closed — 8800 of 8800, by reading fields instead of adjacent tokens
movie_subtitle::build_caption_text now reads all eight families. Key shapes are
uniform and each family is 100 % consistent with its own:
ACROADANADPLBIRDDEMORHINTCAF—MSG_<FAM>_<id>_<page>_<line>VOICEalone —MSG_VOICE_<letter>_<id>_<page>_<line>
Recovered (tests/caption_families_disc.rs, examples/caption_coverage.rs):
| ids | lines | |
|---|---|---|
build_demo_text — token adjacency |
134 | 537 |
build_caption_text — token adjacency |
3721 | 8074 |
build_caption_text — record fields |
4085 | 8800 = all of them |
Two steps, and the second is the one that mattered. Generalising the key parser took 537 → 8074; switching from token adjacency to the record/field table took 8074 → 8800, which is 8800 of 8800 distinct keys.
ixud.rs now has an IdxdObject-shaped reader — [IxudObject] — verified over
the whole disc by tests/ixud_records_disc.rs: 1104/1104 objects parse,
1476/1476 records and 628 165/628 165 named fields reproduce their
ixud_hash, 48 positional. The decode had been verified with a standalone
parser hours earlier and was simply never wired in.
🔑 The same lesson, twice in one session. Pool adjacency is a consequence of
how records are written, not a rule of the format — true for IDXD, true here. The
DEMO control shows it plainly: the token reader finds 537 lines, the field
reader 541. Token adjacency was quietly dropping lines even in the one family
it was written for.
▶️ Superseded first step: movie_subtitle::build_demo_text already pairs a text value
with the MSG_DEMO_<demo>_<page>_<line> key that follows it; the other seven
families use the same <id>_<page>_<line> shape, so generalising the key parser
is most of the work. ⚠️ Do not assume the id spaces relate — the voice-bank
id and the caption id in one message page are different numbers (above).