Files
Sylpheed/docs/re/structures/movie-subtitles.md
Sylpheed RE agent ff83918572 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

387 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.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_<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](../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 `<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_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`/`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](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
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` `TCAF``MSG_<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_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).