Sound-effect descriptor table Confirmed
The blip when the cursor moves, the thud of a Tactical Arts hit, the level-up chime: each is a sound-effect id, and each id is one 8-byte row in a 100-row table baked into the game's main executable. A row names an instrument in a loaded VAB sound bank, how many hardware voices to use - and, through its category byte, which of four open banks the cue plays out of.
At a glance
- In the game
- Every UI, field-script and battle sound effect that is not a voice clip or music
- Where
SCUS_942.54rodata at RAM0x8006F198(file offset0x5F998); no magic- Size / stride
- 100 rows × 8 bytes, ids
0x00..=0x63; ids≥ 0x200come from the current sound bundle instead - Parser
legaia_asset::sfx_table; CLIasset sfx-table <SCUS> [--json]; enginelegaia_engine_audio::SfxBank+sfx_ring- Confidence
- Confirmed - disc table parses identically to live RAM; bank routing pinned by the initialiser's own writes and byte matches against the extracted VABs (how we know)
- Used by
- audio stack, move-power cue path, field menu, media browser cue player
Record layout
A cue id indexes the table at an 8-byte stride. The field names are the designer's own, recovered from a runtime debug format string (p / t / l / n / id).
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0 | 1 | p program | Program (instrument) index in the bank |
+1 | 1 | t tone | Tone index within the program; a multi-voice cue uses consecutive tones (+i per voice). Named by index, never resolved by key range |
+2 | 1 | l note | MIDI-style note (clusters near 60) |
+3 | 1 | n voices | Low 5 bits = voice count (1..=3 in retail); bit 0x20 = sustained mode |
+4 | 1 | id category | Selects a 12-byte mixer record, and through it the VAB slot the cue keys (below) |
+5..7 | 3 | - | Zero; no reader |
slot = record index into every mixer record, so a cue always names its own bank.Which bank a cue plays from
Four fixed banks are open at once and each category keys exactly one of them. The other open slots hold variable banks (scene music, side-band, monster sounds) that no descriptor keys - "the SFX bank is the scene's music VAB" is true only of slot 1.
| Category / slot | Descriptors | Bank | Typical cues | Loaded by |
|---|---|---|---|---|
0 | 16 | System bank, PROT 0868 (resident) | UI: confirm 0x20, cursor 0x21, buzz 0x23, cancel 0x37 | boot |
2 | 53 | Class-2 bank, PROT 0869 (0875 when the battle-data selector is 4) | Battle / duel: 0x09, 0x4C | battle loader, Baka Fighter init |
6 | 30 | Field bank, PROT 0876 | Field-script cues 0x2E, 0x2F; player set | field init |
11 | 1 | Reward bank, PROT 0889 | The single cue 0x50 | battle-reward resolution |
1 | 0 | Scene BGM bank (music_01, varies) | - | BGM loader |
3 | 0 | Side-band vab_01 bank (script-selected) | - | BGM loader |
7 / 8 | 0 | The battle's two monster.snd banks | - | battle loader |
Raw TOC indices run two above extraction indices (CDNAME numbering); CDNAME labels read battle_data for 0868 and monster_data for 0869 - a label is a hint, the byte match is the verdict.
Slots share sound RAM in pairs
The field bank (slot 6) and the class-2 battle bank (slot 2) occupy the same physical SPU region, refilled per mode, so they are never resident together. Field modes open slots 0, 1, 3, 6; battle opens 0, 1, 2, 7. The battle mode init closes slot 6 and clears the field-bank latch 0x8007BAFC (so do the minigame warp and field-VM op 0x36 sub 3); the next field init sees the latch clear and reloads PROT 0876. A cue whose slot is closed is skipped, not rerouted.
| Slot pair | SPU base | Region size | Bank bodies |
|---|---|---|---|
0 / 10 | 0x1010 | 61 440 | PROT 0868 - 59 136 |
1 / 5 | 0x10010 | 143 360 | current BGM bank |
2 / 6 | 0x33010 | 184 320 | PROT 0869 - 188 128 / PROT 0876 - 174 192 |
3 | 0x60010 | 20 480 | side-band bank |
4 / 7 | 0x65010 | 30 720 | monster.snd A |
8 | 0x6C810 | 10 240 | monster.snd B |
11 | 0x6F010 | 69 616 | PROT 0889 - 19 344 |
The sizes are allocation, not enforcement: PROT 0869 overruns the region below slot 3 by 3 808 bytes whenever that slot is closed.
Firing a cue
Two readers consume the table: the per-actor trigger (used by the actor tick) and the per-frame cue ring drainer. The SPU programming under them is PlayStation library code; what is portable is the data and the ring.
The ring: four scheduled one-shots
- Four slots, each an
i16cue id plus au32countdown in vsyncs. A producer writes the id with timer 0 into a round-robin cursor slot; a fifth pending cue replaces one. - An ager decrements every timer by the adaptive frame step and floors at zero; a slot at zero with no id becomes
-1. - The drainer plays a slot only when its timer is exactly zero and its id is still
≥ 0. A cue armed with timerNplays on the frame the countdown first reads zero. - The ring value is the descriptor index; overlays that write the ring directly write ids as-is. The move-power cue path stores
id - 1forid < 0x40.
Voice allocation
| Mode | Voices | Rule |
|---|---|---|
One-shot (n & 0x20 == 0) | 23 - cursor, descending | Rolling cursor over a limit of 3 (1 in game modes 3 and 0x17); each voice is stopped before reprogramming |
Sustained (n & 0x20) | 7 .. 7 + count - 1, ascending | The previous run is released first; the mixer record latches for the held run |
The mixer record's enable byte (+0xB) skips a cue before any voice work; while a global override is set every cue is forced onto channel 6.
What a port has to get right
- Tone by index. The fire path hands the descriptor's tone index straight to the voice programmer. Several retail cues carry a note outside their tone's key window (menu cancel
0x37: tone 5, note 64, window[65,65]), so a key-range lookup renders silence. Engine:VabBank::play_tonebeside the sequencer'splay_note. - UI cues are pitched down on purpose. Program 0 of the UI banks is a one-VAG-per-semitone key map whose
centerbytes sit 12..26 semitones above the descriptors' notes. Pitch is0x1000 × 2^((note - center + fine/128)/12)with no sample-rate factor; a port that also multiplies by22050/44100drops another octave. - There is no footstep sound. A probe over every producer, the trigger, the voice programmer and the ring sees nothing fire while walking; the only walk cues (
0x2E,0x2F) come from field-script triggers. A port that wants a footstep has to author one. - Stage the right bank. A single-bank port fails quietly: a category-0 id fired through the class-2 bank resolves to a sibling sample - a genuine retail blip, twice as long and a fifth lower.
SPU budget: both pinned banks in one region
The engine stages slot 0 plus the current mode's slot-2 / slot-6 bank together; the BGM region is what remains of the console's 512 KiB of sound RAM.
| Region | Bytes |
|---|---|
| PROT 0868 VAG bodies | 59 136 |
| PROT 0869 VAG bodies | 188 128 |
Reserved SFX region (SFX_BANK_SPU_BYTES) | 249 856 (0x3D000) |
BGM region (512 KiB − 0x1000 scratch − the above) | 270 336 |
| Largest scene BGM VAB staged | 269 632 |
One step larger silences music that plays; one step smaller does not fit slot 0 plus PROT 0869. Slot 6 is slot 2's region in retail, and both play hosts refill it per mode the way retail's inits do: PROT 0876 (the field bank) in the field and on the world map, PROT 0869 in battle, a minigame's own bank in its mode. A cue whose slot is closed is silent, as retail's drainer skips it.
Ids ≥ 0x200: the current bundle's record 0
id < 0x200: desc = table + id*8 ; this page
id >= 0x200: desc = bundle + offsets[0] + (id - 0x200)*8 ; current sound bundle, record 0
Both readers resolve the high arm through the sound subsystem's current-bundle pointer, and it has two occupants. In the field the loader repoints it at the scene's prescript bundle on every scene load, so the high bank is the scene's own record 0, sized per scene (jou reserves 96 rows and fills 40; rugi carries 21). In battle it is bse.dat (sound-driver formats) - loaded at battle-scene setup, not at boot: its one caller anywhere on the disc is inside battle init FUN_800513F0. This page previously said "at init".
One column is not static in battle. Before enqueueing a cue the battle cue router FUN_8004FE5C overwrites byte +4 (id, the category) of record[cue_id - 0x200] from a per-actor byte, reaching the bank through its own saved pointer gp+0x678 rather than through the current-bundle slot. So a live cue's VAB slot is chosen by the actor that fired it, and the authored category is a default.
History: the single-bank port
Both viewers and the engine once staged one resident bank and fired every cue through it; peak, duration and "did a voice key on" all passed while the pause menu thumped. Slots 6 and 11 were listed as unpinned before PROT 0876 / 0889 were byte-matched. See do-not-re-walk.
How we know
| Function | Address | What it proves | Dump |
|---|---|---|---|
| Per-actor trigger | FUN_800250D4 (0x800250F4..14C) | 8-byte stride at DAT_8006F198; < 0x200 gate; voice-count key-on | funcs/800250d4.txt |
| Cue-ring drainer | FUN_80016B6C | Program / tone +i / note / channel handed to the voice programmer FUN_80065034; descend-from-23 / ascend-from-7 voice rules | funcs/80016b6c.txt |
| Ring ager / producer | FUN_8001698C, FUN_80035B50 | Timer floor at zero; round-robin cursor gp+0x158; ids DAT_8007B6D8[4], timers DAT_8007C338[4] | funcs/80035b50.txt |
| Sound initialiser | FUN_8001D424 | Writes +8 = record index into all 16 mixer records at 0x80091508: category = slot | funcs/8001d424.txt |
| Bank open gate | FUN_80068B98 | Rejects slot ≥ 0x10 or a closed bank (_DAT_801CE368[id] != 1); repoints the libsnd current-bank globals | funcs/80068b98.txt |
| Bank loaders | FUN_8001FC00 + FUN_8001E54C; callers FUN_800520F0, FUN_801CF00C, FUN_801D6704, FUN_8004E568, FUN_800243F0 | Which PROT entry reaches which slot; SPU bases from FUN_800265E8 | per-function dumps |
| Byte matches | PROT 0868 / 0876 / 0889 | Live slot-0 VagAtr page occurs verbatim at 0868 +4; 0876 holds 30 VAGs for 30 category-6 cues; 0889 populates only program 10, which cue 0x50 names | sfx_table_real, sfx_table_live |
| Footstep probe | FUN_80018DB0 + producers | Standing vs walking for the same vsyncs: no voice program | funcs/80018db0.txt |
Parser: SfxTable::from_scus resolves the table from a SCUS_942.54 image; from_table_bytes parses a RAM window. The routing law lives beside it (slot_for_category, prot_index_for_slot, spu_base_for_slot, SLOT_ALIASES). Source of record: docs/formats/sfx-table.md.