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 usually wrapped. The dominant carrier is the scene streaming shape: a 4-byte chunk header, the VAB body, a second chunk header, then the SEQ.
crates/vab::parse_header(buf, offset)takes a starting offset for that reason. - 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.
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x00 | 4 | magic | VABp |
+0x04 | u32 | version | Bank format version |
+0x08 | u32 | id | Bank id |
+0x0C | u32 | size | Whole-bank byte size |
+0x12 | u16 | ps | Number of used programs = number of tone pages that follow |
+0x14 | u16 | ts | Total tone count |
+0x16 | u16 | vs | Number of VAG sample bodies |
+0x18 | u8 × 4 | master vol / pan, attr | Bank-wide mix defaults |
+0x20 | 128 × 16 | ProgAtr[128] | One slot per program number: tones byte, vol, pan, and reserved words retail reuses at open time |
+0x820 | ps × 16 × 32 | VagAtr pages | Packed: one 16-tone page per used program, in slot order |
| after pages | 256 × u16 | vag_size[] | 1-indexed sizes in 8-byte units; [0] is the spacer |
| after table | - | VAG bodies | SPU-ADPCM sample data, back to back |
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. - The engine expands pages into slot space at upload (
VabBank::upload).
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 engine reproduces it (real_seq_program_change_coverage.rs pins the census). A change past the last used slot reads garbage beyond the tone region in retail; the engine 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.
History: the "three multi-bank archives" and the 1191-header count
Counts of 1191 VABp headers across 239 entries, with 0889 / 0890_sound_data2 as multi-bank archives, came from the superseded over-read entry window (prot § History), which spans those two small entries into 0891's 6 MB archive. On their own sectors they hold one and zero banks; exactly one multi-bank archive exists. See do-not-re-walk.
How we know
| Function / test | Address | What it proves | Source |
|---|---|---|---|
SsVabOpenHead | FUN_80068D94 | Rank map built at open into ProgAtr+8; VAG SPU address into +0xC/+0xE | ghidra/scripts/funcs/80068d94.txt |
| Program-change handler | FUN_80068B98 | Reads the rank byte back as the tone-page index; the unused-slot alias | ghidra/scripts/funcs/80068b98.txt |
real_vab_program_mapping | - | Sparse program sets across the corpus; slot-space expansion is required | crates/engine-audio/tests/ (disc-gated) |
real_vab_tone_attributes | - | Which tone fields retail populates; vibrato / portamento always zero | crates/engine-audio/tests/ (disc-gated) |
corpus_vag_spacer | - | vag_table[0] == 0 in 424 / 424 banks | crates/vab/tests/ (disc-gated) |
real_seq_program_change_coverage | - | Census of program changes that hit unused slots in real BGM | crates/engine-audio/tests/ (disc-gated) |
Full spec with every field: docs/formats/vab.md.
API
use legaia_vab::parse_header;
let header = parse_header(buf, offset)?;
println!("VAB v{} ps={} ts={}", header.version, header.ps, header.ts);
For bulk extraction of every VAB and per-program WAV files, see the vab CLI in extraction tooling. The decoder shares its F0/F1 filter constants with crates/xa.