Xenon 2

How it works · write-up

Xenon 2: Megablast — Sound Driver Notes

xenondoc/SOUND.MD · 57 KB · updated 2026-09-17

This document summarizes what's been reverse-engineered about the sound driver embedded in the Atari ST game Xenon 2: Megablast. Like XENON2.MD, it's a snapshot of current understanding, not a spec — addresses and byte values below were read directly out of a captured ST memory/register snapshot (loaded in Ghidra as program /mydumpat0) using the ReVa MCP tools, and cross-checked where possible against the driver's own code. All function/global names below already exist as real Ghidra symbols/comments in that database (SoundDriver_*, PSG_*, PSGOverlayVoiceState, etc.) — this file is a write-up of that analysis, not new naming invented for the doc.

Everything here concerns the original 68000 game binary's sound engine, not Hatari's own PSG/DMA sound emulation (src/sound.c, src/psg.c) — those emulate the real YM2149 chip in general; this document is about how this specific game drives that chip.

The companion demo page is xenon2-static-song-player.html.

1. The big picture

The driver runs entirely inside the 50 Hz VBL tick. Every video frame, the main loop (around 0x2922-0x2990 in /mydumpat0) does two sound-related things right before drawing:

  1. Checks two 1-byte "mailboxes" — SoundCommand_MailboxA_0x414 and SoundCommand_MailboxB_0x418 — and if either holds a pending command (0x00-0x7F), calls SoundDriver_PlayCommand_PreserveRegisters_FUN_0002cf7a(cmd) and clears the mailbox back to 0xFF (empty).
  2. Calls SoundDriver_VBLTick_PreserveRegisters_FUN_0002cf50(), which ticks the whole sequencer and flushes new register values to the real YM2149.

Underneath that, the driver implements three independent audio mechanisms that share the same three PSG channels and get mixed together only at the very last step, when the final register image is written to hardware:

Mechanism What it is Driven by
Music A 3-channel tracker: patterns, per-channel instruments, arpeggio/volume automation, vibrato, portamento. This is the looping background tune. SoundDriver_Tick_MixChannelsAndFlushMixer_FUN_0002d0da's channel-step calls
Effects (SFX) Three short-lived "overlay voices" that borrow a PSG channel for a moment — tone/noise burst plus a linear volume-decay envelope — and take priority over whatever the music channel underneath was doing. SoundDriver_Tick_OverlayVoices_FUN_0002e100
Samples One resident 8-bit PCM clip, played back through a software DAC that spreads each 8-bit sample across all three PSG channels' volume registers for extra effective resolution, driven by a real MFP Timer D hardware interrupt (not the 50 Hz VBL). SoundDriver_TimerD_EnvelopeTick_WritePSGVolumes_FUN_0002e836

Music and SFX are both "sequenced" (stepped once per VBL tick, 50 Hz) and share the exact same 14-byte PSG register-shadow struct and hardware flush. Sample playback is different in kind: it stops the sequencer entirely (SoundDriver_StopAll_PreserveRegisters_FUN_0002cf88) and takes over all three PSG channels' volume registers at a much higher, independent interrupt rate. A sample and the tracker/SFX engine can never sound at the same time — starting one always stops the other.

2. Hardware summary

Standard Atari ST PSG (YM2149/AY-3-8910-compatible) wiring:

  • $FFFF8800 — PSG register-select (write register index 0-13).
  • $FFFF8802 — PSG data (write value to the selected register). The driver's PCM DAC ISR also addresses this pair as $FF8804/$FF8806 — same PSG data bus, different bus-mirror address, used so two register writes can be done back-to-back with MOVEP.L/MOVEP.W without re-selecting.
  • YM2149 registers actually driven by this engine: 0/1 (tone A fine/coarse), 2/3 (tone B), 4/5 (tone C), 6 (noise period), 7 (mixer: per-channel tone/noise enable bits), 8/9/10 (volume A/B/C). Registers 11-13 (hardware envelope generator) are never written — this driver implements its own software volume envelopes (§4.3) instead of using the chip's envelope unit.
  • MFP Timer D vector $110, enabled/configured via the MFP registers at $FFFFFA01/FA09/FA0D/ FA11/FA15/FA1D/FA25 — used only for PCM sample playback (§5).

3. The command mailboxes and dispatcher

