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
387 lines
19 KiB
Markdown
387 lines
19 KiB
Markdown
# 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, 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 <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).
|