Audio
How Legaia makes sound. The town theme that starts when you step through a gate, the clang of a sword hit and the shout a character gives when an art lands each take a different road to the PlayStation's sound chip: music is a MIDI-like score played through a sampled instrument bank, effects are one-shot samples keyed on a voice, and voices and cutscene audio are streamed straight off the CD. This page follows the three roads and shows how the from-scratch engine rebuilds the stack.
At a glance
- Hardware
- The SPU: 24 voices, 512 KB of its own RAM, ADSR envelopes, one global reverb. Retail runs reverb Studio C, always on, with most voices routed through it
- Music
- Every real track lives in the
music_01bank as a self-contained[VAB][SEQ]pair; scenes pick one by script (field-VM op0x35, id2000 + slot). No BGM table exists - Effects
- 100 static descriptors at
DAT_8006F198; the descriptor's category byte names which of twelve VAB slots the cue plays from - Voices
- CD-XA streams: arts shouts from
XA2 / XA4 / XA6, the melee grunt fromXA30, victory lines from the tail ofmonster.snd - Libraries
- PsyQ libsnd (SsAPI sequencer,
0x80061..0x80067) and libspu (0x80068..0x8006D), statically linked and traceable - Port
crates/engine-audio: from-scratch SPU (ADSR, ADPCM, reverb network), sample-lockedSequencer, SFX bank + scheduler, XA mixing; WebAudio backend for the browser- Oracle
legaia-engine audio-trace- per-frame voice activity against save states and PCSX-Redux captures- Confidence
- Confirmed for dispatch, banks, cues and the sequencer; the
bse.datrecord columns are the SFX-table columns, pinned by the shared decoderFUN_80016B6C
How it works
The SPU cannot be driven from main RAM directly; games go through Sony's PsyQ libraries - libsnd for sequencer playback and libspu for voice and register control. Legaia links both statically, so the libraries are part of the game binary and can be traced like any other code. Legaia's own layer above them is small: a path-string cluster, two loaders, one bank installer, one SFX helper, one BGM poller and one CD-XA clip player.
The surprising part is music dispatch: there is no "BGM id to file" table anywhere. A scene's own script writes a track id, and a per-frame poller turns that id into a PROT.DAT entry number by arithmetic over the disc layout. The archive is the table.
Finding the sound files
Retail reads sound data by PROT.DAT entry number. A dev-station path (h:\...) survives in the same functions but is never taken on a retail disc: the split is a build-mode global, and the dev arm opens files through a host trap that has no disc to read.
| Piece | What it does |
|---|---|
| Path-string cluster | Eight extensions the sound code appends to scene-asset paths - .spk, .LZS, .dpk, .MAP, .PCH, .pac, STR - plus the master file bse.dat. Layout on sound-driver outputs |
Sound init / .dpk loader | Loads bse.dat (the battle occupant of the >= 0x200 SFX descriptor bank) at battle-scene setup - its only caller is battle init FUN_800513F0 - then the per-scene .dpk |
| Streaming-asset loader | Builds paths under sound\; the XA / .pac / STR consumer and the streamer every VAB-slot install goes through |
A third "mode-aware extension dispatcher" that turns up in call graphs is not a sound consumer at all - it is the character-mesh equipment group-transform swap (character mesh).
Sound banks and their slots
An instrument bank (VAB, format on vab) is a set of samples plus the tuning and envelope data that makes them instruments. Legaia keeps several resident at once, each parked in a numbered SPU slot. One installer opens every bank; its argument is the same category byte the SFX descriptors carry, and a 12-byte mixer record per category names the header buffer and the slot.
| Slot | Bank | Installed by |
|---|---|---|
| 0 | PROT 0868 system bank | Resident from boot |
| 1 | The current BGM's own VAB (music_01, variable) | The BGM poller |
| 2 | PROT 0869 class-2 bank (0875 alternate) | Battle scene loader |
| 3 | A vab_01 side-band bank (variable) | The BGM poller |
| 6 | PROT 0876 field bank | Field init |
| 7 / 8 | The active battle's two monster.snd banks | Monster bank loader |
| 11 | PROT 0889 reward bank | Victory spoils |
- Slots 2 and 6 share one SPU base. The record initialiser assigns header buffers from one base with four pairs sharing one, and the SPU-base table gives those pairs one address - the field bank and the battle class-2 bank are the same physical bank in two modes. The engine seeds the same twelve-word image at boot and answers slot queries from the other end, with a test that the two agree.
- Monster banks come from one packed file,
monster.snd, with a per-monster TOC of[start_lba, end_lba + 1]pairs. Its tail clips carry the party's victory-pose voice lines: the spoils routine picks a pose tier from the actor's HP (three-quarters, half, quarter; a status forces tier 4), an action id from a per-character table, and a clip byte - Vahn0xB8..0xBC, Gala0xBD..0xC3, Noa0xC4..0xCB. - Multi-bank archives sit at extraction 0889 / 0890 (
sound_data2) and 0891 (level_up). Slot sizes and the SPU map: sfx table.
Sound effects
A footstep, a menu blip, a sword clang: each is one 8-byte descriptor - which program in which bank, how many voices, how loud - and the actor or script that wants the sound just names it. The static table holds 100 descriptors; ids at or above 0x200 come from the runtime .dpk bank instead.
| Byte | Field |
|---|---|
| +0 | Program |
| +1 | Tone / region base |
| +2 | Note level |
| +3 | Voice count, plus a sustained bit 0x20 |
| +4 | Category - selects the VAB slot the cue plays from |
- Trigger. The actor tick fires the per-actor helper when the actor's one-shot pulse byte is set or a continuous cue is staged; move-VM and field-VM opcodes write the sound id and voice into the actor. The helper keys one voice per descriptor count.
- Bank is named by the cue. The voice programmer issues a VAB program-change before the program lookup, repointing libsnd's current-bank globals at the slot the category byte names. A cue never plays out of the scene's music VAB.
- Engine.
SfxBank::from_descriptorsbuilds the catalog from the disc table and fires each cue against the bank its category names;SfxSchedulerdispatches at the retail timing offset (a hit cue withtiming_frames = 1plays one frame after the strike begins). Implementationcrates/engine-audio/src/sfx.rs.
History: "a cue plays out of the scene's music VAB"
The current-bank globals are shared with the sequencer, so a save state sampled after a BGM note holds the music bank. That artefact produced the earlier reading; the program-change call inside the voice programmer is what selects the bank per cue.
Music: which track plays, and how it starts
Walk into Rim Elm and the town theme starts; walk into a fight and the battle theme takes over. Nothing in the executable maps a place to a tune. The scene's own script says "play track 2016" through field-VM op 0x35, and the per-frame poller turns that id into a disc entry:
| Id | Resolved PROT index | Base |
|---|---|---|
< 2000 (scene-local) | change test: scene_block + 6 + id; loaded: music_01_base + 2 | Global slot 2 - the load index is overwritten, so no scene bank is ever staged |
≥ 2000 (global pool) | music_01_base + (id − 2000) | Raw TOC 990 = extraction 988 |
- The bank is piecewise. Global id
2000 + iequals sound-test tracki, but in extraction space the bank runs988 + ifori ≤ 67and990 + ifori ≥ 68, with a two-entry gap at 1056 / 1057.music_labels::prot_entry_for_bgm_idowns that map; a flat base gets the top of the sound test wrong. - Each entry is self-contained - chunk header,
pBAVbody, then apQESscore. Playing a track uploads that entry's VAB into SPU RAM; a scene'sscene_vab_streamSEQ is never its music, which is why fingerprinting a playing track against the scene-local corpus fails. - Script-selected, not table-driven. Running every scene's prescript and observing the emitted id (
bgm_scene_resolution.rs) shows every scene that starts music picks a global-pool id. The sweep sees a scene's entry track only; cutscenes change music mid-scene with sub-op 9 from timeline records. - Re-selecting the playing track is a no-op - the resolved index is compared with the loaded one before anything happens.
The track-swap handshake
A cutscene can choose the exact beat the outgoing score dies on. The poller runs a stage counter through a seven-entry ladder - wait for CD idle, arm a 30-frame settle, tear down the BGM slot unless the script owns the swap, kick the load, count down, install the SEQ, re-attach unless paused, latch - coordinated with the script through one sound-flag word:
| Bit | Meaning | Set by | Cleared by |
|---|---|---|---|
| 0 | Script-owned start pending (defer slot teardown) | Sub-op 9 | Poller commit, scene entry |
| 1 | BGM slot paused / detached | Sub-op 2, 3, the dance overlay | Sub-op 1, 4, 0xA, scene entry, game over |
| 2 | Keep the audio across the next scene load: the scene initializer skips its music level ramp and its sound-effect voice cut | Sub-op 6 | Field overlay |
| 3 | Load settled - payload staged and delay elapsed | The poller | Poller commit |
| 4 | Release-ack - the script has released the old occupant | Sub-op 0xA | Poller commit |
After setting bit 3 the poller stalls while bit 0 is set and bit 4 is not: a swap started by sub-op 9 keeps the old track until the script commits with sub-op 0xA. A port that honours the sub-op 2 pause but drops the 0xA commit leaves the music paused after the cutscene; the engine routes sub-op 0xA straight to BgmDirector::unhalt_pause because its BGM bytes resolve synchronously.
- Stop, pause, replay. The control words are not the labels they were given. Sub-op 2 stops the track (notes cut, read cursor back to the start), sub-op 3 pauses it (notes keyed off, cursor kept) and sub-op 4 replays it from its first event - the libsnd play call under it resets the cursor before it plays. The engine routes 2 and 3 as a pause and 4 as a rewind-and-resume; it once read 3 as a resume and 4 as a stop, which silenced the score where retail brings it back.
- Movies. Retail's movie player never touches the sequencer; a mid-game movie inherits whatever the script left, and a capture shows a score the script left sounding keeps playing under the movie's audio, and one already stopped (
taiku's own stop word,garmelafter its boss fight) stays silent. Only the title attract acts: it releases the title theme, and the card-init screen it returns to streams the theme again from the top. Both of the port's hosts follow one engine policy - retail by default, so a cutscene movie layers over the score; the attract stops and restarts the theme; an optional ducking policy lowers only a sounding score and gives back only what that movie took. - Entry-script pauses. town01 starts id 2016 and, while a system flag is clear, pauses it at once - the opening's silent dawn, restarted by the cutscene's own sub-op 9. The engine's free-roam staging drops that pause on scene-picker entries only; the new-game chain keeps it.
- Battle swap (engine).
World::set_battle_bgmqueues an ordinary sub-op 1 start on encounter and resumes the field track on battle end. Default battle track: global id 2026 (retail'sbattle_id == 0bundle, sound-test #26). Retail BGM changes are hard cuts, soAudioOut::swap_bgmkey-offs the outgoing sequencer (notes release through their own envelopes) and starts the new one that instant, with a couple of frames of click-guard on the master. - Minigame pages take the same path: Baka Fighter loads extraction 1043 (#55), the dance overlay 1048 / 1054 (#60 / #66); the slot machine, fishing and Muscle Dome inherit their host scene's track.
Battle voices: shouts and the melee grunt
When Vahn lands a Somersault he shouts. That shout is not an SPU sample - it is a short CD-XA clip streamed off the disc, picked at random from a small per-art channel pool so it does not repeat back to back. The plain melee swing works the same way, which is why a port that wires up only the SPU stays silent in a fight.
| Cue | Source | Path |
|---|---|---|
| Arts shout | XA2.XA Vahn, XA4.XA Noa, XA6.XA Gala (16-channel mono banks) | Staged-animation materialiser → cue selector (pool pick) → CD-XA clip player. A second trigger fires from the animation cue track mid-clip |
| Melee grunt | XA30, clip 0x1D, character-spaced channel | The melee roll routine, first emission |
| Melee impact | Cue 0x10C through the cue router - the XA voice leg for a party attacker, an element-tinted ring for a monster | The melee roll routine, second emission |
| Victory line | Tail clips of monster.snd | Spoils routine (see banks) |
Engine. Executing an art pushes one BattleShoutCue; boot demuxes XA2 / XA4 / XA6 from the raw 2352-byte sectors (disc-image boots only); AudioBgmDirector::play_art_shout resolves the pool pick and AudioOut::play_xa_shout mixes the PCM the way the CD input does, never through the 24 voices. Two timing behaviours model the CD contract: a ~150 ms response delay (the shout trails the animation) and back-to-back requests queue rather than cut. The cue tables are parsed by legaia_art::arts_voice.
Shipped. The melee grunt and the impact cue are staged: boot demuxes the XA27 / XA30 clip banks (read_battle_xa_clip_bank) and the live battle loop fires the per-strike cue (World::fire_melee_impact_cue) - the XA30 grunt on the strike that commits the target's +0x1F3 reaction (four captured ordinary swings emit neither cue), the 0x10C sting only when the effect handle _DAT_8007BD84 is non-null, the s7 pose gate honoured as retail's per-strike condition.
Sony's sequencer and SPU driver
The music player is Sony's, not Legaia's. libsnd (0x80061B18..0x800681D8) walks the SEQ bytes, keeps per-channel state in 16 slots of 0xB0-byte records, and turns note events into key-ons; Legaia installs two function-pointer hooks to wire the script VM's sequence context in. libspu (0x80068..0x8006D) pokes the chip's registers, runs the four-mode DMA transfer engine and the block allocator behind SsSpuMalloc / SpuFree. Being the same libraries every PsyQ game shipped is what lets the functions be named.
- Key-on volume chain. Velocity × bank volume × program volume × tone volume widened into the 14-bit register domain, a channel-volume fold, one pan attenuation per source, and - on the sequencer path only - a closing square taper. Engine port
VabBank::fire+sequencer::channel_mix. - Pitch law. A note is pitched against its tone's
centerthrough a two-octave table into a 14-bit SPU PITCH (clamped0x3FFF). - Bank open validates the
pBAVmagic, builds the program-to-tone-page rank map into the reserved ProgAtr words, allocates SPU memory and stashes per-VAG addresses. Engine portVabBank::upload. - Tempo becomes an integer tick step where the wall clock meets the sequencer; the decoder does not consume a whole event per call.
- Not audio: the
0x801CE628cluster next door is libpad. And none of this is libgpu - battle and field code fire cues during render passes, which is why the cluster looks renderer-adjacent in call graphs.
Details: libsnd function and global catalogue
| Address | PsyQ name | Role |
|---|---|---|
FUN_80062340 | SsSeqOpen | Walks the 16-bit slot bitmap at _DAT_801CD2B8, marks the first free slot, initialises it |
FUN_80062410 | _SsSeqInit | Validates magic + version, reads PPQN base, BPM, ticks-per-quarter |
FUN_80061D18 | SsSeqClose | Key-off, clears the bitmap bit, resets the 16 channel records |
FUN_8006275C / FUN_800628F0 / FUN_800641EC | pause / play / stop | Flag edits on the per-slot status word at +0x98: the pause raises 0x2 and keeps the cursor; the play core rewinds the cursor to the start for every mode, then plays; the stop kills the notes and rewinds |
FUN_80061C68 | _SsSeqGetVar | Varint delta-time decode into the running tick at +0x88 |
FUN_80061EDC / FUN_8006206C | SsSeqSetVol / _SsSetSlideVolume | Volume fetch and N-tick ramp |
FUN_80067550 | _SsVoNoteOn | The key-on volume chain |
FUN_80067E9C | _SsSeqNoteOn | Sequence-driven key-on; calls the program-change |
FUN_80065978 | _SsVoKeyOnDirect | Voice allocation, region lookup, pitch + base note |
FUN_80066E50 / FUN_8006C6E4 | _SsPitchFromKey / _SsKey2Pitch | 12-entry key table at _DAT_8007A940; two-octave table |
FUN_80068B98 | VAB program-change | Installs the current-bank globals, reads the packed tone-page index |
FUN_80068D94 | SsVabOpenHead | Bank open (see above) |
FUN_800684CC | Key-off by owner | Keys off every 0x36-stride voice record at 0x801CDB60 whose +0x0 owner key equals the argument (seq | track << 8, as FUN_800638D8 passes it) - not a VAB-id search |
FUN_8006CA7C / FUN_8006CDB0 / FUN_8006DDC8 | SsSeqGetStatus / SetCallback / SetMarkCallback | Resolve the context through the Legaia hook _DAT_801CE564 |
FUN_80062AA0 / FUN_80065440 | SPU command shims | ×0x81 scaling, 0..127 to 0..16383 |
Details: libspu globals, primitives and the transfer engine
| Address | PsyQ name | Role |
|---|---|---|
_DAT_8007AF40 | SPU register base | MMIO 0x1F801C00; +0x180 main volume, +0x1AA SPUCNT, +0x1B0 reverb volume, +0x1C0..0x1FE reverb block |
_DAT_8007AFA4 / _DAT_8007AFF8 | Block table / master attribute struct | Free bit 0x80000000, end bit 0x40000000; 10 modes × 0x44 |
FUN_8006ACBC / FUN_8006C048 | SpuSetVoiceAttr | Mask-driven dispatcher; 24-voice broadcaster over 1 << i |
FUN_8006BCB4 | SpuSetCommonAttr | Master volume, reverb registers, SPUCNT bits |
FUN_8006B1B4 | SpuSetReverbModeParam | 30-attribute reverb commit |
FUN_80069B18 | _spu_t | Four-mode transfer state machine (arm read, arm write, stage address, commit); 0xF00-poll timeout |
FUN_800697E0 / FUN_80069DA8 | _SpuTransfer / SpuWrite | CPU-copy path in 0x40-byte chunks; DMA-vs-CPU pick |
FUN_8006A158 / FUN_8006A728 / FUN_8006A420 | SsSpuMalloc / SpuFree / compactor | First-fit walk, high-bit free, coalesce |
An earlier "SsSepOpen / VAP SEP loader" reading of the bank open is falsified - the magic is the VAB's. See do-not-re-walk.
Engine port
crates/engine-audio ports the stack as a from-scratch model from this page, the libspu API surface and the public SPU register layout. No Sony bytes.
| Module | Models |
|---|---|
spu::Spu, spu::voice | 24 voices with sample address, loop point, pitch, ADSR, L/R volume and reverb send; 4-point Gaussian resampling as the hardware does |
spu::adsr, spu::adpcm | Five-phase envelope with linear / exponential modes; streaming 28-samples-per-block decoder, one per voice |
spu::reverb | Register-driven network (same / different-side IIR, 4-tap comb, 2 all-pass) with the nine standard presets |
spu::ram | 512 KB model, libspu-shaped transfer engine, SpuAllocator |
vab_bind::VabBank | upload drops every VAG into SPU RAM; play_note turns a MIDI key into voice config + key-on. Programs are slot-indexed |
Sequencer | SsSeqOpen / Play / Close / SetVol / Rewind surface; round-robin voice allocation tracking (channel, key); pitch bend against per-tone pbmin / pbmax; loop markers from CC99 (20 = start, 30 = forever) |
AudioOut | One cpal stream draining the SPU at 44.1 kHz; swap_bgm, play_xa_shout. The audio-webaudio feature adds WebAudioOut for the browser |
- Reverb is Studio C, always on, on every voice. Every captured state - field, town, battle, summon, title, minigames - has the master enable set and the 32 reverb registers byte-identical to the
StudioCpreset. A per-vsync capture reads the per-voice enable register as all 24 bits set on every frame, and the output depth, which is not part of any preset, as a fixed pair. The engine callsSpu::set_retail_reverb()once at init and installs the measured depth. No SEQ carries a CC91 reverb-depth event. - Timebase. The production path ticks the sequencer once per SPU sample with an exact integer accumulator - locked to the audio clock, no drift, bit-deterministic for the replay oracle. The SEQ header tempo is a 240 BPM placeholder overridden by the first body tempo event, which carries no MIDI length byte; mis-parsing it pins playback ~3x fast (seq).
- Not modelled: pitch modulation, noise and FM (Legaia uses none), asynchronous DMA timing, and the hardware's 39-tap reverb FIR (decimation + zero-order hold instead).
XA-ADPCM Confirmed
Cutscene audio and voice clips are streamed CD-XA: the drive plays them directly, interleaved with video sectors, without the CPU or the SPU voices in the loop. crates/xa decodes the 4-bit ADPCM bit-exactly against an external lossless reference; the demuxer splits raw 2352-byte sectors by (file, channel), which a 2048-byte ISO view strips. Format on xa.
History: the "non-standard interleave" reading
An earlier reading had most XA sound groups failing validation and inferred a bespoke interleave. That was Form-1 truncation damage in the old extractor, not the format. See do-not-re-walk.
Checking the port: the audio-trace oracle
The port's sound is checked by comparing which SPU voices are active, frame by frame, against a real emulator running the retail disc - the audio sibling of the VRAM and mode-trace oracles. Two retail capture shapes emit the same AudioTraceFrame JSONL: a single-cycle snapshot lifted from a mednafen save state's SPU section, and a multi-frame trace from autorun_audio_trace.lua in PCSX-Redux. The engine runs a standalone SPU + sequencer beside a headless boot session; a retail frame converges when some engine frame's active-voice mask is a superset of retail's with every retail voice's start address matching. A start-address or master-volume mismatch is a hard failure, and so is an engine trace that sounded nothing at all where retail had voices.
What a save-state comparand cannot decide is the rest. An SPU voice leaves its off phase on key-on and returns only when a key-off's release runs all the way down, and nothing does that wholesale - so a save taken mid-playthrough reports nearly every voice non-off, the residue of every cue since boot rather than the set the current score is sounding. A cold scene-entry window holds single-digit concurrent voices, so it can never be a superset of that, however faithful the playback. Deciding which voices belong to the score needs the per-vsync PCSX-Redux trace, captured from a moment whose loaded track is the one the engine side plays - which the save's scene does not settle.
That comparand once read as "the engine is much the quieter side", with reverb routing as the first channel to differ and a roughly halved voice count behind it. Both were instrument defects. The reverb figure came from the wrong register: the emulator's SPU register blob is the hardware window verbatim, and the offset being read was the SPU control register, not the per-voice reverb-enable mask. Read correctly, retail routes all 24 voices, and so does the engine - the oracle had been building a bare SPU where the shipped audio host builds one through set_retail_reverb(), so it was measuring an engine the port does not ship. Reverb now agrees frame for frame on routing, depth and work area.
The voice-count gap was two different pieces of music. An engine trace plays the track the scene's own entry script selects; a retail save plays whatever the playthrough left loaded, and a town save reached by walking in from the world map still holds the overworld track. Paired on one track and run over a comparable stretch of it, the two sides hold the same number of concurrent voices. The rest of the old gap is where in the track each window sits: that track's own score, decoded straight from the sequence data, peaks at nine simultaneous notes in its first few seconds and nineteen over the whole piece - and nineteen is exactly what the retail capture shows. With trace logging on, the sequencer drops no notes at all across the window.
What is left after track and window are controlled for is one per-voice difference: retail spends slightly more voices than it has distinct notes, doubling a note across two slots about one time in six, where the port plays each note once. The trace record now carries per-voice envelope level, volume and envelope-config word from all three emitters, so that question can be asked of the artifact directly.
CLI: legaia-engine audio-trace --scene NAME with --scenario (snapshot) or --retail-jsonl (multi-frame), plus --per-voice for the allocation comparison. Tests audio_trace.rs, audio_trace_multi.rs (needs LEGAIA_AUDIO_TRACE_JSONL_DIR).
How we know
| Function | Address | What it proves | Dump / probe |
|---|---|---|---|
Sound init / .dpk loader | FUN_8001FA88 | bse.dat at battle init (caller FUN_800513F0), dev / retail split on _DAT_8007B8C2 | ghidra/scripts/funcs/8001fa88.txt |
| Streaming-asset loader | FUN_8001FC00 | The sound\ path builder every bank install goes through | 8001fc00.txt |
| Bank installer | FUN_8001E54C / FUN_8002630C | Category to mixer record to slot; open through SsVabOpenHead | 8001e54c.txt, 8002630c.txt |
| Record initialiser / SPU bases | FUN_8001D424 / FUN_800265E8 | Twelve records, four pairs sharing a header and a base | 8001d424.txt, 800265e8.txt |
| Monster bank loader | FUN_8003E104 | monster.snd TOC and LBA range; slots 7 / 8 | 8003e104.txt |
| Per-actor SFX helper | FUN_800250D4 / FUN_80065034 | Descriptor decode; program-change before program lookup | 800250d4.txt, 80065034.txt |
| BGM poller | FUN_800243F0 | Resolver arithmetic, the seven-stage swap ladder, sound-flag bits | 800243f0.txt, 800266e0.txt, 80026520.txt |
Op 0x35 | FUN_801DE840 case 0x35 | Sub-op semantics 1 / 2 / 3 / 4 / 6 / 9 / 0xA | script VM |
| Arts shout selector | FUN_8004C140 / FUN_8003D53C | Channel pool pick; CD-XA clip player | 8004c140.txt, 8003d53c.txt; PCSX-Redux call-site traces |
| Melee roll pair | FUN_801EC3E4 | The grunt and the 0x10C impact cue | overlay_battle_action_801ec3e4.txt |
| Victory spoils | FUN_8004E568 | Pose tier, clip byte, reward bank slot 11 | 8004e568.txt |
| Reverb | SPU register file | Studio C in every captured state | mednafen-state spu sweep |
What's left
- The
bse.datbattle bank: header word, record stride and the record columns (the SFX-table columns;+4is the category byte the cue routerFUN_8004FE5Crewrites per cue throughgp+0x678) are pinned (sound-driver outputs). - The melee grunt and impact cue in the engine: an
XA30bank at boot and cue emission from the live battle loop. - The
.MAP/.PCHPsyQ SoundArtist intermediates are dev-side inputs, not separate retail chunks; nothing to port.