Finishing the step I left open last iteration rather than starting something new. Offsets and packet counts were in hand; what was missing was proof they are actually waves. slb_extract_wave.py wraps a (bank, offset, packets, channels, rate) slice in a synthesized XMA1 RIFF, following the layout the Rust decoder already uses. The three located cues decode to 0.533 s, 0.344 s and 1.016 s of mono 48 kHz audio, audible from sample 0, each with the percussive attack-and-decay envelope of a UI blip. Bitrates come out at 12-15 kB/s, about half the stereo BGM rate, which is what mono should be. The control matters more than the results. The SAME wrapper applied to BGM_001's first wave decodes to 173.808875 s -- identical to the duration that bank's own on-disc RIFF header produced back when Q10 was answered. So the header I synthesized is not approximately right, it reproduces a known-good decode exactly, and the cue durations are trustworthy for the same reason. I did not commit the decoded audio. Three commands regenerate it from the disc, and the corpus's job is measurements and tooling rather than extracted game assets. The offsets, the packet counts and the tool are the deliverable.
11 KiB
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.
✅ 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. 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 — undecodable, reach already recorded
All 32 BGM cues are named BGM_001…BGM_109. Nothing in SOUNDS, FILES or the
bank headers names a screen — see
structures/bgm-two-stems.md. Unchanged by this
iteration.
❔ 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); 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.slbitself — 8 353 472 readable bytes, 0RIFF/ 0seek/ 0WAVE, where every one of the other 7 620 banks is delimited byseekmagic atdata_at + declared_size(structures/slb-data-offset.md);- the XACT project file — absent, as above;
- the sound table's five records —
SOUNDSgives cue→id,FILESgives bank paths,BANK_SEgives the one bank name,SETTINGSgives a path and the (absent) project file,STAGESis 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 |
| played before any input | 6 | 12 288 | mono 48 kHz | 0x5d6c0 |
Each head matched at exactly one offset. Reference data in
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
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 |
| pre-input cue | 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. 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.