movie_manifest::parse now reads BASE_INFO's positional field keys (the game's own cutscene ids, stage*100 + slot) and follows each to its record, instead of scraping the string pool. The pool stores each distinct string once, so a REPEAT reference produced no token and read as "no binding". That single cause explains every wrong cell: 13 later references to VOICE_D_450..454, two to SUBTITLE_hokyu_LS_s11A.tbl, and MS01A's share of pwterop_s01a.prt. All 18 hokyu movies are bound, not five. Counts, verified independently by me against the disc before recording: 104 cutscene SLOTS binding 101 distinct MOVIES; 99 slots / 96 movies with a voice track, 99 / 96 with a subtitle, 22 / 22 with a telop. The docs' old 94 / 83 / 21 are exactly the counts of DISTINCT POOL STRINGS -- not wrong measurements, measurements of the wrong thing. Three denominators were being conflated; the new test pins all three. Two assertions in movie_manifest_disc.rs were false and are corrected: hokyu_DS_s13A binds VOICE_D_452 and resolves to eng\etc\VOICE_D_452.slb. The in-game verdict that rejected that value tested an INFERENCE from a shared demo id, on a decoder that discards 85-87% of banks in this class -- see voice-bank-leading-region.md, committed earlier today. The ~104 script ids are no longer open: they are literal positional keys, each naming its record, and all 104 resolve. The old "counts differ by three, positional pairing does not work" has a concrete cause -- three resupply movies are bound by TWO slots each. Also corrected: the naming convention has 3 subtitle exceptions (s24A/s27A borrow s11A's track) and 18 voice exceptions, not one and five. The legacy scraper is kept as a fallback for blobs with no record table, so the synthetic unit fixtures still exercise it. Artifacts: examples/movie_map_csv.rs regenerates the CSV, now slot-keyed (104 rows; the movie-keyed version silently dropped one slot of each duplicate). Disc tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
8.9 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.