Gameplay/UI code never touches the PSG or the sequencer state directly. It writes a command byte into one of two global mailboxes and lets the main loop's once-per-VBL pump dispatch it:

  • SoundCommand_MailboxA_0x414 — the general-purpose mailbox. Written from a wide variety of call sites: player weapons fire (laser attach 0x18, forward-shot 0x02), shield depletion (0x0f), shop UI navigation/clicks, level/animation-player music-start commands, boss init, etc.
  • SoundCommand_MailboxB_0x418 — written by a much narrower set of call sites, all related to death/explosion effects: PlayerShield_OnDepleted_FUN_00006536 (0x0f, alongside mailbox A), BossSegmentedSpawn_Init_FUN_000505aa, and the shared "hit reaction" terminal step FUN_00003a5e (0x2c or 0x0f depending on which of two explosion "kinds" the caller wants — selected by whether the caller's A2 points at &DAT_00002efe).

Both mailboxes are pumped through the identical SoundDriver_PlayCommand_PreserveRegisters_FUN_0002cf7a → SoundDriver_PlaySoundCommand_Dispatch_FUN_0002df9e path. Having two independent mailboxes just means two sound-triggering events landing in the same frame (say, a UI click and an on-screen hit) don't clobber each other before the pump runs — a plausible reason mailbox B exists mainly for explosions/death is so a hit sound is never dropped by whatever already occupies mailbox A that frame, though this hasn't been confirmed against a live capture.

3.1 Dispatcher semantics (SoundDriver_PlaySoundCommand_Dispatch_FUN_0002df9e)

cmd = mailbox_byte
if cmd & 0x80:                      # top bit set -> PCM sample playback (see §5)
    StopAll()
    start Timer D DAC with the sample selected by (cmd & 0x7f)
else:                               # top bit clear -> tracker instrument/pattern (music or SFX)
    if <driver was mid-PCM-playback>: StopAll()
    if <a specific low bit of the raw command is set>:
        install into overlay voice B or C           # bit selects which of the 3 overlay voices
    else:
        install into overlay voice A

In this dump, only one PCM sample is registered (§7), so in practice cmd & 0x80 is only ever used for that one clip; every other command value (§5) is a tracker instrument/pattern index that gets installed into one of the three overlay voices, not directly into the music channels — see §4.2. Music itself is also started this way: a "start the tune" command is just another instrument/pattern index, the same mechanism SFX use, just typically installed with different target/duration semantics by the pattern data itself.

4. Music: the 3-channel tracker

4.1 Per-VBL tick (SoundDriver_Tick_MixChannelsAndFlushMixer_FUN_0002d0da)

Once per VBL:

  1. Calls SoundDriver_Tick_OverlayVoices_FUN_0002e100 (advances SFX + the shared noise LFSR, §5/§6).
  2. If a tempo-divider carry fires, calls SoundDriver_ChannelStep_PatternAdvance_FUN_0002d578 three times (once per music channel A/B/C) — consumes pattern bytes, may trigger new notes/sequences.
  3. Calls SoundDriver_ChannelStep_NoteAndVibrato_FUN_0002d67a three times — resolves each channel's final tone period (pattern note + per-channel transpose + global transpose, looked up in NotePitchTable_Undecoded_0002d7e0), applies vibrato/portamento, and writes the result plus a computed volume into the shared PSG register shadow, PSG_RegisterShadow_0002d7c4 (14 bytes: toneA fine/coarse, toneB fine/coarse, toneC fine/coarse, noise period, mixer, volume A/B/C — i.e. exactly the layout replayed by the existing static song player, see §7). Right here, Tick_MixChannelsAndFlushMixer also subtracts a global attenuation term from all three channels' volume in the same pass: shadow.volume[ch] = channelOutputVolume - (DAT_0002d0bd ^ 0xF), clamped to 0. DAT_0002d0bd is not pattern data and isn't part of the per-note volume envelope (§4.2.2) — it's a single byte shared across all three channels. Song install (FUN_0002cfa0) sets it to 0xF (so 0xF^0xF=0: zero attenuation, the normal-gameplay case), and every other write site is game-level code entirely outside the sound driver — confirmed by cross-reference: TitleScreen_CreditsScrollDriver_FUN_000082d2 plus three unnamed routines at 0x7a26/0x7dc2/0x7e4c/0x7efe. Read together, this is a scripted fade-in/fade-out the game drives from specific screens/transitions, layered on top of the tracker's own volume envelopes — a live YM capture taken mid-transition will show lower channel volumes than the raw envelope predicts for exactly this reason, not because the envelope decode is wrong.
  4. If any overlay voice (SFX) is currently active on a channel, overwrites that channel's tone/ volume/mixer fields in the shadow with the overlay voice's own computed values (DAT_0002d7d2.. DAT_0002d7db) — SFX always wins over the music channel it's borrowing. This is the mechanism that already keeps "effects" and "music" logically separable at the flush step (see §8).
  5. Flushes all 14 shadow bytes to the real PSG ($FF8800/$FF8802) via a sequence of raw register writes the Ghidra decompiler collapses into conditional bodies — confirmed against raw disassembly at 0x2d2a8-0x2d37c.

4.2 Pattern format (SoundDriver_ChannelStep_PatternAdvance_FUN_0002d578)

Each music channel has a ~0x30-byte state block (channel A/B/C live at 0x2d02e/0x2d05e/ 0x2d08e). Its pattern is a byte stream read from the sound data blob. Every byte falls into one of five ranges:

Byte range Meaning
0x00-0x7F Play note. Value is a scale index (§4.4), combined with transpose at NoteAndVibrato time (§4.1 step 3). Triggers immediately; loop exits for this tick.
0x80-0x98 Command. Dispatches through a 25-entry handler table (§4.2.1). Each handler may consume 0-2 further bytes as its own parameters, then reading continues.
0xB8-0xCF Select pitch/arpeggio sequence via PSG_PitchSequenceOffsetTable_0002d8a0 (§4.2.2). No separate parameter byte — the command byte itself is the index. Reading continues.
0xD0-0xDF Select volume sequence via PSG_VolumeSequenceOffsetTable_0002f080 (§4.2.2). Same shape as pitch-select. Reading continues.
0xE0-0xFF Set duration (inline). No parameter byte: the reload value for the next note's duration counter is byte - 0xDF (i.e. 1-32), encoded directly in the opcode itself. Reading continues.
0x99-0xB7 Unused in this table's populated range — would dispatch through the same mechanism as 0x80-0x98 (index = byte & 0x7F, up to index 0x37), but the offset table is only populated for the first 25 entries (0x80-0x98); not observed in any authored pattern data in this dump.

Correction (this session): an earlier pass had misread this as "0xE0-0xF8 dispatch through a 25-entry table" — a plausible-looking mistake, since the table's storage happens to sit physically right before the 25 handler routines, which in turn sit right before this function itself (0x2D40A table → 0x2D43C-0x2D577 handlers → 0x2D578 this function, contiguously). But the actual dispatch instructions (0x2D618-0x2D676) show the jump only triggers when the byte is < 0xB8 (after the 0x00-0x7F note case has already returned), i.e. for 0x80-0xB7, with only 0x80-0x98 populated — 25 entries, matching the table size, just shifted down by 0x60 from the earlier guess. 0xE0-0xFF is a different, parameter-free "set duration" opcode family, not part of the dispatch table at all. The Ghidra database (function names, the offset table's symbol name) still carries the old _E0_F8_/_EE_/etc. labels from before this correction — no function-rename tool was available this session, so each affected function/table has a corrected plate comment instead; trust this document and those comments over the stale name text.

4.2.1 Commands 0x80-0x98

All operate on the current channel's state block (A0); "global" targets below are driver-wide (A3-relative), not per-channel. None of these trigger a note by themselves except where noted.

