At a glance

In the game
Cutscene dialogue and FMV audio, streamed music tracks, and the per-character Art shouts in battle (Vahn's file is XA2, Noa's XA4, Gala's XA6).
Magic / marker
No file magic - per-sector CD-XA subheader with submode AUDIO | FORM2 (0x04 | 0x20)
Lives in
Top-level disc files XA1.XA, XA2.XA, ... (one or more per release) - not inside PROT.DAT; each file multiplexes up to 16 channels keyed by (file_no, ch_no)
Parser
crates/xa - ADPCM decoder (lib.rs), sector demuxer (demux.rs), WAV export; CLI xa demux-disc-all
Confidence
Confirmed - the decoder is bit-exact, sample for sample, against an external lossless reference decode of a real cutscene track.

Sector layout

A CD stores 2352 bytes per sector. Data files use 2048 of them and spend the rest on error correction; streaming audio (Mode 2 Form 2) trades most of that correction away for 276 more bytes of payload per sector, because a click in an audio stream matters less than a corrupted byte in a program. Each on-disc XA sector is the standard PSX raw layout:

+0x000sync12 B
+0x00CMM SS FF mode4 B
+0x010XA subheader8 B
+0x01818 sound groups × 128 B2304 B
+0x918pad20 B
+0x92CEDC4 B

The sync is 00 + 10 × FF + 00; the header's mode byte is 2. The Form 2 payload is 2324 bytes, of which the last 20 are padding. The subheader is four bytes, stored twice:

+0x10file_nou8
+0x11ch_nou8
+0x12submodeu8
+0x13coding_infou8
+0x14copy of the same four4 B
Consecutive sectors of one XA file belong to different channels; the demuxer sorts them by (file_no, ch_no) into per-channel streams disc sectors (2352 B each) ch 0 ch 1 ch 2 ... ch 0 ch 1 ch 2 ... channel 0 stream 2304 B 2304 B ... each 2304 B payload = 18 sound groups x 128 B; a group decodes to 224 samples (4-bit)
The interleave is what lets the drive stream several voice channels at a fixed read speed; it also means a naive 2048-byte file copy shreds the audio (see the history note under Demuxing).

Subheader bits

Submode bits relevant for audio detection:

bitmeaning
0x04AUDIO
0x20FORM2

Coding-info bits:

bitsmeaning
0 (0x01)stereo (vs mono)
2..=3sample rate (00 = 37.8 kHz, 01 = 18.9 kHz)
4..=5bits/sample (00 = 4-bit, 01 = 8-bit)

The 18 sound groups inside the user data are 128-byte CD-XA ADPCM blocks; see the lib doc-comment for the per-block layout (8 sound units, 28 lines x 4 bytes).

Demuxing: getting the tracks out

Each .XA file is really many audio channels multiplexed together, one channel per sector, tagged by the per-sector (file_no, ch_no) subheader. crates/xa/src/demux.rs (demux_disc_range) reads raw 2352-byte sectors, parses each subheader, filters to AUDIO + FORM2, and splits the audio data into one buffer per (file_no, ch_no) tuple. After that step the per-channel buffer is a clean concatenation of standard 128-byte sound groups that the ADPCM decoder handles directly.

The xa demux-disc-all subcommand drives this across the whole disc - it walks the ISO9660 tree, finds every *.XA, and demuxes each at its own per-sector sample rate / channel mode read from the subheaders (no guessed global rate). This is the “get the voices and streamed music out” command:

xa demux-disc-all "Legend of Legaia (USA).bin" --out extracted/xa_demux

One WAV lands per (file_no, ch_no) channel under extracted/xa_demux/, named <xa-stem>_fileN_chM.wav. The single-file xa demux-disc --lba --size variant remains for targeting one entry by directory offset. legaia-extract runs the demux automatically and writes the WAVs to extracted/XA_WAV/.

Pacing is data-driven per track - the whole point. A track that varies channel mode (the NA disc has 16-channel mono voice files like XA4/XA6 alongside 8-channel stereo music like XA5/XA7/XA8/XA9) decodes each channel at its real width. Channels report their width via coding_info; the decoder handles both 4-bit and 8-bit, and any other (unexpected) width is skipped with a warning rather than mis-decoded.

Sound-group decode (4-bit)

Each 128-byte sound group holds 8 sound units of 28 samples. The decode is bit-exact against an external lossless reference decode of a real cutscene track (every interleaved sample matches), so the layout below is confirmed, not inferred.

OffsetSizeFieldMeaning
+016parameter bytesOne (filter << 4) | range byte per sound unit, filter 0..=3, range 0..=12, with the redundant copy interleaved within each half (layout below)
+16112sample nibbles28 lines x 4 bytes; unit u reads byte u / 2 of each line, low nibble for even u, high nibble for odd

Parameter bytes (0..16). The redundant copy is interleaved within each half, not appended:

byte0123456789101112131415
unitp0p1p2p3p0p1p2p3p4p5p6p7p4p5p6p7

So unit u's parameter byte is at u + (if u < 4 { 0 } else { 4 }). Reading bytes 0..8 as eight sequential params - the "appended mirror" reading - mis-assigns the parameters of units 4..7 and is the classic CD-XA decode trap.

Sample nibbles (16..128). 28 lines of 4 bytes:

line byte0123
low nibbleunit 0unit 2unit 4unit 6
high nibbleunit 1unit 3unit 5unit 7

Per-sample reconstruction (filter coefficients in 1/64 units, k0 = {0, 0.9375, 1.796875, 1.53125}, k1 = {0, 0, -0.8125, -0.859375}):

shifted = (sign_extend(nibble, 4) << 12) >> range
value   = shifted + k0 * prev1 + k1 * prev2
output  = clip16(round_half_away_from_zero(value))
prev2   = prev1;  prev1 = value     // history is the UNCLAMPED, UNROUNDED value

The predictor history is the full-precision reconstructed value, not the rounded+clamped 16-bit output. Re-feeding the clamped output instead is audible only at high volume - the prediction drifts on loud sound-units and rails to the opposite extreme - which is exactly the symptom that bit-exact history feedback removes.

Stereo de-interleave. The LEFT channel is the even units (0,2,4,6) and the RIGHT channel is the odd units (1,3,5,7); output is L,R interleaved, pairing (0,1),(2,3),(4,5),(6,7). Each channel keeps its own (prev1, prev2) history.

Sound-group decode (8-bit)

The 8-bit mode (subheader coding_info bits 4..5 = 01) uses the same 128-byte group but packs 4 sound units of 28 full-byte samples instead of 8 nibble units:

  • Parameter bytes (0..16). The 4 unit params live at bytes 0..4, mirrored three more times at 4..8 / 8..12 / 12..16; the live copy is bytes 0..4. Same (filter << 4) | range encoding.
  • Sample bytes (16..128). 28 lines of 4 bytes; unit u reads byte u of each line (one full signed 8-bit sample per byte, 0x80 = -128).
  • Reconstruction. Identical filter math, but the sample sits in the top byte of the 16-bit word before the gain shift: shifted = (sign_extend(byte, 8) << 8) >> range.
  • Stereo de-interleave. LEFT = even units (0,2), RIGHT = odd units (1,3).

This yields 112 samples/group vs the 4-bit path's 224. Select it with legaia_xa::DecodeOptions { bits: BitsPerSample::Eight, .. } (the demux path maps each channel's reported width automatically; the CLI exposes --bits 8). The whole NA corpus is 4-bit, so 4-bit is the default.

Scope limits

  • 8-bit ADPCM has no disc to test against. Every channel of every *.XA file on the NA disc is 4-bit at 37.8 kHz (demux-disc-all reports bits_per_sample = 4 throughout), so the 8-bit path is covered by synthetic tests only and is not verified bit-exact against a real 8-bit source. A build that uses 8-bit would decode without code changes.
  • Which event plays which channel is not part of this format. demux-disc emits one WAV per channel keyed by (file_no, ch_no). The routing is game logic: the voice-cue dispatchers and cue tables on the audio page and the movie channel selection on the cutscene page. The Art-shout side is in crates/art's arts_voice tables.

How we know

Sources
SubjectSource
Mode 2 / Form 2 sector layoutPSX BIOS docs + legaia-iso::raw
Subheader interpretationcrates/xa/src/demux.rs
4-bit ADPCM filter coefficientscrates/xa/src/lib.rs
Sound-group decode (param + nibble layout, predictor)bit-exact, sample-for-sample, against an external lossless reference decode of a real cutscene track.
Form-1-truncation diagnosisdirect comparison: 90 % of 128-byte groups in the Form-1-truncated extracted/XA/*.XA bytes fail the bytes 8..16 == bytes 0..8 invariant.

See also