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 are streamed straight off the CD. This page follows the three roads; the library catalogues and the port's audio model are on audio internals.
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, on every voice
- 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; a descriptor's category byte names the VAB slot the cue plays from - Voices
- CD-XA streams: arts shouts from
XA2 / XA4 / XA6, the blocked-strike grunt fromXA30, victory lines from the tail ofmonster.snd - Libraries
- Sony's PsyQ libsnd (sequencer,
0x80061..0x80067) and libspu (0x80068..0x8006D), statically linked and traceable - In the port
crates/engine-audio: a from-scratch SPU, a sample-locked sequencer, the SFX bank, XA mixing; a WebAudio backend for the browser
Three roads to the speaker
The SPU cannot be driven from main RAM directly; games go through Sony's libsnd (sequencer playback) and libspu (voices and registers). Legaia links both statically, so they are part of the game binary and can be traced and named like any other code. Legaia's own layer above them is small: two loaders, one bank installer, one SFX helper, one BGM poller and one CD-XA clip player.
Music: which track plays
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 a per-frame poller turns that id into a disc entry by arithmetic - the archive is the table.
- Global ids (≥ 2000) are offsets into the shared
music_01bank: id2000 + iis sound-test tracki, loaded from extraction entry988 + i. Scene-local ids (< 2000) only feed the “did the track change” test - the load itself is overwritten with a global slot, so every scene that starts music picks a global track. - The curated entry map is piecewise, the resolver is not. The curated list puts track
iat extraction988 + ifori ≤ 67and990 + iabove, skipping 1056 / 1057; the resolver loads988 + ithroughout, so from track 68 the two disagree (music tracks). - Each track carries its own instruments. An entry is a chunk header, a
pBAVbank, then apQESscore; playing it uploads that entry's VAB. A scene's own sound stream is never its music. - Re-selecting the playing track is a no-op, so walking between two rooms that name the same theme never restarts it.
Changing track on cue
A cutscene can choose the exact beat the outgoing score dies on. The poller walks a short ladder - wait for the CD, let the load settle for 30 frames, tear down the old track unless the script owns the swap, load, install the score, latch - and coordinates with the script through one sound-flag word. A swap started by sub-op 9 keeps the old track playing until the script commits it with sub-op 0xA. The flag bits are on internals.
- Stop, pause, replay. Sub-op 2 stops the track (notes cut, cursor back to the start), sub-op 3 pauses it (the sequence’s volume goes to
0first, so its notes cut at once, then they key off; cursor kept) and sub-op 4 replays it from its first event. - Hard cuts. Retail never cross-fades between tracks: the old sequence is keyed off (notes release through their own envelopes) and the new one starts that instant.
- Movies leave the score alone. The movie player never touches the sequencer: a score the script left sounding keeps playing under the movie's audio, and one it stopped stays silent. Only the title attract stops the title theme, and the title screen restarts it from the top.
- The silent dawn.
town01starts its theme and, while a story flag is clear, pauses it at once; the opening cutscene's own sub-op 9 brings it in.
Sound effects
A menu blip, a chest, a sword clang: each is one 8-byte descriptor, and the actor or script that wants the sound just names its id. The static table holds 100 descriptors; ids at or above 0x200 come from a runtime bank instead - in battle, bse.dat.
- Trigger. Move-VM and field-VM opcodes write a sound id and a voice into the actor; the actor tick fires the SFX helper on a one-shot pulse or a staged continuous cue, keying one voice per descriptor count.
- The cue names its own bank. Before looking the program up, the voice programmer switches libsnd's current bank to the slot the category byte names - so a cue never plays out of the scene's music bank. Layout and the category → slot → PROT map: sfx table.
- Every battle command press blips. The command flow rings its cues through the dispatcher
FUN_8004FCC8, which storesid - 1for an id below0x40: a commit, a cursor step and a cancel or refused arm each key one descriptor lower than the id the flow names. The item and magic windows are list windows, and the shared list kernel rings their cues itself. The port derives one blip a frame from each surface’s state before and after its input, and both hosts drain it through the session’s audio director. - A sustained cue loops until something releases it. A descriptor with bit
0x20keys a looping sample on voices from 7 up, and nothing in the voice ends it. The teardownFUN_80017910keys those voices off; it runs at every mode entry, at a battle actor’s clip commit when the clip changes, in the defeat fade of a monster seat, and from the sound test’s “stop all”. So a monster’s looping attack sound ends on the frame its next clip commits. The port raises the release in call order with the cue writes, so it runs ahead of that frame’s new cues.
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.
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 slot. One installer opens every bank; its argument is the same category byte the SFX descriptors carry.
| Slot | Bank | Installed by |
|---|---|---|
| 0 | PROT 0868 system bank (the shared UI cues) | resident from boot |
| 1 | the current track's own VAB (music_01) | the BGM poller |
| 2 | PROT 0869 battle class-2 bank (0875 with Terra on party seat 1); a minigame's own bank in fishing, slots and dance | battle loader, minigame inits |
| 3 | a vab_01 side-band bank | 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 are one physical bank in two modes. They share a header buffer and an SPU address: the field init loads 0876 into slot 6, and battle init closes it before the battle loader fills slot 2. That is why retail needs no extra SPU room for field cues.
- Monster banks come from one packed file,
monster.snd, with a per-monster TOC of sector ranges. Its tail clips are the party's victory-pose voice lines: the spoils routine picks a pose tier from the actor's HP (a status forces the weakest pose) and plays that character's clip. - A side-band sample can outlive its bank’s place. Slot 3 has its own base on retail, so a one-shot it is still playing when the next track loads plays out. In the port a long track can overrun that span; a voice still reading it follows the bank onto its re-staged copy at the same offset, and a voice nothing re-stages is cut once the frame’s sound routing ends. Before that, a voice left on the old span read the new track’s instruments, and a looped one held for good: after the Caruban fight on Mt. Rikuroa players heard a horn loop over the scene.
Battle voices: shouts and grunts
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-character channel pool so it does not repeat back to back.
| Cue | Source | When |
|---|---|---|
| Arts shout | XA2.XA Vahn, XA4.XA Noa, XA6.XA Gala (16-channel mono banks) | as an art's staged animation starts; a second trigger fires from the animation's cue track mid-clip |
| Grunt | XA30 clip 0x1D, character-spaced channel | a melee strike the defender blocks; a strike that lands a flinch or knockdown is silent here |
| Impact sting | cue 0x10C through the cue router - an XA27 voice leg for a party target, an element-tinted ring for a monster | the same melee routine's second emission |
| Victory line | tail clips of monster.snd | the spoils screen (see banks) |
Cue tables: legaia_art::arts_voice. The CD contract shows up as two timing behaviours a port has to keep: the shout trails its clip install by roughly 150 ms, and a new request while a clip is sounding replaces it - there is one drive, and the shout site does not wait for it to be free.
CD-XA streaming 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.
Two stages sit between the decoded PCM and the speaker. The CD decoder converts 37.8 kHz to 44.1 kHz with a fixed 29-tap "zig-zag" filter (seven outputs per six inputs), which passes the band at about 0.906 and suppresses the image a linear interpolator would leave. Then the SPU scales the stream by its CD-input volume: every voice cue starts at register 0x75EA (libsnd level 117, about 0.921), set by the clip player's CD callback once the drive starts reading, while a movie plays at full scale. A cue does not keep that level through a change of the game's audio level: the frame driver hands each change to a routine that first resets the CD input to full scale, so a summon whose duck is still ramping when its voice starts plays the rest of the line at full CD volume. The port runs both stages and lifts a sounding clip on every level step.
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.
An enemy's swing already sounds as it should. Given the same swings and the same dice as a recording of the original, nearly every fight we measured puts each swing, block or flinch voice and impact sound in the same place. Two fights sound different because they deal different hands, not because a sound is wrong. Glare's landing sound and its Stone now come at the moment the stare lands, about two seconds before the cast ends. A heal's voice waits for the monster's idle animation to finish, so how soon you hear it depends on where that loop stood.
A summon's voice waits for its module. The line is called on one step of the cast, after the module has parked the drive and polled it, so Theeder's line comes about two seconds after the cast begins and Juggernaut's almost at once. That seek usually queues behind one the battle had just issued for the caster's fanfare, and the game loop stops until the earlier one ends; the port runs the drive on the same seek law, which brings Orb's and Swordie's lines within a few frames of retail's (the seek-then-poll sequence). The drive then takes its own time: under mednafen about 77 ms with the head already in the clip's file and 371 ms from the next file along, plus one sector for each channel the clip sits into the file. No console has been timed. Enemies with shared spell code pick their line by who is casting: only Zora speaks Zora's line over Glare. A module can also pick by its own branch: Call Wave speaks its own line, not Big Wave's, which shares its image.
What a cold scene entry owes
Music does not start because a scene loads. It starts because the scene's entry script reaches op 0x35, and the id it names reaches a director that knows the track carries its own instruments. Anything that drops the engine into a scene from cold - both play hosts and every headless driver - owes the same three steps in this order, and each one is separately load-bearing.
| Step | Kernel | What skipping it sounds like |
|---|---|---|
| 1. Free-roam story staging | World::seed_free_roam_story_baseline | The entry script's authored pause parks the track: a sequencer attaches on the first frame and its playhead never leaves tick 0 |
| 2. Live field entry | BootSession::enter_field_live | Loading the scene alone installs no field record, so the field VM steps nothing and op 0x35 never executes |
| 3. Global-pool start hook | BgmDirector::start_owned_vab | Every real music cue is a global id, so a director that leaves this hook on its no-op default plays nothing while looking fully wired |
All three failures read identically from outside - silence - which is why a driver missing any one of them reports as “the engine starts no music on scene entry”.
- Why step 1 exists. A scene-picker entry runs the entry script with none of the story choreography behind it. Rim Elm's script starts its theme and pauses it for the opening's silent dawn; without the cutscene that later restarts it, the pause would hold forever. The staging drops a pause issued inside the entry window on picker entries only - a new game keeps the authored pause.
- A story twin's flags pick its track too. Rim Elm under attack opens on the town theme and then, on one flag pair, switches to the attack theme over a night grade. A picker entry with neither flag staged would play the wrong tune, so the staging seeds the flag.
- Battle reuses the same road. An encounter queues an ordinary track start for the battle theme and the fight's end resumes the stashed field track - one dispatch path, no battle-only audio code. The theme arrives with the fight, not with the spin.
The battle music timeline
The spin into a fight is the cue alone, for over two seconds; the theme arrives with the fight. Logged per vsync from a walked retail fight (teien_field_run, a set-0 fight), counted from the mode-8 store:
At the fight’s end the battle sequence plays on, ramps down over 129 vsyncs while the map reads, and the field source restarts from its head before the first field frame. The port takes the opening as measured (World::silence_field_audio_for_intro, World::enter_battle_from_formation, both hosts through route_bgm_events). Its load is instant, so the 250 vsyncs of battle theme over retail’s loading screen collapse. A fight on the park word 0x1000 ends the music: the return loads what the word names, which is nothing, and both are silent until a scene starts a track.
The field script picks the fight's music
Which track a fight plays is the field script's choice, not the formation's. Op 0x35 sub-op 7 stores a battle sound set number, and the scene's map init zeroes it on every entry. The battle intro reads it: set N ≥ 0 loads battle bundle 0x36F + N and starts its score; set -1 loads nothing and leaves whatever the event was already playing. That is how a boss fight keeps the boss theme its cutscene started one line earlier. Each bundle of the battle bank carries a byte-identical copy of one music_01 score.
What pitch a note keys at
Both key-on paths - a sequencer note and a one-shot cue - converge on one arithmetic, and its result is written straight into the SPU voice's pitch register. Nothing rescales it afterwards.
n = note + 60 - center + carry
pitch = PITCH[(n % 12) * 16 + fine] shifted by (n / 12 - 5)
PITCH is a 192-entry table at DAT_8007A940 holding one octave at 1/16-semitone resolution; every entry is exactly floor(0x1000 × 2^(k/192)), so the port computes it instead of carrying it.
- A tone plays at unity on its own centre note.
note == centerselects0x1000, which is 44.1 kHz. There is no separate sample-rate factor: a 22.05 kHz sample is authored with its centre an octave above the key it should sound at, and the same law lands on0x800. Folding a rate ratio in as well puts every voice an octave low. - The tone's shift byte raises the pitch by
shift/128of a semitone, quantised to 1/16. It is a positive fine-tune, not a downward correction. - A cue keys half a semitone above a note. The sequencer path forms its fine index as
min(shift >> 3, 15)and never carries. The cue path uses(0x40 + shift) >> 3, where0x40is a literal at every traced call site, and carries a whole semitone at 16.
The law is measured as well as read: across the catalogued save states, 126 of 128 sounding voices hold exactly the pitch it computes from their staged note against the live bank. The two misses are voices whose bank was swapped after the key-on. Port: compute_pitch in engine-audio::vab_bind.
Three format traps that silence or speed up music
- A SEQ meta event has no length byte. Legaia's tempo meta is
0xFF 0x51plus three tempo bytes, and end-of-track is0xFF 0x2Fwith nothing after it. Reading the MIDI length byte that is not there drops the first tempo and pins playback about three times too fast against the header's 240 BPM placeholder (seq). - A meta is a running status. Retail latches
0xFF, so a data byte after a meta is the next meta's kind. One track's closing tempo depends on it. - A VAB arrives as two chunks. Its header and its sample bodies are separate chunks of one stream, and some entries put the score chunk first. Probing fixed offsets finds the wrong bytes; the body origin has to be resolved off the stream (vab).
Scripted field cues ride a four-slot ring
A door creak or a switch click placed by a scene script does not go through the per-actor helper. The field VM's op 0x36 and the motion VM's op 0x09 push the cue id onto a four-slot ring, with an optional countdown, and a drainer keys each one when its countdown ends.
- Two id ranges, two banks. A ring id below
0x200goes through the static table and the bank its category names. An id at or above0x200indexes the scene's own descriptor record and the side-band bank that record names - slot 3 in a town. - The field bank is only there in the field. Most static field cues are category 6, the field bank, which shares its SPU region with the battle bank in slot 2. A category-6 cue therefore keys in the field and is silent in battle, where retail's slot 6 is closed. The port refills that region per mode the same way.
- Placed sounds follow the camera. A town's river or waterwheel is not a ring cue but a placed emitter part with a box around it: each tick its volume is written straight to its voices, scaled by how deep inside the box the camera focus sits, and it is released when the focus leaves, or when the part's script halts and the actor walk frees it - which is how a scripted loop such as Octam's collapse rumble ends. Every field scene load also silences the cue voices, so a loop cannot follow the player through a door. The port runs the same arm on the same parts; every sounding cue voice in the save-state library, emitters included, matches register for register.
- Every scripted id on the disc resolves to a row of its own scene's bank, bar one that points one row past its table and keys nothing in retail either.
Is the port playing what retail plays?
The script's track word says which track was asked for. The sweep reads what the sound library is actually playing in every retail save state - which track each open sequence holds, whether it is playing, at what volume and tempo, and which sound effects ring beside it - then seeds the port into the same state and compares.
- What matches everywhere it can be compared: the field track, whether it is playing, sequence volume, tempo, and the battle track a fight picks from its sound set.
- A cutscene track change waits for its commit. Sub-op 9 holds the new track until the script's
0xAcommit, as retail does, rather than starting it at the select over the old score's timed fade. 91 of the disc's cutscene track changes use that shape. - A door's music is seeded through the door. A state reached through a door carries history a card load does not replay, so a scenario that names its door is seeded through it and the track is the engine's own door logic. The credits vignettes do the same on a scripted chain: their entries park, so what sounds is what the chain's earlier scenes started.
- What is left: one ambient row,
tower's lift cue. It sounded on a contact before the capture, and a seat does not replay that touch.
In the port
crates/engine-audio rebuilds the stack from this page, the libspu API surface and the public SPU register layout - no Sony bytes. It models the 24 voices with Gaussian resampling, the envelope, the ADPCM decoder, the reverb network (set to Studio C on every voice, as retail runs it) and a sequencer counted in whole output samples that spends retail's tick budget once per vsync period, so playback is locked to the audio clock, lands its key-ons where retail's do, and is bit-deterministic for replays. Both play hosts and the minigames page drive it through the same director (engine-session). Module map, the sequencer details and how the port's sound is measured against retail, down to real retail PCM note by note: internals.