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 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_origin finds the second.
  • 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 0 4 bytes +4 = VAB offset 0 VAB header part VABp - ProgAtr - tone pages - sizes chunk 1 4 bytes VAG bodies SPU-ADPCM, 16-byte blocks chunk 2 4 bytes SEQ track pQES - events - FF 2F chunk 0's length is the header part only, so the bank is not contiguous - the chunk-1 header lands between the size table and the first sample block.
Most retail BGM is a wrapped pair - bank first, sequence second - inside a scene's streaming entry, and the bank itself spans two of the entry's chunks. Chunk 0's payload length is exactly the header part, and the two payloads sum to the bank's declared size - which is how the body chunk is identified, since it carries no magic. Six entries put the SEQ chunk before the body chunk, so a reader that assumes the bodies come next lands thousands of bytes early. 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)

+0x00VABpmagic
+0x04versionu32
+0x08idu32
+0x0Csize (fsize)u32
+0x10-u16
+0x12psu16
+0x14tsu16
+0x16vsu16
+0x18master vol / pan, attrs8 B

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

+0x000header32 B
+0x020ProgAtr[128]16 B each
+0x820VagAtr pagesps × 16 × 32 B
after pagesvag_size[256]u16, 1-indexed
 chunk hdr4 B
next chunkVAG bodiesSPU-ADPCM

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..=4 is 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:

+0x00reserved = 0u32
+0x04count = 206u32
+0x08start_sector[count + 1]u32 each

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 +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.
  • In the port, engine-audio::VabBank::upload expands 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:

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.

How we know

Routines and corpus checks
EvidenceWhat 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 censusSparse 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.

See also