At a glance

Magic
VABp at 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.DAT entries. The vab_01 cluster (extraction 1072..1194) holds one bank per entry; 0891_level_up is 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 side engine-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 VABp magic 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, 0 in 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 its center key an octave high. A port that also scales by 22050/44100 plays everything an octave low.
How a music bank sits inside a scene streaming container +0 chunk hdr 4 bytes +4 = VAB offset 0 VAB bank VABp - ProgAtr - tone pages - sizes - VAG bodies chunk hdr 4 bytes SEQ track pQES - events - FF 2F [chunk header][VAB][chunk header][SEQ] - parse_header(buf, 4) skips the wrapper.
Most retail BGM is a wrapped pair - bank first, sequence second - inside a scene's streaming entry. 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.

OffsetSizeFieldMeaning
+0x004magicVABp
+0x04u32versionBank format version
+0x08u32idBank id
+0x0Cu32sizeWhole-bank byte size
+0x12u16psNumber of used programs = number of tone pages that follow
+0x14u16tsTotal tone count
+0x16u16vsNumber of VAG sample bodies
+0x18u8 × 4master vol / pan, attrBank-wide mix defaults
+0x20128 × 16ProgAtr[128]One slot per program number: tones byte, vol, pan, and reserved words retail reuses at open time
+0x820ps × 16 × 32VagAtr pagesPacked: one 16-tone page per used program, in slot order
after pages256 × u16vag_size[]1-indexed sizes in 8-byte units; [0] is the spacer
after table-VAG bodiesSPU-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 +8 reserved 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 >> 3 into the +0xC / +0xE reserved slots.
  • 66 of the 218 wrapped banks - 43 of the 77 music_01 banks - 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:

FieldRole in playbackRetail data
vol / panPer-tone mixUsed
center / shiftKey to pitch - the whole of it, sample rate includedUsed
min / maxNote range that selects the toneUsed
adsr1 / adsr2EnvelopeUsed
pbmin / pbmaxPitch-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 / vibtVibratoAlways zero - no LFO needed
porw / portPortamentoAlways 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 / testAddressWhat it provesSource
SsVabOpenHeadFUN_80068D94Rank map built at open into ProgAtr+8; VAG SPU address into +0xC/+0xEghidra/scripts/funcs/80068d94.txt
Program-change handlerFUN_80068B98Reads the rank byte back as the tone-page index; the unused-slot aliasghidra/scripts/funcs/80068b98.txt
real_vab_program_mapping-Sparse program sets across the corpus; slot-space expansion is requiredcrates/engine-audio/tests/ (disc-gated)
real_vab_tone_attributes-Which tone fields retail populates; vibrato / portamento always zerocrates/engine-audio/tests/ (disc-gated)
corpus_vag_spacer-vag_table[0] == 0 in 424 / 424 bankscrates/vab/tests/ (disc-gated)
real_seq_program_change_coverage-Census of program changes that hit unused slots in real BGMcrates/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.

See also