How it works · write-up
Xenon 2: Megablast — Sound Driver Notes
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:
- Checks two 1-byte "mailboxes" —
SoundCommand_MailboxA_0x414andSoundCommand_MailboxB_0x418— and if either holds a pending command (0x00-0x7F), callsSoundDriver_PlayCommand_PreserveRegisters_FUN_0002cf7a(cmd)and clears the mailbox back to0xFF(empty). - 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 withMOVEP.L/MOVEP.Wwithout 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 attach0x18, forward-shot0x02), 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 stepFUN_00003a5e(0x2cor0x0fdepending on which of two explosion "kinds" the caller wants — selected by whether the caller'sA2points 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:
- Calls
SoundDriver_Tick_OverlayVoices_FUN_0002e100(advances SFX + the shared noise LFSR, §5/§6). - If a tempo-divider carry fires, calls
SoundDriver_ChannelStep_PatternAdvance_FUN_0002d578three times (once per music channel A/B/C) — consumes pattern bytes, may trigger new notes/sequences. - Calls
SoundDriver_ChannelStep_NoteAndVibrato_FUN_0002d67athree times — resolves each channel's final tone period (pattern note + per-channel transpose + global transpose, looked up inNotePitchTable_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_MixChannelsAndFlushMixeralso 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_0002d0bdis 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 to0xF(so0xF^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_000082d2plus three unnamed routines at0x7a26/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. - 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). - 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 at0x2d2a8-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/0x9FCglobal 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) to0xFF. 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_0002f080entries is a word offset (added to the driver'sA3base,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+0x1Afield when the0xD0-0xDFcommand 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 isstep-reload + 1ticks (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
0x87in 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's0x80convention 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; only0x2DBE1is an actual jump target shared between B and C. - Every reachable block terminates cleanly in a real
0x87except 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:
- 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.
- Every
sequenceStepReloadticks, reads the next byte fromsequenceCursorand advances it. A value< 0x80becomes the new output level;0x80means "loop back tosequenceStart"; any other value>= 0x80(in this dump, only0xFF) means "sequence finished — go silent". - Writes the resulting tone period (from
baseTonePeriod, essentially fixed for the whole effect — no per-note melody, unlike music channels) andmixerFlags-gated tone/noise-enable bits into that channel's overlay-output staging bytes, whichTick_MixChannelsAndFlushMixerthen 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:
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).- 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 indexesTimerD_PCMRatePreset_TDDR/TCDCRpairs used to program MFP Timer D's data/control registers. - Installs
SoundDriver_TimerD_EnvelopeTick_WritePSGVolumes_FUN_0002e836as the Timer D interrupt handler ($110), enables Timer D in the MFP interrupt-enable/mask registers, and sets a "PCM active" flag (DAT_0002d0c0high bit) thatTick_MixChannelsAndFlushMixerchecks 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:
- Reads the next unsigned 8-bit byte from the sample buffer.
- 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 fastMOVEPwrites. MOVEP.L/MOVEP.Wwrites 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.- 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
StopAllfirst, 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_MixChannelsAndFlushMixerexplicitly 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-0x98command 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 is0x80-0x98. See §4.2 for the full story and why the Ghidra database's function/table names still sayE0/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 negativechannelTransposemet a small pattern-note byte. Re-checkingSoundDriver_ChannelStep_NoteAndVibrato_FUN_0002d67a's actual instructions (0x2D6AA-0x2D6D0) showed the real arithmetic is unsigned 8-bitadd.bthroughout 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/0x8Cmixer-commit commands' shared byte (A3+0xA0D) turned out to be the exact same memory cell asDAT_0002d0d3(confirmed: this driver'sA3base is0x2C6C6, and0x2C6C6+0xA0D=0x2D0D3exactly) — 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 onPatternCmd_EA/EB/EC_Handler_0002d4d6/0002d4b6/0002d4f6andDAT_0002d0d3were 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 againstSoundDriver_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/0x93loop jump-table (§4.2.4) — full algorithm verified againstPatternCmd_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 point0x87fires, and following the real jump-list reproduces the recording's actual next note. Also decoded: the global volume-attenuation termDAT_0002d0bd(§4.1 step 3) — cross-referencing every write site showed it's set to0xF(= 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/0x93loop 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 real0x87except 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+0x2Fmixer-mask byte for all three channels (0x09/0x12/0x24for A/B/C — each is exactly its own tone+noise-disable bit pair), correcting a stale, never-reverified0x00placeholder the companion demo had been using for channel B. - Not yet decoded: the exact byte-for-byte contents of
PSG_PitchSequenceOffsetTable_0002d8a0beyond 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 commands0x83/0x8D); the purpose of the plain "set global" commands0x89/0x94/0x95/0x96's targets beyond their bare addresses; the exact schedule/curve any specific game transition drives throughDAT_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.