This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/docs/re/structures/movie-subtitles.md
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

19 KiB
Raw Blame History

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.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<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.tbl0x6F2D9663, subtitle_hokyu_DS_s02A.tbl0x3662B1F8, subtitle_RT01C_1.tbl0x756F69FB.
  • 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_<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\, not Movie. A VOICE_<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..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.

⇒ 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" 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)
<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_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 <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_000DEMO_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/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, 423426, 430, 432, 447450, 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_468MSG_VOICE_C_385, VOICE_D_182MSG_VOICE_D_152, VOICE_E_012BMSG_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 2445 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:

  • ACRO ADAN ADPL BIRD DEMO RHIN TCAFMSG_<FAM>_<id>_<page>_<line>
  • VOICE alone — 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_textrecord 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).