Table base + record layout

How to read a row: sound_id indexes the table at an 8-byte stride; the descriptor's five live bytes name a program (instrument), tone, note level, voice count, and mixer category.

FieldValue
Base addressDAT_8006F198 (file offset 0x5F998 in SCUS_942.54)
Index formDAT_8006F198 + sound_id*8
Stride0x8 bytes
Entry count100 descriptors (sound ids 0x00..=0x63)

The runtime readers gate on sound_id < 0x200, but that is an upper bound, not the size: only ids 0x00..=0x63 are real descriptors (every one populated - voice count 1..=3, trailing bytes zero). Id 0x64 onward is unrelated rodata, starting with the \PSX.EXE dev-path string, so the table's true extent is 100 entries.

OffsetNameField
+0pprogram / VAG index - selects the loaded bank's program-attr entry
+1ttone / ADSR-region base; a multi-voice cue uses consecutive regions (+i per voice)
+2lnote-level voice attribute (MIDI-ish, clusters near 60)
+3nlow 5 bits = voice count; bit 0x20 = sustained / continuous mode
+4idcategory - picks the 12-byte mixer record at 0x80091508 + category*12, and through its +8 the VAB slot the cue keys (see below). DAT_80091510 / DAT_80091513 are record 0's +8 / +0xB, not two byte arrays.
+5..7-no observed runtime reader (zero across the whole table)

The field names are the designer's own, recovered from the runtime debug format string "setbl p:%d t:%d l:%d n:%d id:%d".

Consumers

Two functions read the table, both indexing &DAT_8006F198 + id*8:

FunctionRole
FUN_800250D4(sound_id, voice)The per-actor SFX trigger (from the actor tick FUN_80021DF4). Uses only the voice count (n & 0x1F), SpuKeyOn-ing (FUN_800653C8) that many consecutive voices.
FUN_80016B6CThe per-frame SFX cue-ring drainer. Walks the 4-entry ring DAT_8007B6D8 (the same ring FUN_8004FCC8 and the move-power +0x0d sound cues write into), then programs voice_count voices via FUN_80065034 - the libsnd SpuSetVoiceAttr analogue that takes program (+0), note/region (+1 +i), attr (+2), and the channel volume picked by category (+4).

The SPU programming itself (FUN_80065034SpuSetVoiceAttr) is libsnd and out of clean-room scope - the engine has its own SPU. What is portable is the static data.

Program bank - the cue's category picks it

The descriptors' program / tone fields index a VAB, and which VAB is the cue's own choice. FUN_80065034 calls FUN_80068b98(vab_id, program) first, which repoints the libsnd "current bank" globals - _DAT_801ce33c (VAB-header base), _DAT_801ce334 (ProgAtr at +0x20), _DAT_801ce340 (VagAtr at +0x820) - at the slot the cue's mixer record names, and only then reads them. So a cue is not keyed against whichever bank the BGM sequencer left open. That older reading was easy to reach: the globals really are shared with the sequencer, so a save state sampled between cues holds whatever keyed last, and after a BGM note that is the music bank.

Across the catalogued save states mixer record N holds +8 == N and +0 == slot N's live VabHdr, in every record of every state - so the category byte is the slot id.

CategoryDescriptorsBank it keys
016Slot-0 system bank = PROT 0868. The shared UI cues (0x1A, 0x20, 0x21, 0x23, 0x37).
253Slot-2 class-2 bank = PROT 0869 (0875 when DAT_8007BD11 == 4). Battle / duel (0x09, 0x4C).
630Slot 6, PROT entry unpinned. Includes the field script cues 0x2E / 0x2F.
111Slot 11, PROT entry unpinned.

PROT 0868's identity is a byte match, not a label: a live field state's slot-0 VagAtr program-0 page (512 bytes) occurs verbatim in extraction entry 0868 at VAB offset +4, with the header's ps = 5 agreeing. Its CDNAME label reads battle_data and 0869's reads monster_data - the usual reminder that a label is a hint.

Why staging one bank fails quietly

Both viewers and the engine used to stage a single resident SFX bank, the class-2 one, and fire every cue through it. That is right for the 53 category-2 cues and wrong for the 16 category-0 ones - but it does not error and it does not go silent, because both banks carry a one-VAG-per-semitone UI key map at program 0. The id resolves to a sibling sample: a genuine retail blip, roughly twice as long and a fifth lower than the field menu's, because PROT 0869's center bytes are authored higher. That was the thump the browser play page's pause menu made. Peak, duration and "did a voice key on" all pass in that state; the only observable that separates the two is which PROT entry the samples came from.

Both hosts now stage the two pinned banks out of one SPU allocator over a shared reserved region and resolve each cue through its own category. The 31 category-6/11 descriptors fall back to the class-2 bank - exactly what they did before - rather than being routed on a guess, and which PROT entries fill slots 1 / 3 / 6 / 11 is the remaining open question.

SPU budget

Two banks resident at once means the reserved region has to hold both, and the music region is what is left of the console's 512 KB of sound RAM.

RegionBytes
PROT 0868 VAG bodies59 136
PROT 0869 VAG bodies188 128
Reserved SFX region249 856 (0x3D000)
BGM region (512 KB − 4 KB scratch − the above)270 336
Largest scene BGM VAB on the disc269 632

Every VAG in both banks is already a multiple of the allocator's 16-byte ADPCM block, so the packed footprint equals the raw total and 2 592 bytes stay free. The figure is squeezed from both sides: one step larger drops the BGM region below the largest scene VAB and starts silencing music that plays today, one step smaller does not fit both banks.

Provenance

Decoded directly from the disc, and cross-checked byte-for-byte against live save-state RAM: the table window at 0x8006F198 read out of a catalogued mednafen state's main RAM parses to the identical 100 descriptors as the disc SCUS_942.54, confirming the table is static rodata and the parser offset is right. The two cue ids the engine's default SFX bank already references resolve to 0x1A = program 3 / note 67 and 0x4C = program 3 / tone 8 (voice count 2).

Parser

legaia_asset::sfx_table::SfxTable::from_scus resolves the table from a SCUS_942.54 image (PSX-EXE t_addr → file-offset map, identical to the item-name table resolver); from_table_bytes parses a raw window straight out of save-state RAM. SfxDescriptor exposes the decoded fields plus voice_count() / sustained() / is_active() / vab_slot().

The same module carries the routing law: slot_for_category, prot_index_for_slot (which returns None for the unpinned slots, so nothing downstream can guess one), the PINNED_SLOT_BANKS pairs, and SfxTable::cue_slots for the per-cue view.

The disc-gated sfx_table_real test pins the layout and the category histogram against the real executable; sfx_table_live validates the parse against live RAM and feeds the descriptors into legaia_engine_audio::SfxBank::from_descriptors; sfx_cue_resident_bank proves the routed cues key a voice and that both banks pack inside the reserved region; and the browser play page's play_sfx_channel asserts a category-0 and a category-2 cue resolve to different PROT entries.

See also