Why your MIDI tool chokes

Two retail-vs-spec divergences catch every converter that treats SEQ as “MIDI with a different header”:

  1. Legaia's header has a u32 big-endian version field where the PsyQ documentation says u16, shifting every later field by two bytes. A parser using the documented 13-byte layout reads the wrong PPQN and tempo.
  2. PSX SEQ meta events carry no MIDI length byte. A Standard MIDI File writes a tempo change as FF 51 03 tt tt tt; SEQ writes FF 51 tt tt tt. Reading a phantom length byte silently drops the first tempo override - and because every retail track ships a 240 BPM placeholder header tempo that the first body event corrects, the audible symptom is everything plays ~3x too fast.

Both are detailed below. The format is a publicly-documented PsyQ SDK shape (header layout + event encoding); this page describes byte order, the meaning of every header field, and how Sony's SsAPI sequencer consumes the event stream - no Sony bytes appear here.

Event stream

Each event is a delta-time (variable-length integer) followed by a status byte and zero or more data bytes - the standard MIDI encoding. Running status applies: if the first byte of an event is < 0x80, reuse the previous status byte and treat that byte as data.

Status range Event Data bytes
0x80..=0x8F Note Off 2 (key, velocity)
0x90..=0x9F Note On 2 (key, velocity) - velocity == 0 ≡ NoteOff
0xA0..=0xAF Poly Aftertouch 2
0xB0..=0xBF Control Change 2 (controller, value)
0xC0..=0xCF Program Change 1 (program)
0xD0..=0xDF Channel Aftertouch 1
0xE0..=0xEF Pitch Bend 2 (LSB, MSB; both 7-bit)
0xFF NN Meta event fixed length per type (see below)

Channel index is the low nibble of the status byte (0..=15). Retail data only uses 0x90 / 0xB0 / 0xC0 / 0xE0 (Note Off is 0x90 with velocity == 0).

A disc-wide sweep of every SEQ-bearing PROT entry (numbered entry in PROT.DAT, the disc's single big archive) pins exactly what the score emits, and the engine Sequencer acts on all of it: pitch bend (0xE0) is used heavily (thousands of events across a handful of music banks) and is applied by scaling each sounding voice's pitch over the tone's own pbmin/pbmax range; channel volume (CC7) and pan (CC10) are the busiest controllers and are dynamic (the music swells and pans voices mid-note), re-derived per change from a channel-free per-note base. Channel/poly aftertouch (0xD0/0xA0) and expression/reverb controllers never appear, so the per-cue reverb-enable for BGM is not carried in the sequence data.

Variable-length quantity (VLQ)

A VLQ is a big-endian sequence of 7-bit groups; the high bit of each byte is 1 for "more bytes follow", 0 for the final group. Maximum 4 bytes per delta. SEQ uses VLQ for delta-times. See legaia_seq::read_vlq.

Meta events

PSX SEQ meta events have no MIDI variable-length length field. This is the one place the format diverges sharply from a Standard MIDI File: the SsAPI sequencer reads a meta-type byte and then a fixed number of payload bytes determined by the type. The two meta types that appear in retail data:

Kind Bytes after type Meaning
0x51 3 Set Tempo (u24 BE microseconds per quarter note). The 3 tempo bytes follow 0x51 directly - there is no 0x03 length prefix. An SMF would write FF 51 03 tt tt tt; PSX SEQ writes FF 51 tt tt tt.
0x2F 0 End-of-Track. Two bytes total (FF 2F), no 0x00 payload. Required; terminates parsing.

Any other meta type has an undefined fixed length, so the parser cannot safely skip it and stops the track there (the reference SsAPI reader behaves the same way).

The tempo gotcha. Reading a phantom MIDI length byte mis-decodes every tempo event: 0x51 would consume the first tempo byte as a "length", then swallow the following note events as a bogus payload, and the override would be dropped. Retail tracks ship a 240 BPM (250000 µs/qn) init-placeholder header tempo that the first body 0xFF 0x51 event immediately overrides to the real musical tempo (e.g. FF 51 0B 71 B0 = 750000 µs/qn = 80 BPM). Dropping that override pins playback at the 240 BPM placeholder - a constant ~3x-too-fast rate. Every retail SEQ has ppqn = 480.

Loop markers

PSX SEQ encodes looping through NRPN-style control changes on 0xB0: controller 0x63 (99) value 20 = Loop Start, value 30 = Loop Forever (jump back to the last Loop Start). 88 of 92 retail SEQ tracks carry these markers.

The parser surfaces them as ordinary ControlChange events (the bytes really are a CC), and the engine Sequencer interprets them at playback time: a Loop Start records the position immediately after the marker, and a later Loop Forever - or an end-of-track that follows a Loop Start - rewinds there rather than to event 0. The rewind lands on the event after the marker, so it neither re-fires the marker nor re-applies its delta, and the integer sample-clock is reset so the looped body re-fires on the same sample offset every pass. Sequencer::set_loop_to remains an external fallback for the four tracks that carry no markers.

Tempo math

tempo is microseconds per quarter note; ppqn is ticks per quarter note (always 480 in retail data). Per-tick duration is tempo / ppqn microseconds, and the runtime accumulates real-world time against this rate. A mid-stream SetTempo overrides for future events only - events that already fired at the previous tempo are unaffected.

legaia_seq::us_per_tick(tempo, ppqn) returns the per-tick duration as f64 for inspection. The engine playback clock (Sequencer) does not use this float: it accumulates time as an exact integer in units of sample × ppqn × 1_000_000 and fires an event of delta d ticks once the accumulator reaches d × tempo_us × 44100, which keeps every term integer and the timebase free of long-track drift.

Where the data lives

SEQ payloads are loaded by the PsyQ libsnd SsSeqOpen family - see subsystems/audio.md → "Public SEQ API". On-disc, SEQ data lives inside the same scene-VAB-prefixed streaming containers described in scene-bundles.md - which means the SEQ bytes sit at a non-zero offset behind a wrapper, not at the start of the file. The _DAT_8007BAC8 slot the field VM writes (opcode 0x35) is consumed by FUN_800243F0, which resolves a SEQ payload through the CDNAME per-scene block and hands it to FUN_80062340 (SsSeqOpen) for playback.

Tooling

crates/seq (binary seq) parses SEQ files end-to-end:

seq info    <PATH>    # header summary + event-type histogram
seq events  <PATH>    # disassemble every event in source order
seq json    <PATH>    # full parse as JSON

Playback is the engine side: legaia_engine_audio::Sequencer consumes a parsed Seq + a loaded VabBank and drives the clean-room SPU model. See docs/subsystems/audio.md → "Engine-audio model".

See also