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_game_tables::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).
| Field | Meaning |
|---|---|
p program | Program (instrument) index in the bank |
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 |
l note | MIDI-style note (clusters near 60) |
n voices | Low 5 bits = voice count (1..=3 in retail); bit 0x20 = sustained mode |
id category | Selects a 12-byte mixer record, and through it the VAB slot the cue keys (below) |
+5..7 | Zero across the table; no reader |
Row 0x64 onward is unrelated rodata (a dev-path string), so the readers' < 0x200 gate is a bound, not the table size. The drainer still indexes the table for every id below 0x200, so an id past row 0x63 keys whatever the bytes after the table read, and about half of those rows carry a voice count. No shipped producer is known to push such an id; the port keys nothing for one.
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 Terra stands on party seat 1) | 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. - Who writes it in the field: field-VM op
0x36(sub 0 pushes a cue id, sub 4 sets its delay in vsyncs - scripts pair them) and the motion VM's op0x09. Both pass the id straight from bytecode, static ids and per-scene ids≥ 0x200alike. - 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.
The side-band bank a script selects
Category 3 keys the side-band slot, and field-VM op 0x36 sub 1 picks which bank fills it: a request id 2000..=2999 streams the vab_01 block's entry id - 2000 into slot 3, ≥ 3000 streams id - 3000 into slot 6, and 0x1000 parks the request without loading anything. Shipped scripts use only those three forms.
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 |
- A one-shot keeps only a voice's last pass. The stop before each reprogram raises the voice's key-off bit and clears its pending key-on, and nothing reaches the chip until the flush. So a voice the wrapping cursor visits twice in one cue keeps only its last pass: that pass's tone, or nothing but the stop when the tone does not resolve. Under the field's limit of 1 a four-voice row lands on voices 23 and 22 twice each, and a
jouinarow whose tone runs past its program's tones from the third voice on keys nothing at all in retail. The port resolves every pass first and keys afterwards (SfxBank::play_entry). - A zero-voice sustained cue. The held-count write sits inside the key-on loop, so a sustained cue with no voices releases the old run but leaves the held count unchanged;
SfxBank::play_descriptordoes the same for a runtime row.
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. - The dialog box cues its picker, not its text. The dialog pager
FUN_801D84D0(field overlay, PROT 0897) reaches the ring only from the option picker's cursor handler: cue0x21when an Up / Down press actually moved the cursor (a press clamped at either end is silent), cue0x20on the confirm press. The typewriter, page turns and dismissing a box are silent - there is no text blip to port. Engine:legaia_engine_dialog::dialog::{PICKER_MOVE_CUE, PICKER_CONFIRM_CUE}, pushed throughWorld::push_sfx_cueby the three runners that drive a picker. - A door has no cue of its own. None of the field overlay's producer calls sits in the warp-tile arm, the walk-on trigger dispatch or the scene-change routine
FUN_8001FD44, and the overlay never stores into the ring directly. A door that sounds does so because its own record carries a field-VM op0x36ahead of the0x3F- a script literal. - 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
A port that stages slot 0 plus the current mode's slot-2 / slot-6 bank together has this much sound RAM left for music out of the console's 512 KiB:
| Region | Bytes |
|---|---|
| PROT 0868 VAG bodies | 59 136 |
| PROT 0869 VAG bodies | 188 128 |
| Reserved SFX region | 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, refilled per mode: 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.
One bank does not fit that window: the dance hall's (PROT 1231). Retail overruns into slot 3's closed base; the port has slot 0 below and the top of SPU RAM above, so it lays the overflow in the BGM region's free tail instead (a voice addresses each sample on its own, so a bank need not be contiguous). That half is a borrower: a track that reaches it drops the bank, and the director re-stages it once the free tail moves.
Ids ≥ 0x200: the current bundle's record 0
| Cue id | Descriptor address |
|---|---|
< 0x200 | table + id*8 - this page |
≥ 0x200 | bundle + offsets[0] + (id - 0x200)*8 - the current sound bundle's 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.
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.
How we know
Provenance by function
| 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 | disc + save-state comparison |
| Footstep probe | FUN_80018DB0 + producers | Standing vs walking for the same vsyncs: no voice program | funcs/80018db0.txt |
In the port: 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.