VAB sound bank Confirmed
Every instrument you hear in Legaia's music - the strings under the world map, the drums in a battle theme - and every sound effect is a sampled sound stored in a VAB bank. A VAB is Sony's standard PlayStation instrument bank: a set of programs (instruments) made of tones (key-range variants) that point into compressed sample bodies the sound chip plays. The bank is one half of the music; the note data that plays against it is the other half, the SEQ.
At a glance
- Magic
VABpat the bank's own offset 0 - usually not the PROT entry's offset 0 (see the wrapper figure)- Where on disc
- 424 banks across 219
PROT.DATentries. Thevab_01cluster (extraction 1072..1194) holds one bank per entry;0891_level_upis the single multi-bank archive (206 banks); most music banks ride inside scene streaming containers - Stride
- 32-byte header, 16-byte program slots, 32-byte tones, 8-byte-unit sample sizes
- Parser
crates/vab(header + extractor + SPU-ADPCM decoder); playback sideengine-audio::VabBank- Confidence
- Confirmed - standard Sony layout; the Legaia-specific rules are pinned by the bank-open and program-change routines (how we know) and disc-wide corpus tests
- Used by
- Audio stack, Media player, Asset viewer
What trips up a stock VAB reader
The format itself is documented in the PsyQ SDK, so this page covers only the parts that are Legaia-specific - the things that make a generic VAB extractor return nonsense on this disc.
- The bank is wrapped and split. The dominant carrier is the scene streaming shape, and the bank is two chunks of it: the header tables are chunk 0's payload and the sample bodies are a later chunk's, so a chunk header sits inside the bank.
crates/vab::parse_header(buf, offset)takes a starting offset for the first part;vag_body_originfinds the second. - Don't trust the filename. Extraction labels carry the +2 CDNAME numbering shift; a block name is a hint. Trust the
VABpmagic and the loader constant. - Program numbers are not tone-page indices. Tone pages are packed by used program, so a program finds its page by rank. This is the single most consequential fact on this page.
- The VAG size table is 1-indexed. Slot 0 is a spacer,
0in every retail bank; it is not a pitch or sample-rate field. - Sample rate lives in
center. A 22.05 kHz body is authored with itscenterkey an octave high. A port that also scales by 22050/44100 plays everything an octave low.
SceneAssets::seq_in_stream_entries and bgm_seq_offset slice past the wrappers.Layout
A bank is a fixed 32-byte header, a 128-slot program table, a packed run of 16-tone pages, a 256-entry sample-size table, and then the sample bodies.
Bank header (32 bytes)
ps is the number of used programs (and so of tone pages), ts the total tone count, vs the number of sample bodies.
Whole bank
Each ProgAtr slot carries a tones byte, volume, pan and reserved words retail reuses at open time. The pages are packed, one per used program, in slot order. Sizes are in 8-byte units with [0] a spacer. The header part is 0x20 + 0x800 + 0x200·ps + 0x200 bytes; the bodies follow in a different chunk, so on disc a 4-byte chunk header sits between the size table and the first sample block.
Finding the sample bodies
The body chunk carries no magic. It is identified by length: chunk 0's payload is exactly the header part, and header_part_len + body_len == fsize. Its type byte is 0x01 in almost every bank and 0x03 in ten.
- A reader that walks straight on from the size table lands on the body chunk's header, four bytes early. The SPU-ADPCM grid shows it: a block's high nibble is a filter index and only
0..=4is legal, and the grid four bytes on is 100% legal where the naive origin scores about chance. - Six entries (
0886,1058,1059,1063,1064,1065) put the SEQ chunk before the body chunk, so the naive origin is thousands of bytes off and no small alignment probe recovers it. Anything that indexes a body out of a stream entry has to walk the chunks.
In the port, crates/vab has three entry points and picking the wrong one is a silent defect: parse for a bank at a known offset (its body spans are the chunk-header address), parse_in_stream for a bank inside a DATA_FIELD stream (spans already resolved), and vag_body_origin_at for the body chunk alone, walking forward from the end of the header part.
The one multi-bank archive
PROT extraction 0891 holds 206 banks - one per monster sound-effect set - in a single 5.7 MB entry that the game never loads whole. Its first sector is the index:
Each word is a bank's first sector relative to the entry; the extra final word is the archive's own sector count.
Bank i runs from start_sector[i] to start_sector[i+1], so the table is bounded by its successor exactly the way a PROT TOC entry is. Each bank is a two-chunk stream in the shape above, a zero terminator, then sector padding the builder left its previous buffer contents in.
The reader is FUN_8003E104: it bounds the bank index against the count word, subtracts the two sector bounds and shifts both left by 11. Its callers pass monster_id - 1. The index is resident at 0x801C8980 because the boot image (PROT 0895) copies 0x400 bytes of the entry's first sector there at start-up.
Program slots vs packed tone pages
A SEQ ProgramChange (or an SFX descriptor) names a program number, which indexes the 128-slot ProgAtr table directly. The tone pages that follow are packed: one 16-tone page per program whose tones byte is non-zero, ps pages in total, in slot order. A program finds its page by its rank among the used slots, not by its own number.
- Retail builds the rank map once, at bank open: the open routine writes the running used-program count into each slot's
+8reserved word, and the program-change handler reads it back as the page index (how we know). - The open also stashes each VAG's SPU address
>> 3into the+0xC/+0xEreserved slots. - 66 of the 218 wrapped banks - 43 of the 77
music_01banks - use sparse program sets, so indexing pages by the raw program number mis-tones or silently drops most of their instruments. - In the port,
engine-audio::VabBank::uploadexpands pages into slot space.
The unused-slot alias
Retail stores the rank counter before the used check increments it, so a program-change to an unused slot borrows the next used slot's page while keeping its own mvol/mpan. Real BGM exercises this - music_01 PROT 868 program 5 and PROT 996 program 19 select gap slots that play through the alias - and the port reproduces it. A change past the last used slot reads garbage beyond the tone region in retail; the port leaves those voices silent.
Tone attributes: which fields retail uses
Each 32-byte tone (VagAtr) carries the standard Sony fields. A disc-wide census of every tone fixes which of them the retail data populates:
| Field | Role in playback | Retail data |
|---|---|---|
vol / pan | Per-tone mix | Used |
center / shift | Key to pitch - the whole of it, sample rate included | Used |
min / max | Note range that selects the tone | Used |
adsr1 / adsr2 | Envelope | Used |
pbmin / pbmax | Pitch-bend range in semitones; the sequencer scales a 0xEn wheel event by the sounding tone's range (VabBank::pitch_bend_range) | Mostly 2 (GM default); a few tones at 4 / 12 / 24 / 40; (0, 0) does not bend |
vibw / vibt | Vibrato | Always zero - no LFO needed |
porw / port | Portamento | Always zero |
center / shift carry the sample rate
center is the key at which the tone plays at unity - SPU pitch 0x1000, 44.1 kHz - and shift raises it by shift/128 of a semitone. The header has no per-sample rate, so a 22.05 kHz body is authored with center twelve semitones above the key it should sound at. Full key-on law: audio § key-on pitch law. crates/vab's WAV writer hard-codes 22050 for standalone extraction, a separate question from playback.
The VAG size table is 1-indexed
The trailing table is 256 × u16; vag_table[1..=vs] hold each sample's size in 8-byte units, so vag_table[0] is a leading spacer. It is 0 in all 424 retail banks, and no pitch offset is derived from it - VabReport::vag_table_spacer surfaces the raw byte only.
How we know
Routines and corpus checks
| Evidence | What it proves |
|---|---|
SsVabOpenHead, FUN_80068D94 (ghidra/scripts/funcs/80068d94.txt) | Rank map built at open into ProgAtr+8; VAG SPU address into +0xC/+0xE |
Program change, FUN_80068B98 (ghidra/scripts/funcs/80068b98.txt) | Reads the rank byte back as the tone-page index; the unused-slot alias |
Multi-bank reader, FUN_8003E104 (ghidra/scripts/funcs/8003e104.txt) | The 206-entry sector index: bound, table[i] / table[i+1], << 11 |
| Disc-wide corpus census | Sparse program sets; which tone fields are populated; vag_table[0] == 0 in 424 / 424 banks; chunk 0 is the header part in every bank and the chunk-derived body origin is the only one with a legal ADPCM grid |
Full spec with every field: docs/formats/vab.md. For bulk extraction of every bank to per-program WAVs, see the vab CLI in extraction tooling.