confirmed three ways Chasing where the event code comes from, sub_821C5580 turned out to do two things worth having. For Q6 it arms the outer gate: li r11,3 ; stw r11,16(r28) is exactly the value sub_821C7850 tests before dispatching, which answers the "what does this+16 == 3 gate on" question I left open last iteration. It then passes r5 = [r27+4], an event read out of a structure, so the vocabulary is still not enumerable. Of the four callers of sub_821C7850, three pass constants -- 0, 0 and 5 -- and this one does not. The accident is a few instructions earlier: li r5, 1103 into a sound-play call. 1103 is a BGM cue id, BGM_103. That closes a residual I had written off as undecodable: which bank the menu plays. The cue table genuinely cannot say, since its BGM entries are numeric -- but the code can, and it checks out three independent ways. The census says BGM_103.slb is two waves of 3876864 and 3930112 bytes; the XMA probe at the main menu saw two stereo streams of 3876864 and 3930112 bytes. Byte for byte on both. That also corrects me. bgm-two-stems.md said those observed sizes matched no bank's declared waves and inferred the decoder gets a window rather than a whole wave. Wrong on both counts -- I had checked only the BGM_0xx rows of the census because that was the block on screen. METHOD gets it: check a measurement against the whole reference set, not the part you happened to be reading.
264 lines
13 KiB
Markdown
264 lines
13 KiB
Markdown
# Menu audio — the event vocabulary is on the disc; the binding is not
|
||
|
||
**Status:** ✅ `CONFIRMED` and **decoded** for the cue vocabulary and the bank
|
||
that holds it. 🟡 the cue↔event binding is a **name match**, not a measurement.
|
||
❔ two negatives with their reach: no cue names a screen's music, and the audio
|
||
for an individual SE cue cannot yet be extracted.
|
||
|
||
Answers most of [MISSION Q8](../port/MISSION.md).
|
||
|
||
## ✅ The UI cue vocabulary — decoded
|
||
|
||
`dat/tables.pak`'s sound table has a `SOUNDS` record of 5 798 cues. 322 are `SE_*`
|
||
and the **low block is the user-interface vocabulary**, named by the game's
|
||
authors after the *events*, not after the sounds:
|
||
|
||
| id | cue | id | cue |
|
||
|---|---|---|---|
|
||
| 1 | `SE_UI_START` | 9 | `SE_UI_SUB_WIN_CLS` |
|
||
| **2** | **`SE_UI_CURSOR`** | 10 | `SE_UI_ALART_WIN` |
|
||
| **3** | **`SE_UI_DECIDE`** | 11 | `SE_UI_PAUSE` |
|
||
| **4** | **`SE_UI_CANSEL`** *(sic)* | 12 | `SE_UI_SPLASH_IN` |
|
||
| **5** | **`SE_UI_IMPOSI`** | 13 | `SE_UI_SPLASH_OUT` |
|
||
| 6 | `SE_UI_WAIT` | 14 | `SE_UI_LOAD_CMP` |
|
||
| 7 | `SE_UI_NEXT` | 17/18 | `SE_UI_WEAPON_PLAN` / `_CREATE` |
|
||
| 8 | `SE_UI_SUB_WIN_OPN` | 81–83 | `SE_UI_MISSION_START` / `_UPDATE` / `_END` |
|
||
|
||
Full 322-cue list in [`data/se-ui-cues.txt`](data/se-ui-cues.txt). The remaining
|
||
blocks are gameplay: `SE_HUD_*` (21–83), `SE_BR_*` briefing (50–72), `SE_SW_*` /
|
||
`SE_MW_*` weapons, `SE_DS_*` engine, `SE_EXP_*` explosions, `SE_SHIELD_*`.
|
||
|
||
## ✅ They all live in one bank — decoded
|
||
|
||
The sound table's `BANK_SE` record is a **single field**: `Static.slb`. And the
|
||
disc-wide check agrees — **0 of the 322 `SE_*` cues has an entry in `FILES`**, the
|
||
5 135-path list where every voice, briefing and BGM bank is named. SE is the one
|
||
family that is not one-cue-one-file.
|
||
|
||
`SETTINGS` names the XACT project too: `PATH` = `game:\dat\sound.pak+`,
|
||
`PARAM` = `Pj_Silph.xgs`.
|
||
|
||
## 🟡 The binding: a name match, and I am calling it that
|
||
|
||
MISSION asks which cue fires on **move / confirm / back / error**. Read off the
|
||
names:
|
||
|
||
| event | cue |
|
||
|---|---|
|
||
| cursor moves | `SE_UI_CURSOR` (2) |
|
||
| Ⓐ confirm | `SE_UI_DECIDE` (3) |
|
||
| Ⓑ back | `SE_UI_CANSEL` (4) |
|
||
| invalid / error | `SE_UI_IMPOSI` (5) |
|
||
| submenu opens / closes | `SE_UI_SUB_WIN_OPN` / `_CLS` (8/9) |
|
||
| the splash | `SE_UI_SPLASH_IN` / `_OUT` (12/13) |
|
||
|
||
**Nobody has watched the game emit cue 2 on a d-pad press.** This is inference
|
||
from identifiers, and it belongs in the same box as Q4's GamePart ids — with one
|
||
honest difference worth stating: these are the *authors' own event names*, chosen
|
||
to describe when the sound plays, not asset labels I am interpreting. It is a
|
||
strong name match. It is still a name match, and the port is authoring it.
|
||
|
||
Measuring it needs the guest's cue-play call observed with its argument — the
|
||
same instrumentation Q4's residual wants, and not something this container can do
|
||
today (it runs `--mute=true` against an SDL dummy device, so there is no audio
|
||
path to watch either).
|
||
|
||
## ✅ Which BGM per screen — the TABLE cannot say, but the CODE can
|
||
|
||
All 32 BGM cues are named `BGM_001`…`BGM_109`, and nothing in `SOUNDS`, `FILES` or
|
||
the bank headers names a screen. That negative stands **for the tables**.
|
||
|
||
It does not stand for the executable. `GamePart_Title`'s phase handler
|
||
`sub_821C5580` contains `li r5, 1103` feeding a sound call — **cue 1103 =
|
||
`BGM_103`** — and `BGM_103.slb`'s two declared waves (3 876 864 / 3 930 112 bytes)
|
||
are byte-for-byte the two streams the XMA probe saw decoding at the main menu.
|
||
Three routes, one answer; see
|
||
[`structures/bgm-two-stems.md`](structures/bgm-two-stems.md).
|
||
|
||
So the per-screen BGM binding is recoverable **per screen, from the code that
|
||
plays it** — not from any table.
|
||
|
||
## ❔ And a new negative: an individual SE's audio is not extractable yet
|
||
|
||
`Static.slb` is **8 353 472 readable bytes** (declared 8 970 240 — exactly the
|
||
616 768 over-declaration the corpus already records), and it contains
|
||
**0 `RIFF`, 0 `seek`, 0 `WAVE`** — scanned over the whole buffer, so this is not
|
||
an offset problem. Every other bank on the disc is delimited by `seek` magic at
|
||
`data_at + declared_size` (7 620/7 620 in
|
||
[`slb-data-offset.md`](structures/slb-data-offset.md)); `Static.slb` is outside
|
||
that population. So the route that extracts every voice line and every BGM does
|
||
**not** give you `SE_UI_CURSOR`'s audio: the cue is named, the bank is named, and
|
||
the wave inside the bank is not locatable by any boundary marker.
|
||
|
||
**The named next step is `Pj_Silph.xgs`** — the XACT project file `SETTINGS`
|
||
points at, which in XACT is exactly where cue→wave-index lives. It **is** in
|
||
`sound.pak`: `name_hash("Pj_Silph.xgs")` resolves to TOC index 9454. ⚠️ Its entry
|
||
is 533 bytes at a 2048-aligned offset and carries no `XGSF` magic — and its region
|
||
has phase 1728, so those 533 bytes are very likely the *previous* bank's tail
|
||
rather than its own (the leading-region effect, [`slb-data-offset.md`]
|
||
(structures/slb-data-offset.md)). Locating its real bytes is the first job, not
|
||
parsing XACT.
|
||
|
||
## For the port
|
||
|
||
* the cue **names and ids** are disc facts — use them as the event vocabulary;
|
||
* which event fires which cue is **authored from the table above**;
|
||
* which BGM plays on which screen is **authored** — nothing on the disc says;
|
||
* and the UI **sound effects cannot be exported yet**. That is a gap in the
|
||
assets, not in the naming.
|
||
|
||
## ❔ `Pj_Silph.xgs` is a dead end — retracting the lead, with the reach
|
||
|
||
The section above named `Pj_Silph.xgs` as "the named next step", on the reasoning
|
||
that in XACT the cue→wave index lives in the project file. **That route is dead.**
|
||
|
||
| check | result |
|
||
|---|---|
|
||
| the entry's own bytes | 533 bytes, high entropy, **no `XGSF` magic** |
|
||
| ±8 KB around the entry in the flat concatenation | no `XGSF`, `SDBK`, `WBND`, `RIFF` or `seek` |
|
||
| **all of `sound.pak` — 1.08 GB across `.p00`–`.p04`** | **0 × `XGSF`, 0 × `SDBK`, 0 × `WBND`** |
|
||
| the executable | **0** occurrences of `XGSF`/`XACT`; no string matching `xact`, `.xgs`, `wavebank` or `soundbank` |
|
||
|
||
**Control, run first:** the same magic scan over `BGM_001.slb`'s neighbourhood
|
||
finds `RIFF` exactly where the bank structure says it should be. The scan works;
|
||
the magic genuinely is not there.
|
||
|
||
So the `.xgs` / `.slb` names are **inherited from the authoring tool, not from the
|
||
shipped format** — nothing on this disc is a parseable XACT container. There is no
|
||
XACT project file to read, and writing an XACT parser would have been wasted work.
|
||
|
||
### Where that leaves the SE audio: undecodable, with reach
|
||
|
||
Locating an individual SE cue's wave inside `Static.slb` is **not possible from
|
||
anything found on the disc**. Looked in:
|
||
|
||
* `Static.slb` itself — 8 353 472 readable bytes, **0 `RIFF` / 0 `seek` / 0 `WAVE`**,
|
||
where every one of the other 7 620 banks is delimited by `seek` magic at
|
||
`data_at + declared_size` ([`structures/slb-data-offset.md`](structures/slb-data-offset.md));
|
||
* the XACT project file — absent, as above;
|
||
* the sound table's five records — `SOUNDS` gives cue→id, `FILES` gives bank
|
||
paths, `BANK_SE` gives the one bank name, `SETTINGS` gives a path and the
|
||
(absent) project file, `STAGES` is empty. **No record carries an offset.**
|
||
|
||
**What does corroborate the binding:** `Static.slb` is 8 353 472 bytes, and at the
|
||
bitrates the disc uses elsewhere (25 697–31 000 B/s) that is **269–325 seconds of
|
||
audio — 0.84 to 1.01 s per cue over 322 cues.** Exactly the shape of a bank of
|
||
short UI/HUD/weapon effects, which is what `BANK_SE` says it is. The bank is the
|
||
right bank; only the index into it is missing.
|
||
|
||
**If it is ever picked up**, the route is the running game, not the disc: watch
|
||
the guest hand a wave offset/length to the decoder when a cue fires. That is the
|
||
same instrumentation the cue↔event binding needs, and the same one this container
|
||
cannot run.
|
||
|
||
## ✅ Retracting "cannot be extracted" — the waves are locatable, by playing them
|
||
|
||
The section above concluded that an individual SE cue's audio "cannot be
|
||
extracted". **That was too strong, and the tool to do it was already in this
|
||
build.** Canary here carries a cvar `xma_param_probe`, added for exactly this
|
||
purpose — its own comment says it logs each XMA stream's parameters and head
|
||
bytes "so raw sound.pak entries can be matched to real decode params".
|
||
|
||
Run with `--xma_param_probe=true`, drive the menu, and each newly-decoded stream
|
||
prints one line with its packet count, channel count, sample rate and **first 32
|
||
bytes**. Searching those bytes in `Static.slb` finds the wave.
|
||
|
||
| event | packets | bytes | format | offset in `Static.slb` |
|
||
|---|---|---|---|---|
|
||
| **d-pad move** (cursor) | 4 | 8 192 | mono 48 kHz | **`0x1ec0`** |
|
||
| **Ⓑ back** (cancel) | 2 | 4 096 | mono 48 kHz | **`0x0ec0`** |
|
||
| **Ⓐ confirm** (title → main menu) | 6 | 12 288 | mono 48 kHz | **`0x5d6c0`** |
|
||
|
||
Each head matched at **exactly one** offset. Reference data in
|
||
[`data/se-cue-runtime-offsets.txt`](data/se-cue-runtime-offsets.txt).
|
||
|
||
**Structurally this also cracks the bank's layout.** The first two are
|
||
*contiguous* — `0x0ec0 + 4096 = 0x1ec0` — so `Static.slb` is a packed run of
|
||
XMA waves, each a whole number of 2 048-byte packets, with no delimiter between
|
||
them. That is why the `seek`-magic scan found nothing: there is nothing to find.
|
||
A wave is defined only by (offset, packet count), and those live in the game's
|
||
own tables, not in the bank.
|
||
|
||
⚠️ **The order is not cue-id order.** `Ⓑ`'s wave precedes the cursor's in the
|
||
file, while the name match puts `SE_UI_CURSOR` at id 2 and `SE_UI_CANSEL` at 4.
|
||
So the index cannot be derived by counting; it has to be observed per cue.
|
||
|
||
### What is now measured, and what is still a name match
|
||
|
||
* ✅ **measured** — a d-pad move and a Ⓑ press each play a *specific, different*
|
||
wave, and both waves are located in `Static.slb`. The port can have the audio.
|
||
* 🟡 **still a name match** — that the cursor's wave *is the cue called*
|
||
`SE_UI_CURSOR`. The event→wave binding is measured; the event→*name* binding is
|
||
still read off the authors' identifiers.
|
||
* ✅ **decoded to PCM** — see below. The cues play.
|
||
|
||
### ✅ The waves decode — artifact, not assertion
|
||
|
||
[`tools/re-capture/slb_extract_wave.py`](../../tools/re-capture/slb_extract_wave.py)
|
||
wraps a `(bank, offset, packet count, channels, rate)` slice in a synthesized
|
||
XMA1 `RIFF` for ffmpeg's `xma1` decoder:
|
||
|
||
```
|
||
slb_extract_wave.py Static.slb 0x1ec0 4 # -> Static_0x1ec0.riff -> 0.533 s
|
||
```
|
||
|
||
| event | offset | packets | decodes to | RMS | peak | envelope |
|
||
|---|---|---|---|---|---|---|
|
||
| d-pad move | `0x1ec0` | 4 | **0.533 s** | 2 085 | 29 813 | sharp attack, monotonic decay |
|
||
| Ⓑ back | `0x0ec0` | 2 | **0.344 s** | 2 985 | 16 973 | attack, peak in frame 2 |
|
||
| **Ⓐ confirm** | `0x5d6c0` | 6 | **1.016 s** | 4 327 | 32 767 | peaks in frame 3, long decay |
|
||
|
||
All three are mono 48 kHz, audible from sample 0, and have the percussive
|
||
attack-and-decay shape of UI blips — not silence, not noise.
|
||
|
||
**Control, run first:** the *same* synthesized wrapper applied to `BGM_001.slb`'s
|
||
first wave decodes to **173.808875 s** — identical to the duration obtained from
|
||
that bank's own on-disc `RIFF` header in
|
||
[`structures/bgm-two-stems.md`](structures/bgm-two-stems.md). The wrapper is not
|
||
approximately right; it reproduces a known-good decode exactly.
|
||
|
||
The bitrates are consistent too: 8 192 B / 0.533 s ≈ 15.4 kB/s, 4 096 / 0.344 ≈
|
||
11.9 kB/s, 12 288 / 1.016 ≈ 12.1 kB/s — mono at roughly half the stereo BGM rate.
|
||
|
||
🟡 **The decoded audio is deliberately not committed.** Three commands regenerate
|
||
it from the disc, and the corpus keeps measurements and tooling rather than
|
||
extracted game audio. The offsets, packet counts and this tool are the
|
||
deliverable.
|
||
|
||
## The three cues the milestone needs are bound — 2026-08-28, second run
|
||
|
||
A second, independent boot repeated the probe and pinned the one that was still
|
||
unattributed.
|
||
|
||
| event | wave | packets / bytes | runs agreeing |
|
||
|---|---|---|---|
|
||
| **d-pad move** | `Static.slb+0x1ec0` | 4 / 8 192 | **2 of 2** — identical head bytes |
|
||
| **Ⓑ back** | `Static.slb+0x0ec0` | 2 / 4 096 | **2 of 2** — identical head bytes |
|
||
| **Ⓐ confirm** | `Static.slb+0x5d6c0` | 6 / 12 288 | newly bound this run |
|
||
|
||
The `0x5d6c0` wave was previously logged as "played before any input", because
|
||
the first run only started counting at the main menu. Counting from the **title**
|
||
instead shows it appears exactly on the Ⓐ that advances title → main menu, along
|
||
with the two stereo BGM stems (the menu's music starting).
|
||
|
||
So move / confirm / back — everything the five-screen milestone needs — are
|
||
located and decodable.
|
||
|
||
🟡 **What that Ⓐ measurement does not separate.** The same press both *confirms*
|
||
and *opens a screen*, so this wave could be the cue the vocabulary calls
|
||
`SE_UI_DECIDE` or the one it calls `SE_UI_SUB_WIN_OPN`. The port needs *a* sound
|
||
on confirm and this is the one the game plays there; which name it carries is not
|
||
established.
|
||
|
||
## ❔ ⬅ and ➡ play nothing distinct
|
||
|
||
[`menu-navigation-semantics.md`](menu-navigation-semantics.md) measured that
|
||
left/right do not move the cursor. They also **fire no new XMA stream** — so
|
||
there is no distinct "invalid" cue on the main menu, and `SE_UI_IMPOSI` was not
|
||
observed. The port should leave left/right silent.
|
||
|
||
⚠️ **Reach of that negative:** the probe dedups on head bytes, so a press that
|
||
replayed an *already-heard* wave would log nothing. At that point in the run the
|
||
cursor, confirm and BGM waves had all been heard. So this excludes a **distinct**
|
||
invalid cue; it cannot exclude left/right quietly replaying one of those three.
|