Cmd Name Parameters Effect
0x80 Retrigger note — Clears the frame-duration counter (+0x1E), then runs the same "restart" tail as a natural note-trigger: reloads the duration counter from +0x1C, recomputes the pitch-sequence pointers, and runs the 3-channel mute-flag merge (see the box below).
0x81 Vibrato off — Clears vibrato mode/flags (+0x2C) to 0.
0x82 Vibrato on (resume) — Sets vibrato mode/flags (+0x2C) = 0x40 (enable bit only); keeps the existing speed/depth.
0x83, 0x8D (unnamed, shared handler) — Sets bit 1 of the channel status byte (+0x00). Both command bytes reach the identical routine; the bit's downstream effect wasn't cross-referenced further this session.
0x84 Portamento/glide on stepAmount:int8, stepCount:uint8 Clears the glide accumulator (+0x0E), sets status bit 2 (portamento-enable), stores the two parameter bytes into step-amount (+0x18, signed) and step-count (+0x19).
0x85 Note-step on (down) — Sets status bit 3 only. The tail of PatternAdvance checks bit 3 every non-triggering tick and, if set, increments (bit7=1) or decrements (bit7=0) the note value (+0x1D) by one — this command leaves bit7 as-is, so it glides in whatever direction was last set (observed default: down).
0x86 Note-step on (up) — Sets status bit 7 (direction=up) and bit 3 (enable) together — the forced-up-direction counterpart to 0x85.
0x87 Advance jump-table / repeat — Walks a null-terminated list of 16-bit offsets set up by 0x93 (below) — full algorithm, live list data for all three of song 0's channels, and verification against a real YM2149 capture in §4.2.4.
0x88 Vibrato on (set speed/depth) speed:uint8, depth:uint8 Stores speed into +0x2A, depth into +0x29, seeds the running phase accumulator (+0x2B) with the same depth value, then sets mode/flags (+0x2C) = 0x40 (enable).
0x89 Set global 0xA04 value:uint8 Stores the byte verbatim into shared global A3+0xA04. Purpose of that global wasn't cross-referenced further.
0x8A Commit noise-mixer bits — Merges bits 3-5 (mask 0x38 — YM2149 mixer's noise-disable bits for channels A/B/C) of this channel's mixer-mask field (+0x2F) into shared mixer-staging byte A3+0xA0D via a masked replace; clears channel flag +0x01.
0x8B Commit tone-mixer bits — Same mechanism as 0x8A but mask 0x07 (tone-disable bits A/B/C); sets channel flag +0x01 = 0xFF.
0x8C Clear mixer bits — ANDs the complement of this channel's whole +0x2F byte into A3+0xA0D — forces off whichever tone/noise bits this channel had set. Companion "clear" to 0x8A/0x8B's "set."
0x8E Stop / end of song — Clears global A3+0xA03, then calls the driver's general "stop everything" utility (FUN_0002cf5e → FUN_0002d38a → SoundDriver_StopAllAndDisablePCM_FUN_0002e740) — halts all three channels and PCM, not just this one. The same utility is called directly (outside any pattern) from several UI screens as a generic "stop whatever's playing," confirming this is the pattern-embedded end-of-track marker.
0x8F Extend current note — Sets status bit 5, reloads the duration counter (+0x1B = +0x1C) and re-runs only the mute-flag merge — a genuinely shorter tail than 0x80/0x8B/etc's full retrigger: it does not reset the pitch (+0x1D), the volume-sequence cursor (+0x20/+0x28), or the volume envelope's period counter (+0x1F). Net effect: the currently-sounding note keeps playing at its current pitch and current point in its volume envelope for another duration ticks, rather than cutting to a fresh attack. Confirmed by decompiling the handler directly (PatternCmd_EF_Handler_0002d564, real trigger 0x8F) — an earlier pass of this doc described it as "joins the same tail as 0x80," which overstates the similarity; the actual instructions are a strict subset.
0x90 Mute channel — Unconditionally sets the channel's mute flag (+0x2D) = 0xFF.
0x91 Unmute channel — Unconditionally clears the mute flag (+0x2D) = 0.
0x92 Set transpose transpose:uint8 Stores into the per-channel transpose/tuning offset (+0x2E), added into every subsequent note lookup as a plain unsigned byte (8-bit wraparound, not sign-extended) — see §4.4.
0x93 Define jump-table / loop list listOffset:uint16 (2 bytes, big-endian) Stores the 16-bit value across +0x06/+0x07 (the A3-relative base of a null-terminated list of further 16-bit offsets) and clears the step index (+0x0A/+0x0B). Paired with 0x87, which walks the list.
0x94 Set global 0x9F8 (+ tempo snapshot) value:uint8 Stores the byte into A3+0x9F8, then also copies the driver's live tempo-increment byte (DAT_0002d0be) into the adjacent global A3+0x9F9 — a "new value + snapshot of current tempo" pair, suggestive of a tempo-slide/restore feature.
0x95 Set global 0xA05 (+ loop-divider snapshot) value:uint8 Same shape as 0x94: stores into A3+0xA05, then copies the driver's loop/repeat divider (DAT_0002d0cb) into A3+0xA06.
0x96 Set global 0x9F7 value:uint8 Stores the byte verbatim into A3+0x9F7. No snapshot pairing (unlike 0x94/0x95).
0x97 Trigger sound command inline soundCommand:uint8 Passes the parameter byte to SoundDriver_PlayCommand_PreserveRegisters_FUN_0002cf7a — the same entry point the two mailboxes (§3) dispatch through. A music pattern can fire an SFX/overlay-voice/PCM command synced to a specific beat, bypassing the mailbox protocol entirely.
0x98 Clear global 0x9FA — Clears shared global A3+0x9FA, one of a group of three (0x9FA/0x9FB/0x9FC) the mute-flag-merge tail treats as per-hardware-channel A/B/C "SFX override in progress" gates. This handler always targets 0x9FA specifically regardless of which channel is executing it — implying it's only ever authored into channel A's own pattern data in practice.

The mute-flag-merge tail, referenced by several commands above: after (re)triggering, the driver checks, for all three hardware channels at once, whether that channel's 0x9FA/0x9FB/ 0x9FC global is clear or that channel's active overlay voice has its noise-enable mixer bit set; only if all three conditions hold does it set this channel's own mute flag (+0x2D) to 0xFF. This cross-channel condition — the closest thing to an explicit "conditional" in the command set — effectively means: don't let a channel's own note re-trigger un-mute it while an SFX is actively overriding any of the three hardware channels.

Live +0x2F mixer-mask values, all three music channels (read directly from the memory dump via ReVa, one targeted byte read per channel to rule out a manual hex-counting slip): channel A (0x2D02C+0x2F = 0x2D05B) = 0x09, channel B (0x2D05C+0x2F = 0x2D08B) = 0x12, channel C (0x2D08C+0x2F = 0x2D0BB) = 0x24. All three follow the same pattern — (1<<channelIndex) | (1<<(channelIndex+3)), i.e. each channel's own mask only ever asserts its own tone-disable and noise-disable bits (§4.2's 0x07/0x38 bit conventions above), never another channel's — a "hand back to whoever else wants it" convention consistent across all three. An earlier pass of the companion demo had used 0x00 as a placeholder for channel B, never re-verified against the dump; that's now corrected to the measured 0x12.

4.2.2 Pitch/volume sequence select (0xB8-0xCF, 0xD0-0xDF)

The command byte itself is the table index (no separate parameter byte): pitch-select computes index = (byte + 0x40) & 0xFF, volume-select computes index = (byte + 0x30) & 0xFF, each * 2 for a word offset into PSG_PitchSequenceOffsetTable_0002d8a0 / PSG_VolumeSequenceOffsetTable_0002f080 respectively. Because the addition wraps at the byte boundary, the 24 values 0xB8-0xCF land on table indices 0xF8-0xFF then 0x00-0x0F (and similarly for the 16 volume-select values) — the table storage is laid out so this wraparound lands on the intended contiguous block. The selected offset becomes this channel's new pitch-sequence pointer (+0x10/+0x14) or volume-sequence pointer (+0x24, with the last byte before the resolved offset copied into +0x1A as an immediate initial value) respectively.

The pitch-sequence table's (0xB8-0xCF) byte-for-byte contents were not read this session. The volume-sequence table (0xD0-0xDF) was — format and full contents below.

