The brief's rule is to commit reference data beside the finding so the port can be built without a disc. Nothing had ever checked that the docs' cited artifacts actually exist. doc_link_check.py walks every markdown file under docs/, resolves each relative link, and reports targets that are missing -- and separately targets that resolve to a ZERO-BYTE file, which looks fine in any listing. links resolving 1038 -> 1049 missing targets 16 -> 5 empty targets 0 -> 0 +11 resolving and -11 missing against 11 edits: the counts pair, which is the confirmation the pass did what it claimed and touched nothing else. Two of the sixteen were the evidence for the UI layout decode itself. structures/ui-rat-layout.md is what the port is built on, and its two figures -- backing "the tutorial PAUSE menu rebuilds pixel-accurately from its sprites" and "the same method reproduces the main menu" -- were written as captures/ui-layout/... from a file in structures/, one directory too shallow. The headline evidence for the decode could not be opened from its own document. Eleven links had the wrong relative depth with the target present. Each was rewritten only where exactly one candidate path resolved, so nothing was guessed; the first pass left three alone because equivalent spellings (captures/../captures/x) failed to collapse, and a second pass normalised them. Five remain genuinely absent and are left rather than invented: two point at MEMORY.md outside the repo, one at a header in the separate xenia-canary-native tree, and two name documents that were never written (weapon-datasheet-runtime.md, canary-build-verified-env-confound.md). None is port-relevant. A missing document is a different problem from a bad path and is not something a link fix should paper over.
214 lines
9.9 KiB
Markdown
214 lines
9.9 KiB
Markdown
# ISL `request_script_message` → caption text
|
||
|
||
**✅ Settled 2026-08-26.** Every mission-script message call resolves to the
|
||
dialogue line it plays. The 28 stage scripts are readable as dialogue.
|
||
|
||
## The link
|
||
|
||
Built-in **64** (`request_script_message`) takes one operand in slot 0: a
|
||
two-word symbol reference into **symbol table 1**. The symbol's name is a
|
||
message id such as `MSG_VOICE_D_257`. The text lives in the language pack's
|
||
IXUD blocks (`dat/GP_MAIN_GAME_<lang>.pak`) as a **named field** whose key is
|
||
`<id>_<page>_<line>`, read via the record/field table documented in
|
||
[idxd-container.md](idxd-container.md).
|
||
|
||
MSG_VOICE_D_257 → field key `MSG_VOICE_D_257_000_00` → "Katana! Damn it!"
|
||
|
||
## Evidence
|
||
|
||
Measured over all 28 `StageNN.ssb`:
|
||
|
||
| | |
|
||
|---|---|
|
||
| built-in 64 call sites | **2683** |
|
||
| slot 0 not staged | 0 |
|
||
| operand not in symbol table 1 | 0 |
|
||
| operand symbol type | **6** at all 2683 sites |
|
||
| distinct message names | 1338 |
|
||
| names with caption text at `_000_00` | **2683 / 2683 (100 %)** |
|
||
|
||
There is no residue: no call site takes a different operand shape, no name is
|
||
of a type other than 6, and no name is missing from the caption table. That
|
||
totality is the result — a partial rate would have meant the operand encoding
|
||
was only sometimes what I thought it was.
|
||
|
||
Name families and their share of call sites:
|
||
|
||
MSG_VOICE 2407 MSG_ADAN 118 MSG_ACRO 82
|
||
MSG_ADPL 38 MSG_TCAF 35 MSG_RHIN 3
|
||
|
||
`MSG_VOICE_*` is crew/wingman dialogue; the four-letter families are
|
||
ship-side announcements (`ACRO` = ACROPOLIS, `ADAN`/`ADPL` = ADAN,
|
||
`TCAF`, `RHIN` = Rhino flight).
|
||
|
||
## Page and line structure — and a correction to this page's first tool
|
||
|
||
**✅ Measured 2026-08-26**, on the English pack. A caption id is keyed
|
||
`<id>_<page>_<line>`:
|
||
|
||
* a **page** is one subtitle box with **four line slots** (4 506 pages allocate
|
||
4, 263 allocate 3 — no other width occurs). ⚠️ That counts *slots*, not text;
|
||
an English page carries **1 to 4** actual lines — see the correction below;
|
||
* successive **pages are successive utterances**, and may be different
|
||
speakers. A multi-page id is therefore a whole exchange, not one long line:
|
||
|
||
MSG_DEMO_009 page 0 No way! There's more of them?
|
||
page 1 Rhino Leader to ACROPOLIS! There's a whole fleet of them now!
|
||
page 2 What? Is there any way you can get out of there?
|
||
page 3 If we get rid of those fighters, we can cut our way out of here.
|
||
|
||
Of 4 091 distinct ids, **452 span more than one page** (374 `MSG_VOICE`,
|
||
78 `MSG_DEMO`), up to 8 pages.
|
||
|
||
**This refuted the first version of `isl_dialogue.py`**, committed earlier the
|
||
same day, which read only page `000`. **356 of the 1338 script message names
|
||
are multi-page**, so a quarter of the dialogue it printed was silently
|
||
truncated to its opening utterance. The tool now walks pages until one is
|
||
empty. The 2683/2683 resolution figure above is unaffected — it counted ids
|
||
that *have* text, and every id does have a page `000` — but the *rendered*
|
||
text was incomplete, and the caveat this page originally carried ("whether any
|
||
id uses a page other than `000` is unmeasured") understated it as a question
|
||
when it was already a defect.
|
||
|
||
## Why this was not obvious earlier
|
||
|
||
The caption table only became readable on 2026-08-25, when `build_caption_text`
|
||
was switched from scraping adjacent string-pool tokens to reading the IXUD
|
||
**field** table — which took the extractable line count from 537 to 8800
|
||
(8800 of 8800 distinct keys). Before that the great majority of these 1338
|
||
names had no text to resolve *to*, and the 100 % figure was unreachable.
|
||
|
||
## What this does not settle
|
||
|
||
* **Which recording plays.** The caption is the *text*; the voice bank binding
|
||
is a separate and still-unresolved question — see
|
||
[voice-bank-leading-region.md](../voice-bank-leading-region.md) and the
|
||
known case of a generic line playing against a specific subtitle.
|
||
* **Which page a `MSG_DEMO_*` id belongs to.** The `DEMO` family is not called
|
||
from the stage scripts at all — 78 of its ids are multi-page, so something
|
||
else drives cutscene dialogue. That driver is unidentified.
|
||
* ~~**The other five languages.** Only the English pack was swept.~~ ✅ Swept
|
||
2026-08-26 — see below.
|
||
* **Ordering.** The tool prints call sites in file order, which is *script*
|
||
order, not necessarily *play* order — phases and branches are not resolved.
|
||
|
||
## Tool
|
||
|
||
tools/re-capture/isl_dialogue.py <StageNN.ssb> [GP_MAIN_GAME_E.pak]
|
||
|
||
Sample output committed at [../data/isl-stage02-dialogue.txt](../data/isl-stage02-dialogue.txt)
|
||
(Stage 02, 213 message calls, 213 with text).
|
||
|
||
|
||
## ✅ All six language packs swept (2026-08-26)
|
||
|
||
| pack | distinct `MSG_*` keys | with non-empty text | ids | pages |
|
||
|---|---|---|---|---|
|
||
| **E** English | 18 813 | **8 800** | 4 091 | 4 758 |
|
||
| **D** German | 18 813 | — | 4 091 | — |
|
||
| **F** French | 18 813 | — | 4 091 | — |
|
||
| **I** Italian | 18 813 | — | 4 091 | — |
|
||
| **S** Spanish | 18 813 | — | 4 091 | — |
|
||
| **J** Japanese | 14 295 | 7 266 | 4 087 | 4 765 |
|
||
|
||
The five European packs have **byte-for-byte identical key sets** — zero keys
|
||
missing, zero extra, in any direction. Only Japanese differs, and the difference
|
||
is not missing content:
|
||
|
||
* its **id** set matches to within 5 missing and 1 extra of 4 091;
|
||
* its **page** count is 4 765 against English's 4 758 — the same;
|
||
* its **lines per page** are fewer: mean **1.52** vs **1.85**, and it never uses
|
||
a 4-line page where English has 113.
|
||
|
||
So Japanese carries the same dialogue and simply **wraps into fewer lines**,
|
||
which is what a language without inter-word spaces does in a fixed-width box.
|
||
The 4 521 "missing" keys are line slots that were never needed. ❌ A reading of
|
||
"Japanese is missing 4 521 captions" would be wrong.
|
||
|
||
### ⚠️ Correcting my own wording elsewhere
|
||
|
||
**This page**, further up, said a page holds "3 or 4 wrapped lines (4 506 of 4,
|
||
263 of 3 — no other width occurs)" — I first misattributed that sentence to
|
||
[cutscene-message-table.md](cutscene-message-table.md) while correcting it,
|
||
which is its own small carelessness. It measured **key slots**, which the
|
||
records allocate four at a time. Counting lines that actually carry text, an
|
||
English page holds **1 to 4**:
|
||
|
||
1 line: 1 574 2 lines: 2 439 3 lines: 632 4 lines: 113
|
||
|
||
Both numbers are true of different things, but "holds 3 or 4 wrapped lines"
|
||
reads as a statement about the text and is wrong as such. A page has up to four
|
||
slots and usually fills one or two.
|
||
|
||
This also reconciles the **8 800** figure quoted throughout the corpus: it is
|
||
distinct English keys *with non-empty text*, out of 18 813 keys in total. The
|
||
larger number is not a contradiction — most slots are empty.
|
||
|
||
## ✅ Dialogue with audio durations, all 28 stages
|
||
|
||
**2026-08-26.** [`../data/stage-dialogue-with-durations.txt`](../data/stage-dialogue-with-durations.txt)
|
||
joins three separately decoded systems — the ISL script's built-in 64 call
|
||
sites, the caption table, and the sound-bank `seek` tables:
|
||
|
||
offset | message id | eng s | jpn s | caption
|
||
|
||
Duration is the bank's last cumulative sample over its sample rate. **Nothing is
|
||
decoded**; every number is read.
|
||
|
||
Coverage over the 2 683 call sites: **1 663 (62.0 %) have an English duration**
|
||
and **2 198 (81.9 %) a Japanese one**. The gap is the headerless banks, which
|
||
carry no `seek` table — and which language lacks one differs per line, so the
|
||
two columns are complementary rather than nested.
|
||
|
||
**Two columns, deliberately.** `FILES` names the `jpn\` banks and the game
|
||
substitutes the language directory, so a line's English and Japanese recordings
|
||
are different files of different lengths. The caption text is English, so `eng`
|
||
is the column that matches it. Reporting a single duration here would have
|
||
silently attached Japanese timings to English text — the first version of this
|
||
artifact did exactly that, which is why it was regenerated.
|
||
|
||
The difference is not academic:
|
||
|
||
MSG_VOICE_A_065 "They got Leader!" eng 1.78 s jpn 2.46 s
|
||
MSG_VOICE_D_257 "Katana! Damn it!" eng - jpn 2.59 s
|
||
MSG_VOICE_D_342 "Katana, Ellen, good work." eng 4.18 s jpn -
|
||
|
||
❔ Not settled: the 38 % of call sites with no English duration. They are
|
||
headerless banks. **A `seek`-based recovery was tried and does not work** — see
|
||
below.
|
||
|
||
### ❌ Recovering headerless durations from the `seek` chunk — tried, refused
|
||
|
||
A headerless bank has no `RIFF`, so no `data` size to read. But a `seek` chunk
|
||
describes the wave *ending* at its own position, so an entry carrying one looks
|
||
like it should yield a length for free. Across the bank set:
|
||
|
||
| | eng | jpn |
|
||
|---|---|---|
|
||
| has a `RIFF` (already covered) | 3 686 | 4 301 |
|
||
| headerless **with** a valid `seek` | **343** | 419 |
|
||
| headerless, no `seek` | 353 | 380 |
|
||
| no entry for that language | 753 | 35 |
|
||
|
||
343 recoverable English durations would lift coverage from 62 % to about 75 %,
|
||
and the numbers look right — median **3.25 s**, p90 5.18 s, which is exactly the
|
||
shape of in-mission voice.
|
||
|
||
**It is still wrong.** A `seek` sits immediately after *its own* data, so the
|
||
implied wave start is `seek_pos − packets × 2048`. Checking that:
|
||
|
||
wave starts BEFORE the entry begins : 277 of 343
|
||
wave starts inside the entry : 66 of 343
|
||
|
||
**For 277 of the 343, the `seek` describes the previous bank's wave, not this
|
||
entry's.** That is the same pairing rule that made my earlier `seek` readings
|
||
fail; taking the length anyway would attach a neighbour's duration to 81 % of the
|
||
recovered rows. Even the 66 that start inside the entry are not *proven* to be
|
||
that entry's own wave — "starts inside" is necessary, not sufficient.
|
||
|
||
The median 3.25 s is precisely the kind of plausible-looking figure that has
|
||
already misled this work twice today, so the approach is recorded as **refused**
|
||
rather than applied with a caveat. Recovering these lengths needs the bank→wave
|
||
assignment settled first — the open question `auto/slb-loader` records as *"which
|
||
bank in a window belongs to the entry's name"*.
|