Sound driver explorer
Everything on this page was derived directly from the resident sound driver and its data
tables inside a captured ST memory snapshot (loaded in Ghidra as /mydumpat0) — no Hatari
session, audio recording, or external game files are used. The driver implements three independent
mechanisms sharing the same three PSG channels: a music tracker, transient effects
(SFX) "overlay voices" that borrow a channel from the tune, and one resident sample played
through a software PCM DAC. See SOUND.MD for the full write-up this page
demonstrates. An earlier pass mis-dispatched the tracker's command opcodes as 0xE0-0xF8;
§2 below runs the corrected 0x80-0x98 opcode table against real pattern
bytes read live from the dump.
1.Music — the 3-channel tracker
A per-frame register dump replayed through an emulated YM2149: 3 tone/noise channels stepped once per VBL (50 Hz) by the pattern sequencer. See SOUND.MD §4.
2.Pattern disassembly — the corrected 0x80-0x98 opcodes
The three streams below are real pattern bytes, read live from the memory dump at each channel's currently-installed pattern pointer (song 0's three channel offsets, resolved through the driver's own two-level offset tables — not hand-picked or synthesized), then decoded byte-by-byte with the corrected opcode table from SOUND.MD §4.2. Every note, command, parameter, and duration below is read directly off those bytes. Toggle a channel to show/hide its disassembly and mute/solo it in the playback below — both views share the same filter state, the way SOUND.MD §8 argues effects and music already stay independent inside the real driver.
Source addresses: channel A 0x2DC76, B
0x2D99B, C 0x2DB14 (200 bytes each,
SoundDataBlobBase_0002c6c6 + song-0's per-channel offset, resolved via
DAT_0002d8ef/DAT_0002d8f0 — see FUN_0002cfa0, the driver's "install a
song" routine).
How playback is synthesized: each note's volume follows the real envelope
selected by the last 0xD0-0xDF command on that channel — 16 decoded
sequences of direct 0-15 volume steps (SOUND.MD §4.2.2), stepping at that sequence's own measured
rate and holding at its last value once it runs out, exactly as the driver's cursor/hold mechanic
works; a fresh note always restarts its envelope from step 0. That decay-to-hold shape is what
gives notes separation, not an artificial gate. Each channel's 0x8A/0x8B/0x8C mixer commands
are simulated against one shared, accumulating mixer-register byte, using that channel's real
+0x2F mask value read from the memory dump — this can gate a note's tone off, add the
chip's shared noise generator on top, or both; a note whose pitch index falls beyond the decoded
96-entry table plays as noise-only (or silent) rather than a guessed pitch. Loop/jump commands
(0x87/0x93) are shown in the list and followed during playback,
using each channel's real jump-list read from the memory dump (SOUND.MD §4.2.4) — when a channel
hits 0x87 it jumps to the real target address and keeps playing from there, which is
usually a resync point back near that channel's own start but sometimes a genuine variation; this
is bounded (an excerpt still ends rather than looping forever) and stops cleanly if a jump lands
somewhere bytes weren't fetched for. The lists above show this same followed-loop sequence, so
everything played highlights a real row, including whatever a jump landed on.
Every channel's volume also has the driver's real global attenuation term applied
(DAT_0002d0bd, SOUND.MD §4.1 step 3) — normally zero (song install's default), but
exposed here as the Fade slider below since it's a game-triggered fade rather than something
the pattern bytes encode. Vibrato/portamento
(0x82/0x84/0x88) and transpose (0x92)
are applied to the synthesized pitch, straight from their measured parameters. Tick rate
uses song 0's real tempo byte (0x4C) against the driver's carry-accumulator
formula (SOUND.MD §4.1) rather than a flat 50 Hz.
Controls: the Tempo slider scales tick length (10%-150%) purely to
make the excerpt easier to follow by ear — it doesn't change what's decoded, only how fast it's
replayed, and takes effect on the next Play excerpt; Fade works the same way (15 =
off, matching the driver's own no-attenuation default). The currently-sounding note (and the
command/parameter bytes that produced it) highlights live in the lists above while playing;
channel toggles mute/unmute in real time, mid-playback. Each column also has its own
step controls — step one raw byte at a time, independent of the other two channels and of
full playback; landing on a note byte plays it in isolation (using whatever transpose was set
earlier in that channel's stream). Click any row to jump the cursor straight to it, or click
anywhere in a column and use ↑/↓ — both work regardless of whether the step buttons
themselves are still on screen, since the list can get taller than the viewport once you scroll or
expand it. Each column's width is independently resizable — drag its bottom-right corner to widen
it enough to read a line in full (the row scrolls sideways if all three no longer fit side by
side). Height is shared: Expand all opens every list to its full length at once, in sync;
click it again to collapse all three back to a fixed, individually-scrollable height. Each list is
the channel's real unrolled sequence, not just its first 200 bytes: every 0x87
it hits is actually followed (SOUND.MD §4.2.4), so the list keeps going into whatever real pattern
bytes that jump lands on — shown as a gold loop → 0xADDR row — for as long as that's bounded
and fetched (a run stops at a dashed ■ row when it hits 0x8E, runs off the edge
of fetched data, or reaches the excerpt's length/jump limits). The leading number on each row is
that byte's real absolute address, not an offset into a fixed 200-byte block — addresses
jump around once a loop is followed, since a channel's stream can now span several disjoint regions
of the dump. Align by time re-lays the same unrolled bytes out by tick instead: every
channel's row N now starts at the same t-numbered tick (SOUND.MD §4.1's shared
per-VBL clock), with blank filler lines padding out any channel that's mid-note or idle while
another is busier at that moment — and because this is built from each channel's real unrolled
walk, the alignment stays honest across a loop, not just for the first pass through the original
excerpt.
Loop jump-lists
Each row is one channel's real 0x87 jump-list (SOUND.MD §4.2.4), laid
out in visit order — not a merged graph, because the real mechanism isn't a runtime branch choice:
each channel just walks its own list in a fixed cycle, position 0 to N‑1 then back to 0.
Most positions point back to that channel's own pattern start (dimmed own start boxes); a
detour box is a genuine variation, read live from the dump; an unknown box (outlined
red) is a jump target whose bytes were never fetched — a dead branch, not a guess. The gold marker
tracks each channel's real live position while Play excerpt is running, computed from the
same 0x87 events driving the audio, so it moves exactly when a followed jump actually
fires.
Loop-list mesh — true per-edge graph
The same basic-block structure as above, laid out by real Graphviz
(fdp, force-directed — nothing hand-positioned) with every real edge drawn, not
simplified: each block that ends in 0x87 connects directly to every target its
channel's jump-list can resolve to (258 edges total). Node border color is the
owning channel; a thicker off-white border marks the one block reachable by more than one channel;
dashed red marks the one unfetched jump target. Channel A's blocks share no edges with B or C, so
Graphviz's own component-packing (pack/packmode, not a hand-placed
position) stacks that island above the B/C cluster. While Play excerpt (above) is
running, each channel's current block is outlined and gets a small triangular cursor in that
channel's color — driven by the same real event stream as the audio and the strips above, so
all three views always agree.
3.Effects — the 3 overlay voices
Each button below re-synthesizes one SFX from the driver's own measured data: a
fixed tone/noise mix at a fixed period, gated by a linear volume-decay envelope read straight out
of the memory dump (not guessed). Runs on its own AudioContext, independent of the
music player above — muting one never touches the other, which is exactly the separation
SOUND.MD §8 leans on for "keep effects, silence music." See SOUND.MD §5.
| Effect | cmd | Period | Tone Hz | Tone | Noise | Envelope steps | Duration |
|---|
Noise-generator clock isn't stored per effect in the driver (it's shared with whichever pitch the music sequencer's noise-period register currently holds) — this demo uses a representative default. Everything else in the table (period, mixer gating, envelope shape/rate) is read verbatim from the dump.
4.Samples — the PCM8 software DAC
The one sample resident in this dump, extracted byte-for-byte from
PCM_SampleData_15320Bytes_0002f182 (15,320 bytes) and wrapped in a plain 8-bit
unsigned/4800 Hz WAV header — this is the driver's real sample data, not a resynthesis. In the
original driver these bytes are instead streamed through an MFP Timer D interrupt into a
256-entry lookup table that spreads each byte across all three PSG channels' volume registers as a
makeshift higher-resolution DAC (SOUND.MD §7) — starting it always stops the tracker/SFX engine
first, so it never mixes with sections 1-3 on real hardware.
5.Architecture — muting & mixing
The eventual goal is independently muting music/samples while keeping effects, and potentially mixing in external music. The driver's own control flow already makes this tractable — see SOUND.MD §8 for the full reasoning; summarized here:
ChannelStep_*) merged only at the last step — silencing just its contribution
shouldn't disturb an active SFX overlay on the same channel, or the shared noise LFSR.Not yet implemented or verified against a live capture — this is a reading of the existing driver's control flow, not a tested patch.