Volume-envelope format. Confirmed by decompiling SoundDriver_ChannelStep_NoteAndVibrato_FUN_0002d67a (the same per-channel tick function that computes pitch, lines 46-59) rather than assumed by analogy with the pitch table — the two tables turn out to use different conventions:

  • Each of the 16 PSG_VolumeSequenceOffsetTable_0002f080 entries is a word offset (added to the driver's A3 base, 0x2C6C6) to that sequence's first data byte. The byte immediately before that address (offset - 1) is a per-sequence step-reload value, copied into the channel's +0x1A field when the 0xD0-0xDF command fires.
  • On note retrigger, the sequence cursor (+0x20) resets to the sequence start and the channel's output volume (+0x1E) is set to the sequence's first byte immediately (no delay).
  • Every tick after that (the same 50 Hz step that also advances pitch/vibrato), a per-channel counter (+0x1F) counts down from the step-reload value; when it underflows, it reloads and the next byte in the sequence is peeked. A step interval is step-reload + 1 ticks (the countdown starts already-loaded, so the first tick after retrigger is ticks 1 of that interval, not tick 0).
  • If the peeked byte has bit 7 clear (0x00-0x7F), it's a normal step: the cursor advances onto it and it becomes the new output volume (a direct 0-15 YM2149 amplitude-register value).
  • If the peeked byte has bit 7 set (in practice always 0x87 in this table), the cursor does not advance and the output volume holds at its last value indefinitely — this is a sustain/end marker, not a loop-restart (unlike the pitch-sequence table's 0x80 convention documented in §4.4, which does wrap back to the sequence start — the two tables are not symmetric).

Full table (all 16 sequences, 0xD0-0xDF; step-reload is in ticks, each tick = 1/50s at the driver's nominal rate before this song's own tempo-scaling; "hold" = sustain at the last listed value forever, i.e. until the next retrigger):

Cmd Index Step reload Envelope (volume 0-15 per step)
0xD0 0 1 15, 14, 13, 11, 9, 1, hold
0xD1 1 1 14, 13, 11, 9, 1, hold
0xD2 2 1 15, 14, 13, 11, 13, 12, 10, 9, 11, 10, 8, 7, 9, 8, 6, 5, hold
0xD3 3 1 15, 15, 13, 11, 13, 12, 10, 9, 11, 10, 8, 7, 9, 8, 6, 5, hold
0xD4 4 1 12, 13, 15, 14, 13, 12, hold
0xD5 5 2 15, 15, 13, 11, 13, 12, 10, 9, 11, 10, 8, 7, 9, 8, 6, 5, hold
0xD6 6 8 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, hold
0xD7 7 8 13, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, hold
0xD8 8 5 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, hold
0xD9 9 8 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, hold
0xDA 10 2 12, 13, 14, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, hold
0xDB 11 1 12, 13, 15, 14, 12, 11, 9, 8, 7, 6, 5, 4, 3, 2, 1, hold
0xDC 12 1 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, hold
0xDD 13 1 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, hold (attack ramp only, no decay)
0xDE 14 1 15, hold (flat, no envelope)
0xDF 15 1 0, hold (silence)

Most entries are plain decay shapes (matching the SFX envelopes in §5.4); 0xDA/0xD4 swell before decaying, 0xDD is attack-only, 0xDE/0xDF are degenerate one-step "envelopes" used to force a channel to a fixed loud or silent level. Read live from the memory dump at 0x2F080-0x2F175 (offset table) and 0x2F0A0-0x2F177 (sequence + step-reload data); table ends exactly at 0xDF's sequence, with unrelated data immediately after.

4.2.3 Inline duration (0xE0-0xFF)

No dispatch, no separate parameter byte: in_A0[0x1C] = byte + 0x21 (truncated to a byte), i.e. the next note's duration-counter reload value is byte - 0xDF, giving reload values 1-32 for opcode bytes 0xE0-0xFF. This is a compact single-byte "set tempo/duration for the next note" encoding — cheaper than a 2-byte command+parameter pair for the single most common per-note adjustment.

4.2.4 The loop jump-table (0x87/0x93) — decoded this session

0x93 (listOffsetHi listOffsetLo) stores a 16-bit A3-relative offset into the channel's +0x06/+0x07 field (the base of a null-terminated list of further 16-bit A3-relative offsets) and clears a persistent byte-offset step index at +0x0A/+0x0B to 0. 0x87 (no parameters) walks that list, one step per call, verified against PatternCmd_E7_Handler_0002d490's actual instructions rather than assumed from the byte-range table alone:

candidate = listBase + stepIndex
if word@(A3+candidate) == 0 (terminator):   candidate = listBase; nextStep = 2   # wrap to entry 0
else:                                        nextStep = stepIndex + 2
entry = word@(A3+candidate)
channel.stepIndex = nextStep
jump the pattern-read cursor to (A3 + entry), then keep reading from there —
  can immediately hit and trigger a note in the same call, no separate tick needed

The step index counts bytes into the list (advances by 2 per call), not list-entry number — a detail that only shows up by reading the instructions, not by paraphrasing the byte-range table.

Live data, all three of song 0's music channels (read directly from the memory dump, not reconstructed): channel A's list sits at 0x2D968 (19 entries + terminator), channel B's at 0x2D8F6 (20 entries), channel C's at 0x2D920 (35 entries). For all three, most entries resolve to that same channel's own pattern start address (e.g. channel A's entries are overwhelmingly 0x15B0, which is A3+0x15B0 = 0x2DC76 — exactly where channel A's own pattern begins) — so most loop iterations simply restart the pattern from the top. A handful of entries per channel detour into different resync points or short variations of the same material instead of a hard restart. One channel-A entry (index 15, value 0x1803 → 0x2DF9A) resolves to bytes that decode as 68k code, not pattern data — that particular branch sits outside the reachable pattern region and was never observed taken in a live capture, so it's treated as unreachable rather than guessed at.

A genuinely unexpected finding: the three channels' loop lists aren't confined to their own 200-byte regions. Several entries land squarely inside what we'd been calling another channel's pattern bytes (e.g. one of channel B's entries lands inside channel C's own pattern data, mid-stream). The three "channel patterns" aren't walled-off blocks — they're three independent read cursors into one shared data area, and the driver is fine with one channel's loop redirecting through bytes another channel also reads from its own start point (a synchronized-unison composition technique, not a decode artifact).

Full basic-block reachability, all three channels (verified this session, not sampled). Starting from each channel's own pattern start and following every 0x87 this driver can actually reach (a block runs until its own 0x87, splitting the shared byte stream at every jump site), the complete reachable set is: channel A 7 blocks, channel B 9, channel C 11 — 26 distinct blocks total, 258 directed edges once every block-ending 0x87 is expanded against its full jump-list (most blocks are reachable from more than one list position, so the edge count is well above the block count). Two structural facts fall out of walking the entire set rather than a handful of jumps:

  • Channel A's reachable set shares no block, and no edge, with channel B's or C's. Despite the cross-channel byte-stream overlap described above (one channel's loop landing inside bytes another channel also reads from its own start), channel A's own 19-entry list never actually resolves into anything B or C can reach, or vice versa — it's a fully self-contained loop structure once you enumerate where every entry actually goes, not just the sampled examples.
  • Channels B and C share exactly one block, at 0x2DBE1 — the single point where the two channels' reachable sets intersect. Every other cross-channel byte overlap noted above turns out to land inside one of these 26 blocks rather than at a block boundary, so it doesn't itself add a reachable node; only 0x2DBE1 is an actual jump target shared between B and C.
  • Every reachable block terminates cleanly in a real 0x87 except the one already-documented dead branch (0x2DF9A, channel A list index 15, decodes as 68k code) — re-confirmed by fetching further bytes at 5 blocks whose first pass had been cut off by an arbitrarily-sized read window before reaching their real terminator; all 5 resolve into already-known block-start addresses (4 of 5) or that same known dead end (1 of 5), never into new unknown territory. No other unreachable/unfetched target exists among these 26 blocks.

This structure is visualized directly (real Graphviz fdp layout, every edge drawn, nothing hand-positioned) in the companion demo's "Loop-list mesh" panel, with live per-channel cursors during playback.

Verified against a real YM2149 capture: a live register recording of this song's first ~20 notes on channel A matches this driver's own note-pitch decode exactly, note for note — right up to the point where 0x87 fires. A naive linear read of the pattern bytes (not following the jump) diverges from the real recording at exactly that instruction; following the jump-list as documented above reproduces the recording's actual next note. This is about as direct a confirmation as this kind of reverse engineering gets — see the companion demo's implementation notes for the exact comparison.

4.3 Note/vibrato/portamento (SoundDriver_ChannelStep_NoteAndVibrato_FUN_0002d67a)

Confirmed per-channel state fields (offsets into the ~0x30-byte channel struct):

Offset Field
+0x00 status/flags byte (bit0 toggles every call; bit2/bit5 gate sub-behaviors)
+0x0e portamento/glide accumulator (word)
+0x10/+0x14 pattern data pointer (current / loop-restart)
+0x18/+0x19 portamento step amount (signed) / step counter
+0x1a/+0x1e/+0x1f duration/frame counters (shared with PatternAdvance)
+0x1d transpose byte (added into the note lookup)
+0x20/+0x28 loop-count pointer / value
+0x29 vibrato depth
+0x2a vibrato speed
+0x2b vibrato running phase accumulator
+0x2c vibrato mode/flags (bit5 = direction, bit6 = vibrato enabled, bit7 = alt mode)
+0x2d mute flag (bit7 set = channel silenced)
+0x2e per-channel transpose/tuning offset
+0x2f mixer-mask contribution for this channel

Vibrato is a triangle-wave ramp between 0 and depth (+0x29), reflecting via the direction bit; portamento glides the tone period toward a target over +0x19 frames using signed step +0x18.

4.4 The note-pitch table (decoded this session)

NotePitchTable_Undecoded_0002d7e0 (name kept as-is — not renamed, see §4.2's correction note) holds exactly 96 big-endian 16-bit tone periods — confirmed by table size, not assumption: the table runs 0x2D7E0-0x2D89F (192 bytes) and ends exactly where PSG_PitchSequenceOffsetTable_0002d8a0 begins, with no slack either side.

Read live and checked arithmetically, it's a standard 12-tone-equal-tempered chromatic scale spanning 8 octaves (8 × 12 = 96), computed from the ST's 2 MHz PSG clock (Hz = 2,000,000 / (16 × period)), tuned close to concert pitch (A4 = 440 Hz):

Index Period (hex) Period (dec) Frequency Note
0 0x0EEE 3822 32.71 Hz ~C1
12 0x0777 1911 65.42 Hz ~C2 (period exactly halves per octave: 3822/1911 = 2.0005)
24 0x03BB 955 130.9 Hz ~C3
36 0x01DD 477 262.1 Hz ~C4 (middle C)
45 0x011C 284 440.14 Hz A4 (semitone ratio period[n]/period[n+1] ≈ 2^(1/12) = 1.0595 throughout, matching to <0.1%)
57 0x008E 142 880.28 Hz ~A5
95 0x000F 15 8333 Hz top of table — integer-period quantization is coarse this high, so the precise note identity/tuning reference at the extreme top isn't pinned down as tightly as the low/mid range

Full 96-entry table (hex tone periods, 12 per row = one octave per row, index 0 at top-left):

0EEE 0E17 0D4D 0C8E 0BD9 0B2F 0A8E 09F7 0967 08E0 0861 07E8   (octave 1: C1..B1)
0777 070B 06A6 0647 05EC 0597 0547 04FB 04B3 0470 0430 03F4   (octave 2: C2..B2)
03BB 0385 0353 0323 02F6 02CB 02A3 027D 0259 0238 0218 01FA   (octave 3: C3..B3)
01DD 01C2 01A9 0191 017B 0165 0151 013E 012C 011C 010C 00FD   (octave 4: C4..B4, index 45 = A4 = 011C)
00EE 00E1 00D4 00C8 00BD 00B2 00A8 009F 0096 008E 0086 007E   (octave 5: C5..B5)
0077 0070 006A 0064 005E 0059 0054 004F 004B 0047 0043 003F   (octave 6: C6..B6)
003B 0038 0035 0032 002F 002C 002A 0027 0025 0023 0021 001F   (octave 7: C7..B7)
001D 001C 001A 0019 0017 0016 0015 0013 0012 0011 0010 000F   (octave 8: C8..B8)

Index into this table, per the disassembly of SoundDriver_ChannelStep_NoteAndVibrato_FUN_0002d67a (0x2D6AA-0x2D6D0) — corrected this session, see §10:

index = (patternNote + globalTranspose + channelTranspose + arpeggioByte) & 0x7F

All four terms are summed as plain 68k add.b byte operations — 8-bit, wrapping at 256, never sign-extended — and the sum is then doubled as a byte too (wrapping again at 256) to form the table's byte offset; the net effect, confirmed against the raw instructions rather than assumed, is rawSum & 0x7F (0-127), which can never go negative. channelTranspose (+0x2E, command 0x92) must be treated as its raw unsigned parameter byte, not converted to a signed number first — doing that conversion before adding (an earlier version of both this document and the demo page did) is exactly what produced impossible results like note index -11: in real 8-bit arithmetic, 1 + 0xF4 wraps to 245, not -11.

globalTranspose (DAT_0002d0ca) is reset to 0 by FUN_0002cfa0 on every song install and never written again by any of the 25 pattern commands, so it's 0 throughout. arpeggioByte comes from a second, independent per-channel byte stream (pitchSequenceCursor, +0x14/+0x10) that NoteAndVibrato reads and auto-advances every tick (not just at note-trigger) — a genuine "arpeggio" effect distinct from the main pattern stream, selected by the 0xB8-0xCF commands documented above. Its default table (DAT_0002d8c0) starts with byte 0x80 — bit 7 set means "restart the sequence" and gets masked off before use, so it contributes 0 and loops on itself forever until a channel actually issues a 0xB8-0xCF select. None of the three excerpts on the demo page ever does, so arpeggioByte = 0 throughout them (verified by counting 0xB8-0xCF bytes, not assumed) — this second stream is not otherwise decoded here.

Since the wrapped index only ever lands in 0-127, and this table only has 96 confirmed entries (0-95), indices 96-127 land past the end of NotePitchTable_Undecoded_0002d7e0 and into PSG_PitchSequenceOffsetTable_0002d8a0's own bytes — real hardware reads that as if it were a tone period too, so it isn't a null case to special-case away; it's what a byte with that particular note+transpose combination actually does. channelTranspose = -12 (byte 0xF4) applied to a low pattern-note byte is enough to land there — exactly the case flagged from the demo page (channel B, byte offset +0x94, pattern note 0x01: index wraps to 117, past the decoded range).

5. Effects: the 3 overlay voices

5.1 Why "overlay"

SFX don't get their own PSG channels — there are only three, and music already owns them. Instead, the driver keeps three small transient voice states, PSG_OverlayVoiceA/B/C_State (struct PSGOverlayVoiceState, 26 bytes, at 0x2e6d2/0x2e6ec/0x2e706), one per PSG channel. When active, an overlay voice's computed tone/volume/mixer bits replace that channel's music output for the duration of the effect (§4.1 step 4) — so firing a laser briefly "steals" one of the three music channels, plays the effect, then hands the channel back to the tune.

struct PSGOverlayVoiceState {       // 26 bytes total
    uchar flags;
    uchar toneStepReload;
    ushort baseTonePeriod;          // fixed pitch for the whole effect (no per-note melody)
    uchar volume;
    uchar unknown05;
    uchar mixerFlags;               // gates tone/noise per this channel, see §5.3
    uchar unknown07[3];
    uchar sequenceSelector;         // indexes the volume-envelope table, see §5.3
    uchar sequenceStepReload;       // envelope step rate (ticks per envelope step)
    uchar unknown0c;
    uchar sequencePeriodReload;
    uchar toneStepCounter;
    uchar sequenceStepCounter;
    uchar sequencePeriodCounter;
    uchar unknown11;
    uchar *sequenceStart;           // -> envelope bytes in the sound data blob
    uchar *sequenceCursor;
};

5.2 Install (SoundDriver_PlaySoundCommand_Dispatch_FUN_0002df9e, non-PCM path)

For command cmd (0-0x7F): the dispatcher copies a 14-byte instrument record — flags .. sequencePeriodReload, i.e. the first 14 fields of the struct above — from SoundDataBlobBase_0002c6c6 + offsetTable[cmd], where offsetTable is DAT_0002e356 (measured: 47 consecutive 16-bit offsets, uniformly spaced 14 bytes apart, i.e. exactly one instrument record per table entry, immediately followed in memory by the 47 records themselves starting at 0x2e3b4). It then resolves sequenceStart = SoundDataBlobBase + sequenceOffsetTable[sequenceSelector] (a second table, DAT_0002e646) and resets the per-voice counters.

5.3 Tick (SoundDriver_OverlayVoiceA/B/C_Tick_FUN_0002e128/0x2e1e2/0x2e29c)

Each active overlay voice, once per VBL:

  1. Decrements a couple of duration counters; if the effect has fully expired, silences its output (writes 0 to its channel's staging bytes) and returns.
  2. Every sequenceStepReload ticks, reads the next byte from sequenceCursor and advances it. A value < 0x80 becomes the new output level; 0x80 means "loop back to sequenceStart"; any other value >= 0x80 (in this dump, only 0xFF) means "sequence finished — go silent".
  3. Writes the resulting tone period (from baseTonePeriod, essentially fixed for the whole effect — no per-note melody, unlike music channels) and mixerFlags-gated tone/noise-enable bits into that channel's overlay-output staging bytes, which Tick_MixChannelsAndFlushMixer then copies into the PSG register shadow ahead of the music channel's own values (§4.1 step 4).

Measured, not guessed: reading the actual sequence bytes live from the dump shows they are plain linear volume-decay ramps, e.g. (hex) 0F 0E 0D 0B 0A 09 08 07 06 05 04 03 02 01 FF and 0E 0D 0C 0B 0A 09 08 07 06 05 04 03 02 01 FF — i.e. every effect in this driver is fundamentally "pick a fixed tone/noise mix, then fade the volume down to silence over N ticks." There is no separate pitch-sweep table for overlay voices analogous to the music engine's arpeggio sequences.

5.4 Concrete measured examples

Read live from the memory dump (SoundDataBlobBase_0002c6c6 + the two offset tables above) for four command IDs already tied to specific gameplay events by cross-reference (§3):

cmd Event baseTonePeriod mixerFlags (tone/noise gate) toneStepReload envelope steps × rate
0x02 ForwardShotAttachment_UpdateOnFire — forward shot fired 0x0034 (52) 0xF6 → tone and noise both enabled 0x09 15 steps × every 3 ticks
0x0F PlayerShield_OnDepleted / explosion "kind 2" 0x0010 (16, very short/high) 0xF7 → noise only 0x1E (30) 15 steps × every 9 ticks
0x18 PlayerLaserAttachment_Update — laser fired 0x0002 (2, extremely short = very high pitch "pew") 0xF6 → tone and noise 0x63 (99) 15 steps × every 4 ticks
0x2C SpawnExplosionEffect_TailIntoAllocator explosion "kind 1" 0x0000 (irrelevant — noise-only) 0xF7 → noise only 0x05 15 steps × every 4 ticks

This lines up with intuition: the two explosion/impact sounds are pure filtered noise bursts (no tone channel involved) with different decay rates; the two weapon-fire sounds are a short tone burst mixed with noise for texture, with the laser using an extremely short (near-ultrasonic) base period for its characteristic "zap," decaying over a longer real-world time (toneStepReload=0x63) than the forward shot.

6. The shared noise generator

SoundDriver_AdvanceNoiseLFSR_FUN_0002e720 advances a 17-bit-ish linear-feedback shift register (state held across unaff_A3+0x2076/+0x2078) once per VBL tick, called from SoundDriver_Tick_OverlayVoices_FUN_0002e100 before any overlay voice ticks. Its output byte feeds the PSG's noise generator (via PSG_RegisterShadow_0002d7c4.noisePeriod) and is shared by whichever channel(s) currently have their mixerFlags noise-enable bit set — so all "noise" content (music percussion, SFX like the explosions in §5.4) is driven by one continuously-running LFSR, not per-effect random seeds.

7. Samples: the PCM8 software DAC

7.1 Starting a sample

A command byte with the top bit set (cmd & 0x80 != 0) is handled entirely differently from §3/§5:

  1. SoundDriver_StopAll_PreserveRegisters_FUN_0002cf88() — kills the tracker/SFX engine and mutes the PSG outright (SoundDriver_MuteAllPSGChannels_FUN_0002d396, which explicitly zeroes PSG volume registers 8/9/10 and disables tone+noise in register 7).
  2. Looks the selected sample up in three parallel tables (indexed by cmd & 0x7f): PCM_SampleStartOffsetTable_0002f17c, PCM_SampleLengthTable_0002f178, and a rate-preset byte (PCM_SampleRatePresetIndexTable_...) that indexes TimerD_PCMRatePreset_TDDR/TCDCR pairs used to program MFP Timer D's data/control registers.
  3. Installs SoundDriver_TimerD_EnvelopeTick_WritePSGVolumes_FUN_0002e836 as the Timer D interrupt handler ($110), enables Timer D in the MFP interrupt-enable/mask registers, and sets a "PCM active" flag (DAT_0002d0c0 high bit) that Tick_MixChannelsAndFlushMixer checks so the VBL-rate sequencer stops touching the PSG while a sample is playing.

Only one sample is registered in this dump: start offset 0, length 0x3BD8 (15,320 bytes), rate preset 4, no loop flag.

7.2 The ISR is a 3-channel software DAC, not an envelope generator

SoundDriver_TimerD_EnvelopeTick_WritePSGVolumes_FUN_0002e836 fires once per Timer D interrupt (independent of, and much faster than, the 50 Hz VBL). Each firing:

  1. Reads the next unsigned 8-bit byte from the sample buffer.
  2. Uses it as an index (× 8) into a 256-entry × 8-byte lookup table, PCM8_To_YM2149_3ChannelDAC_Table_256x8_0002e880. Each record is [0x08, volA, 0x09, volB, 0x0A, volC, 0, 0] — i.e. "register 8 = volA, register 9 = volB, register A = volC" pre-packed for fast MOVEP writes.
  3. MOVEP.L/MOVEP.W writes those four register/value pairs straight to $FF8800/$FF8802/ $FF8804/$FF8806 — programming all three PSG channels' volumes in one interrupt, in effect using three logarithmic 4-bit volume DACs in parallel as one higher-resolution linear 8-bit DAC.
  4. At end-of-buffer, either loops back to the saved start pointer (if the sample's loop flag is set) or disables Timer D and clears the "PCM active" flag, letting the VBL sequencer resume next tick.

With the real ST MFP clock (2.4576 MHz) and the programmed prescaler/TDDR for rate preset 4, this plays back at 4,800 samples/sec, giving the one embedded 15,320-byte clip a duration of ≈3.192 seconds — independently confirmed by literally extracting and playing the bytes (§7.3).

7.3 Verified by extraction

The 15,320 bytes at PCM_SampleData_15320Bytes_0002f182 were read directly out of the memory dump (two read-memory calls, 8192 + 7128 bytes) and repackaged as a standard 8-bit unsigned PCM WAV file at 4800 Hz mono — no synthesis or guessing involved, just the driver's own sample bytes wrapped in a WAV header. This is real, listenable audio extracted straight from the ST memory image, and its computed duration (15320 / 4800 = 3.192s) matches the ISR/timer analysis above exactly.

8. Implications for muting/mixing (future work)

The user-facing goal is eventually being able to independently mute music and samples while keeping effects, and potentially mixing in an external music track. The architecture above makes this more tractable than it might look:

  • Samples are already fully independent. They only run through the Timer D ISR, always call StopAll first, and never touch the tracker/SFX state. Disabling the bit-7 PCM path (or simply never installing the Timer D vector/interrupt) removes samples with no effect on music or SFX.
  • Effects already take priority over music at the merge point. §4.1 step 4 shows Tick_MixChannelsAndFlushMixer explicitly overwrites a channel's tone/volume/mixer fields with the active overlay voice's values after the music channel step has already written its own — i.e. overlay SFX already "wins" over the music channel it's borrowing, unconditionally. This means a patch that zeroes/silences only the music channel-step output (SoundDriver_ChannelStep_NoteAndVibrato's contribution) should leave active SFX completely unaffected, since the overlay-voice overwrite happens regardless and doesn't depend on the music channel having produced anything meaningful.
  • Mixing in external music is plausible for the same reason: because music and SFX are computed by entirely separate code paths and merged only at the final 14-byte register-shadow flush, an emulation-layer intercept could skip/replace the tracker's audible contribution (e.g. by feeding external audio into the mix instead of the flushed PSG image, or by force-zeroing PSG_RegisterShadow_0002d7c4's tone/volume fields for channels the tracker owns) while leaving the noise LFSR (§6) and overlay-voice ticking (§5) running exactly as before, so SFX keep working against the replacement soundtrack.

None of the above has been implemented or tested against a live capture yet — it's a reading of the existing driver's control flow, offered as the likely least-invasive hook points.

9. Quick address reference

Address Symbol What
0x2922 (main-loop pump, un-named) Reads mailboxes 0x414/0x418, dispatches, then calls the VBL tick
0x414 SoundCommand_MailboxA_0x414 General-purpose pending-command mailbox
0x418 SoundCommand_MailboxB_0x418 Explosion/death-effect pending-command mailbox
0x2cf50 SoundDriver_VBLTick_PreserveRegisters_FUN_0002cf50 Entry point called once per VBL (50 Hz)
0x2cf7a SoundDriver_PlayCommand_PreserveRegisters_FUN_0002cf7a Entry point for dispatching a pending command
0x2cf88 SoundDriver_StopAll_PreserveRegisters_FUN_0002cf88 Stop everything (tracker + PCM)
0x2d0da SoundDriver_Tick_MixChannelsAndFlushMixer_FUN_0002d0da Music+SFX tick and PSG register flush
0x2d02e/2d05e/2d08e Music channel A/B/C state (0x30 bytes each)
0x2d396 SoundDriver_MuteAllPSGChannels_FUN_0002d396 Mute/reset PSG output
0x2d578 SoundDriver_ChannelStep_PatternAdvance_FUN_0002d578 Music pattern byte-stream stepper
0x2d67a SoundDriver_ChannelStep_NoteAndVibrato_FUN_0002d67a Note/vibrato/portamento resolver
0x2d7c4 PSG_RegisterShadow_0002d7c4 14-byte staging buffer flushed to the real PSG each tick
0x2d40a PatternCommandHandlerOffsetTable_E0_F8_0002d40a 25-entry command-handler offset table for commands 0x80-0x98 (symbol name is stale — see §4.2's correction note)
0x2d43c-0x2d577 PatternCmd_*_Handler_0002d43c..0002d572 The 24 individual command-handler routines (§4.2.1); function names carry the same stale 0xE0-0xF8 offset as the table — see each function's corrected plate comment
0x2d7e0 NotePitchTable_Undecoded_0002d7e0 Note-to-tone-period lookup — decoded this session, see §4.4 (name kept as-is, not renamed)
0x2d8a0 PSG_PitchSequenceOffsetTable_0002d8a0 Music arpeggio/pitch automation offsets
0x2df9e SoundDriver_PlaySoundCommand_Dispatch_FUN_0002df9e Top-level command dispatcher (§3.1)
0x2e100 SoundDriver_Tick_OverlayVoices_FUN_0002e100 Ticks noise LFSR + 3 SFX overlay voices
0x2e128/0x2e1e2/0x2e29c SoundDriver_OverlayVoiceA/B/C_Tick_FUN_... Per-voice SFX tick
0x2e356 DAT_0002e356 SFX instrument-offset table (47 × 2-byte entries, 14-byte stride)
0x2e646 DAT_0002e646 SFX volume-envelope offset table
0x2e6d2/0x2e6ec/0x2e706 PSG_OverlayVoiceA/B/C_State_... Live SFX voice state (PSGOverlayVoiceState, 26 bytes)
0x2e720 SoundDriver_AdvanceNoiseLFSR_FUN_0002e720 Shared noise LFSR, advanced once per VBL
0x2e740 SoundDriver_StopAllAndDisablePCM_FUN_0002e740 Stop tracker + disable Timer D/PCM
0x2e80c+ TimerD_PCMRatePreset_TDDR/TCDCR_... PCM playback rate presets
0x2e836 SoundDriver_TimerD_EnvelopeTick_WritePSGVolumes_FUN_0002e836 Timer D ISR: 3-channel PCM8 software DAC
0x2e880 PCM8_To_YM2149_3ChannelDAC_Table_256x8_0002e880 256×8-byte PCM-byte → 3×YM volume conversion table
0x2c6c6 SoundDataBlobBase_0002c6c6 Base of all pattern/instrument/envelope/sample-index data
0x2f182 PCM_SampleData_15320Bytes_0002f182 The one embedded PCM sample (15,320 bytes, 8-bit unsigned, 4800 Hz, ≈3.192s)

10. Confidence / open questions

  • High confidence, cross-checked against raw disassembly and/or live-read data tables: the three-mechanism split (music/SFX/samples), the PSG register-shadow flush, the PCM8-to-3-channel DAC scheme and its rate math, the SFX volume-envelope byte format (§5.3 — actually read from memory, not inferred), the four concrete SFX examples in §5.4 (tied to real call sites, not guessed IDs), the note-pitch table's 96-entry chromatic-scale decode (§4.4 — table boundaries and the A440 tuning check both confirmed against live data), and the full 0x80-0x98 command set (§4.2.1 — each handler individually disassembled and cross-checked against the struct fields it touches, not inferred from the opcode number alone).
  • Corrected this session: the pattern-command dispatch range was previously documented as 0xE0-0xF8; re-checking the actual dispatch instructions (not just the table's physical storage location) showed the true range is 0x80-0x98. See §4.2 for the full story and why the Ghidra database's function/table names still say E0/F8/etc. (no rename-function tool was available this session — corrected plate comments were added to every affected symbol instead, and should be trusted over the stale names).
  • Corrected this session (2): the note-pitch table's index formula (§4.4) was previously given as a plain signed sum (patternNote + channelTranspose + globalTranspose); the demo page's playback used that formula and printed impossible negative indices (e.g. "idx -11") whenever a large negative channelTranspose met a small pattern-note byte. Re-checking SoundDriver_ChannelStep_NoteAndVibrato_FUN_0002d67a's actual instructions (0x2D6AA-0x2D6D0) showed the real arithmetic is unsigned 8-bit add.b throughout with no sign extension, plus a previously-undocumented fourth term (a per-channel arpeggio byte, auto-advanced every tick from a second byte stream, pitchSequenceCursor) — see §4.4 for the corrected formula and the arpeggio mechanism.
  • Corrected/decoded this session (3): the 0x8A/0x8B/0x8C mixer-commit commands' shared byte (A3+0xA0D) turned out to be the exact same memory cell as DAT_0002d0d3 (confirmed: this driver's A3 base is 0x2C6C6, and 0x2C6C6+0xA0D=0x2D0D3 exactly) — so the three channels' commits genuinely read-modify-write one persistent, accumulating mixer-register byte in real chronological order, not a fixed baseline each time. Plate comments on PatternCmd_EA/EB/EC_Handler_0002d4d6/0002d4b6/0002d4f6 and DAT_0002d0d3 were corrected to record this. Also decoded: the volume-sequence table (PSG_VolumeSequenceOffsetTable_0002f080, §4.2.2) — full 16-entry table, step-reload/hold-marker format confirmed against SoundDriver_ChannelStep_NoteAndVibrato_FUN_0002d67a's actual instructions rather than assumed by analogy with the (differently-behaved) pitch-sequence table.
  • Corrected/decoded this session (4): the 0x87/0x93 loop jump-table (§4.2.4) — full algorithm verified against PatternCmd_E7_Handler_0002d490's actual instructions, plus the live jump-list data for all three of song 0's music channels read straight from the memory dump (not reconstructed or guessed), and independently confirmed against a real YM2149 register capture: a naive linear (non-loop-following) read of the pattern bytes diverges from the recording at exactly the point 0x87 fires, and following the real jump-list reproduces the recording's actual next note. Also decoded: the global volume-attenuation term DAT_0002d0bd (§4.1 step 3) — cross-referencing every write site showed it's set to 0xF (= zero attenuation) at song install and otherwise only ever written by game-level screen/transition code outside the sound driver (a scripted fade, not pattern-driven), explaining why a live capture taken mid-transition shows lower channel volumes than the raw per-note envelope predicts.
  • Corrected/decoded this session (5): full basic-block reachability enumeration of the 0x87/0x93 loop jump-table (§4.2.4), walking every jump reachable from each channel's own pattern start rather than sampling a few — 26 distinct blocks, 258 edges; channel A's 7-block reachable set turns out to share no block or edge with B's/C's, while B and C share exactly one block (0x2DBE1); every reachable block terminates cleanly in a real 0x87 except the one already-known dead branch (0x2DF9A), confirmed by extending 5 blocks whose first-pass fetch window had cut them off before their real terminator. Also measured: the live +0x2F mixer-mask byte for all three channels (0x09/0x12/0x24 for A/B/C — each is exactly its own tone+noise-disable bit pair), correcting a stale, never-reverified 0x00 placeholder the companion demo had been using for channel B.
  • Not yet decoded: the exact byte-for-byte contents of PSG_PitchSequenceOffsetTable_0002d8a0 beyond the index-arithmetic/wraparound behavior (§4.2.2) confirmed against the dispatch code; the downstream effect of channel status bits 1 and 5 (set by commands 0x83/0x8D); the purpose of the plain "set global" commands 0x89/0x94/0x95/0x96's targets beyond their bare addresses; the exact schedule/curve any specific game transition drives through DAT_0002d0bd; and the exact bit(s) of the raw mailbox command byte that select which of the three overlay voices (A/B/C) a given SFX installs into.
  • Not yet implemented/tested: the muting/mixing hook points discussed in §8 are a reading of the control flow, not a verified patch.