# 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/.pak` + `.p00` — per-language **subtitle timing tracks** (`eng`, `jpn`, `deu`, `fra`, `esp`, `ita`) plus the caption font. - `dat/GP_MAIN_GAME_.pak` + `.p00` — per-language **caption TEXT** (`E`=Eng, `J`=Jpn, `D`=Deu, `F`=Fra, `I`=Ita, `S`=Esp). - `dat/tables.pak` entry `ADVERTISE_MOVIE` (hash `0x5B983A08`) — the master **movie manifest**: all 101 `.wmv` names 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

.wmv` | Stage `NN` **story** cutscene, part `P` (A/B/C…) — e.g. `S02C` = stage 2, 3rd story scene | | `RT

.wmv` | Stage `NN` **radio / briefing** transmission, part `P` (`_1`/`_2` = split clips) | | `hokyu__s

.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 — `.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_.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**: bytes `5A 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_ MSG_DEMO_ …`. So the track says *which* demo-message shows *when*, not the text itself. ## Caption text — `GP_MAIN_GAME_.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___` (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 `.wmv` optionally followed by `+….prt` (overlay art), `+SUBTITLE_.tbl`, and a bare `VOICE_`. Grouping the pool on `.wmv` (records are emitted in order) recovers `movie → Option` without decoding the IDXD record binary (`crate::movie_manifest`). The voice token is **not** always `VOICE_`, 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_` in `\Movie\`. - **5 `hokyu_*` resupply movies bind to in-mission radio clips** — e.g. `hokyu_LS_s02A → VOICE_D_450`, which lives in `\etc\`, *not* `Movie`. A `VOICE_` 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 `\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..454` exist (no 44x/45x neighbours). Decoded durations are suspicious — `450`=2.8s, `451`=1.6s, `452`=2.2s, but `453`=**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 % of `453` and 85 % of `454` sit in front of it, high-entropy and not padding. See [voice-bank-leading-region](../voice-bank-leading-region.md). ⇒ 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 `\…\.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"` before `01: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) ─ nh("subtitle_.tbl") ─→ .pak track track (IXUD) → [ (MSG_DEMO_, timecode), … ] MSG_DEMO_ → GP_MAIN_GAME_ → "the localized caption line(s)" ``` ## Coverage - 92 / 97 movies have a subtitle track. - **66 movies carry timed `MSG_DEMO` captions** — 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 `.pak`, resolves each movie's track, cross-references `GP_MAIN_GAME_` text, and prints per-movie timed transcripts + the movie→mission table. ## In-mission dialogue (future work) `GP_MAIN_GAME_.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___` 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`/`D` lose **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, no `VOICE_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]` plus `VOICE_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](ixud-localised-text.md). ### ⚠️ 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 __ 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: * `ACRO` `ADAN` `ADPL` `BIRD` `DEMO` `RHIN` `TCAF` — `MSG____` * `VOICE` alone — `MSG_VOICE____` 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___` key that follows it; the other seven families use the same `__` 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).