DUET research: SID-Wizard 1.97 formats and player (summary)
m64's engineering research (session 2026-08-01) for duet's pure-Zig reimplementation of the sid-wizard 1.97 player: byte-exact .swi/.swm format specs validated against 324/324 example instruments and 143/143 SW 1.97 module files, and a line-referenced model of the whole player runtime. Sources are Hermit's SW 1.97 distribution — SWM-spec.src (format constants), SWMconvert.c (C enums for every table/FX value; the byte-exact loader ProcessSWMver1()), player.asm (the 159 KB 64tass player — all line refs below are into that file), the 1.9 user manual §III, and the example corpus. SW 1.97's license is WTF ("Do what the frick you want with this code, but mentioning me if you use it would still be nice"). The companion summary s-duet-research-sidwizard-deep carries the same mechanisms at 6502-exact algorithm depth; this one carries the model.
Key claims#
The SWI instrument format (validated 324/324)#
A .swi is a 2-byte C64 load address ($A1 $04) followed by the instrument exactly as it sits inside an SWM; the last 8 bytes are the name (screen codes, space-padded). Max instrument size $80 (128 bytes). Layout (offsets after the load address):
| offset | meaning |
|---|---|
+$00 | control byte: bits 0–1 HR timer (0..2 frames of hard-restart before the note) · bit 2 staccato HR type (waveform $18 test+mute in the HR phase) · bit 3 1st-frame-waveform enable · bits 4–5 vibrato type · bit 6 PW-table reset DISABLE · bit 7 filter-table reset DISABLE |
+$01/+$02 | HR attack/decay, HR sustain/release (defaults $0F / $F0) |
+$03/+$04 | AD, SR written at note start (adsr-envelope) |
+$05 | vibrato rate (low nibble) / amplitude (high nibble) |
+$06 | vibrato delay OR amplitude-increment speed (type-dependent) |
+$07 | WF-arp table speed 0..$3F (0 = every call); bit 6 ($40) PW table at multispeed rate; bit 7 ($80) filter table too |
+$08 | default chord number · +$09 octave shift (2's-complement semitones) |
+$0A/+$0B | PW-table / filter-table pointer (relative to instrument base) |
+$0C/+$0D/+$0E | gate-off pointers into the WF / PW / filter table ($00 = disabled) |
+$0F | 1st-frame waveform (default $09 = gate+test → oscillator-reset trick; $09 in 260 of the 324 examples) |
+$10 | WF-arp table (3-byte rows), then PW table, then filter table |
The WF and PW tables end with a $FF row-start; the filter table's terminator is REPLACED by a size byte (= instrument size without the name). The gate-off pointers and all table-jump targets are instrument-base-relative — the "relative to table" comments in SWM-spec.src lines 89–91 are wrong; the player (NGATEOF 3117–3175) is ground truth.
Table semantics (instrument-tables)#
- WF-arp-detune row
[wf, arp, detune], advanced once per instrument-tick (gated by arp speed; in multispeed tunes the unit is player calls): wf$00..$0F= hold the row N extra ticks;$10..$FDwaveform/control value (gate masked by the pattern gate);$FE xxjump;$FFend (state holds). Arp:$00..$5Fsemitones up ·$7Frun chord ·$80NOP ·$81..$DFabsolute note C-0..A#7 ·$E0..$FFnegative shift; arp activity cancels slide/vibrato. Detune:$00..$FEupward,$FFNOP. - PW row
[cmd, param, kbtrack]:$8X xxset 12-bit width$Xxx(one tick) ·$01..$7F xxsweep (add signed xx per tick for cmd ticks) ·$FE xxjump ·$FFend/hold; col 3 = keyboard-tracking curve. - Filter row
[cmd, param, col3]:$8r..$Fr xxset band (high nibble$8+band bits) + resonance r + cutoff xx ·$01..$7F xxsweep the 11-bit cutoff ("fine filter sweep", normal/extra players) ·$FE/$FFas PW. Col 3:$00..$7Fkeyboard track up,$90..$FFdown,$8xfilter-switch override (x = channel mask). First row$00 00 00= "passive": the channel is filtered but the instrument does not control cutoff/resonance/band. The filter is a shared resource: one track controls it at a time, leftmost channel priority (the player processes ch 3→2→1; the later write wins) (filter-programming). - Chord table:
$00..$7Dsemitones up,$80..$FFdown,$7Ereturn to the WF-arp table,$7Floop forever (chord-arpeggio). - Tempo program table:
$00..$7Fper-row tempi; an entry with bit 7 set is the last (shuffle-funktempo).
The SWM module format (from ProcessSWMver1(), byte-exact)#
2-byte load address, then a 64-byte header: magic "SWM1" · frame speed 1..8 (multispeed factor) · pattern highlight step · mute/solo per channel ($FF = on) · default pattern length · sequence count (= subtunes × 3) · pattern count · instrument count · chord-table and tempo-table packed lengths · driver type (info only) · tuning type (0: 440 Hz, 1: 432 Hz Verdi, 2: just intonation in C) · 40-char author/title. Payload IN ORDER: sequences (data + 1 trailing size byte each) · patterns (data + size byte + row-count byte) · instruments (exact SWI layout) · chord table · tempo table · subtune funktempo pairs (2 bytes per subtune; left byte's bit 7 set → single tempo = left & $7F, else funktempo alternating left/right per row). The file is parsed backwards from EOF — sizes trail their data.
Pattern row encoding (constants in SWMconvert.c 113–163): note byte $00 empty · $01..$5F note (C-0 up) · $60..$6F vibrato FX (amplitude 0..F) · $70..$77 packed empty rows · $78 portamento note-FX · $79/$7A sync on/off · $7B/$7C ring on/off · $7D gate-on · $7E gate-off · bit 7 set → instrument byte follows. Instrument byte: $01..$3E instrument · $3F legato · $40..$7F small-FX; bit 7 → FX byte follows. FX byte: $20..$FF small-FX, $01..$1F big-FX with one parameter byte. Pattern end: $FF.
Orderlist values: $01..$7F pattern · $80..$8F/$90..$9F transpose down/up ($90 = original) · $A0..$AF main volume · $B0..$EF track tempo · $F0..$FD visual separator NOP · $FE end (track halts) · $FF xx jump to orderlist position xx (xx ≥ $80 → subtune jump) (orderlist).
Limits (1SID): ≤ 100 patterns, ≤ 37 instruments, ≤ 8 subtunes, sequence ≤ 126 bytes, pattern ≤ 249 bytes / 248 rows, instrument ≤ 128 bytes.
The four formats: .swm/.sws/.swt/.swq are ONE container (validated 143/143)#
The chip count is the magic's fourth byte ("1"/"S"/"T"/"Q"), which decides the track count (3 per SID) AND the header layout. One field drives every layout difference: the mute/solo block is one byte per channel — it grows 3 → 6 → 9 → 12 and shoves the header around it. 2SID still fits in the low bytes (the gap SWM-spec.src:50 reserved "for later expansions (e.g.: 2SID version mute/solo bytes)"); 3SID/4SID re-pack the whole header and grow it to 65 / 69 bytes. AUTHORPOS == tuneheadersize − 40 in all four — the author field is always the header's tail (the cheapest structural check on a layout guess). The caps SHRINK with chip count (the C64 runs out of memory, not numbering):
1SID .swm | 2SID .sws | 3SID .swt | 4SID .swq | |
|---|---|---|---|---|
| chips / tracks | 1 / 3 | 2 / 6 | 3 / 9 | 4 / 12 |
| header size | 64 | 64 | 65 | 69 |
| max instruments (selectable) | 37 (36) | 30 (29) | 27 (26) | 23 (22) |
| max patterns | 100 | 105 | 105 | 102 |
| max subtunes | 8 | 2 | 1 | 1 |
Orderlists are stored subtune-major, track-minor: track t of subtune s is sequence s*CHN_AMOUNT + t. SubtuneAmount = (SequenceAmount−1)/CHN_AMOUNT + 1. The SID2..4 address-ID bytes index SID-Wizard's own list of C64 addresses ($D420/$D440/$D460 by default, menu.inc:1025-1035) — a hardware setting with no meaning off a real C64.
Multi-SID runtime#
Almost everything is per chip: each SID has its own filter program and filter-controller track, its own $D415..$D418 block (COMMONREGS 1143–1215), and its own main volume (SEQ_FX picks SEQVOL2/3/4 by the track's chip, player.asm 3021–3038). Genuinely global: the orderlist/pattern/tempo machinery. The dispatch order is voice-major, chip-minor — 2SID runs ch3, ch6, ch2, ch5, ch1, ch4 (DOTRACK 964–1004, MULPLY 1248–1290) — because the "leftmost wins" filter takeover is a race among ONE chip's three voices, resolved per chip. The tempo table grows with the track count: the tempo-program base is 8 / 14 / 20 / 26 bytes in, one tempo pair per track.
Subtune truths#
$00is not a value a song carries. The manual's orderlist table starts at$01and the editor enforces it in four places (displayer2.inc,datawriter.inctwice,commonsubs):$00draws as dots, typing0writes$01, a$00cannot be overwritten, INSERT writes$01, and the cursor snaps off a$00back to the track's terminator.$00is the zero-fill after a track's end and nothing else; the cursor cannot park below a terminator.- A terminator only ever sits at the end. There are no END/JUMP keys; typing
$FE/$FFis allowed only where the byte under the cursor is already ≥$FE(datawriter.incchkfeff), refused everywhere else. To move a track's end, delete rows above it. The manual adds: an orderlist must not begin with$FE/$FF, and effects shouldn't precede them — the player'sSEQFXL2safety check makes a$FFdirectly after a seq-FX NOT loop (prevents a freeze), so a transpose right above a jump silently kills the loop. - Every subtune always exists. The subtune keys (
Ctrl+,/.,Shift+F5/F6) only select — a bounds check on a counter (keyhandler.inc:1297). The orderlist memory is pre-partitioned into fixed slots and the depacker fills every slot past the file's count with a lone$FE(packdepack.inc:880); an unused subtune is three orderlists that are nothing but their END. The saver mirrors this: trailing empty subtunes are not written (packdepack.inc:59), soSubtuneAmounton disk is "up to the last non-empty orderlist". A new tune gets a default subtune with patterns 1..3 (sequence n =[n, $FE],menu.inc:375-388). The cap is per format — 8 / 2 / 1 / 1 — and a.swtcarrying two subtunes is a file SID-Wizard itself could not open. Each subtune owns a funktempo pair at the file's tail; the default is$86/$83= single tempo 6 with 3 parked forCtrl+T. - The subtune jump is per track, and the display never follows it.
$FF $8xrunsSETSEQA("SET SEQUENCE ON ONE TRACK", player.asm 2671), rewriting one channel's orderlist pointer — the three tracks can be in three different subtunes at once, a state no single orderlist can display. The editor always draws the SELECTED subtune (getsubaddrreadsselsubt,commonsubs.inc:509); the playing subtune is never consulted for the display. Playback starts on the selected subtune, and selecting a subtune stops playback (playadapter.inc:287, 356). Measured over the 102 distinct modules in the SW tree: 10 have more than one subtune, 9 chain them with jumps, and in 0 do the tracks jump to different subtunes — the split is real in the format and absent from the corpus. ENTERopens the phrase under the cursor;Shift+ENTERopens what the whole ROW plays — one pattern per track.
Player runtime model (line refs into player.asm)#
The player runs once per PAL frame (50 Hz) plus, in multispeed tunes, framespeed−1 extra MULPLY calls evenly spaced across the 312 rasterlines (exporter.asm 1360). Per frame: channels 3→2→1 via DOTRACK (1333), then COMMONREGS (1007) writes the ghost registers in a deliberate order — SR, AD, freq, PW, then waveform, "keeping distance" between the ADSR and waveform writes for reliable note starts — then $D417 = filter-switch | resonance<<4, $D418 = volume | band<<4, $D416 = cutoff-hi + keyboard tracking via EXPTABH + FX shift, $D415 = cutoff-lo (11-bit cutoff; low bits not kb-tracked) (1085–1141).
Row/tick timeline (SPDCNT counts 0..tempo−1 per row, 1344–1384):
- tick 0: fetch the pattern row (1388–1477); a real note with HR timer 2 → hard restart now (1517–1592): ghost ADSR ← HR-ADSR, gate masked off (PTNGATE=
$FE); staccato type also writes waveform$18and freezes the tables that frame. - tick 1: advance pattern/orderlist one row EARLY — seq FX (transpose/volume/tempo) apply DELAYED via shadow variables;
$FEhalt,$FFloop/jump (1626–1728); then the HR phase for HR-timer-1 instruments. - tick 2: NOTE START (STRTSND 1820): DPITCH = note + octave shift + transpose; vibrato type from ctrl; 1st-frame waveform writes the freq HI byte only + WFGHOST ← header
$0F; WF table position ←$10; PTNGATE=$FF; ARPSCNT=$FF(multispeed skips this channel this frame); PW/filter table positions reset unless ctrl bits 6/7 suppress it and no instrument was selected this row (TABLRST 1826). Legato ($3F) and portamento ($03) skip the restart — the pitch is approached with a max-speed slide instead (1810–1816). - other ticks: CNTPLAY (2058): vibrato/slides, then the chained table runners FILTPRG → SETPWID → WFARPTB; ghost waveform = table wf & PTNGATE.
A note's audible start is uniformly 2 frames after its row's tick 0 — the lookahead that makes 2-frame hard restart possible. Tempo < 3 skips the HR phases (the NORMAL player needs tempo ≥ 3; only the EXTRA player's FASTSPEEDBIND folds ticks for tempo 1–2).
Multispeed calls (MULPLY 1240): per channel — skip on a note's first frame; always run the WF table; if instrument arp-speed bit 6 also the PW table; if bit 7 also the filter table (1293–1327); then COMMONREGS. So the WF table always runs at multispeed rate, PW/filter opt in per instrument, and row fetch / HR / note start happen only in the main call — table counters tick per call, not per video frame.
EXPTABH (2957–2982): 11 zero bytes, then FREQTBH (the 96-entry PAL freq-hi table) continues it, then a clamp tail. Equal temperament means indexing at note+k multiplies by 2^(k/12) — the quasi-multiply behind filter keyboard tracking (absolute value) and PW keyboard tracking (first difference). FREQTBH/FREQTBL are C-0-based, DPITCH 0..$5F; tuning-type variants exist for 432 Hz and just intonation.
Vibrato and calculated slides (VIBSLIDE 2085–2171, SETVIB0 2890–2941): the central trick is that EXPTABH is an exponential curve, so vibrato step = freq16(4×amp + DPITCH − 107) per frame and slide step = freq16(speed/2 + DPITCH − 107) — constant musical depth and rate at any pitch ("calculated"). Vibrato is a triangle applied directly to the frequency accumulator, period 2×rate frames. The four types (ctrl bits 4–5) differ only in phase seed and the role of instrument byte 6: type 0 "increasing" = no delay, byte 6 is a per-frame amplitude increment (unbounded); type 1 "normal" = byte 6+1 frames of delay then symmetric; type 2 seeds at rate (traced: oscillates ABOVE the note); type 3 seeds at 0 (traced: BELOW). The source comments name $20 "down-oriented" and $30 "up-oriented" — the OPPOSITE of the traced arithmetic (open question; implement as traced). Slides ($01/$02) never self-terminate; portamento ($03) steps FREQMOD+1 (carry quirk) and snaps to the exact target on overshoot; note-column portamento $78 uses a fixed speed of 110 and turns the next real note into a glide without retrigger; legato ($3F in the instrument column) sets max slide speed — snap next frame, envelope untouched (pitch-slide-and-portamento).
FX dispatch (NOTE_FX 3069+, INSPTFX 3179+): note column $79/$7A set/clear the sync bit, $7B/$7C the ring bit in the waveform ghost — they persist until a WF-table waveform row replaces the byte (ring-modulation-and-sync). All FX run once, at tick 2. Small-FX: $2x/$3x attack/decay (merged with the instrument nibble), $4x waveform hi-nibble, $5x/$6x sustain/release, $7x chord, $8x vib amplitude, $9x vib rate, $Ax main volume, $Bx filter band, $Cx arp speed (+ instant step), $Dx DETUNER = x×8, $Ex control nibble, $Fx resonance. Big-FX highlights: $04 raw waveform ghost (no $FF legato special case in the player); $09/$0A/$0B WF/PW/filter table jump ($0B skips the controller check — quirk); $0E sets PW bits 8–11 only; $17–$1B alias to $1C (cutoff shift, added every frame); $1D/$1E track/note delay exist only in the EXTRA player; $1F raw $D417.
Tempo machinery (1341–1384, 3586–3696, 3007–3049): TEMPOTBL positions 0,1 = the main tempo/funktempo pair (from the subtune funktempo bytes at init), then per-track pairs, then the tempo programs. A plain byte = tempo for one row, advance; a bit-7 byte = tempo (&$7F) then loop. Funktempo = a nibble pair alternating. Row length = tempo & $7F frames. Orderlist FX are fetched one row early at tick 1, hence the delay shadows: transpose lands exactly on the new pattern's first note; volume and tempo apply at the next tick 0.
The tempo table as a saved thing: the file carries only the programs — the main pair, track pairs and the runtime dummy are not saved (packdepack.inc:215). An empty tune saves ZERO tempo-table bytes; the depacker prefills RAM with 62 one-byte $80 "empty programs" and the packer crops trailing empties while keeping embedded ones. Caps: table ≤ 119/113/107/101 bytes (1–4 SID), ≤ 62 programs, ≤ 32 bytes per program. In the editor bit 7 is never typable (high nibble limited to 0–7), INS injects a $00 row, DEL cannot take a terminator; subtune tempo lives in the orderlist context (</> tempo 1, [/] tempo 2, C=+T toggles funktempo = EOR $80 on the left byte). SW's $12/$15 treat operand 0 as a no-op ("NO TEMPOPROGRAM 0", player.asm 3641–3656).
Bit-exactness quirk list#
- WRPITCH carry-in not normalized: SID freq = FREQ + DETUNER (+0/+1 by code path — ±1 LSB jitter tied to table state). 2. Gate-off with a WF gate-off pointer does NOT clear the gate (the pointed table rows must do it). 3. Chord rows store the detune column unconditionally (
$FF= literal +255). 4. The WF table has no self-jump/underflow guards ($FEhalts only on target ≥$80). 5. DETUNER is never reset by note start or init. 6. On ticks 0/1 with a pending note only the WF table runs (NORMAL player). 7. PW/filter sweep counters survive note start. 8. PW keyboard-track ±1 carry artifacts; out-of-table reads are deterministic. 9. Portamento steps FREQMOD+1. 10. Type-0 vibrato amplitude is unbounded. 11. Big-FX$0Bignores the filter controller;$17–$1Balias$1C. 12. SETFMOD's clamp at 203 reads one byte past the table. (6502-exact pseudocode for all of these: s-duet-research-sidwizard-deep.)
Practical takeaways#
- The SW player is a deterministic 50 Hz register-writing state machine — the whole runtime is knowable, and every claim above is checkable against a player.asm line number.
- The four module extensions are one format; the caps (instruments, patterns, subtunes) shrink as chips are added, and a multi-subtune
.swt/.swqcannot exist. - Composers' rules the editor never spells out:
$00in the orderlist is not "pattern 0" but the void after the end; jumps/ends can only be typed over an existing terminator; an orderlist effect placed directly before a$FFloop silently disables the loop; the audible note always starts 2 frames after its row is fetched. - Table speed arithmetic: a WF-table row is held (speed+1) player calls; in an N× multispeed tune that unit is calls, not frames — sweeps and arps scale with framespeed, tempo/vibrato/slides do not.
Notable quotes#
- m64, on the orderlist
$00: "i have NO WAY to enter a 00. 00 can not be in the song, i guess it is what is after end." - m64, on terminators: "you can not place a JMP arbitrary in the orderlist. Only on the END marker. … A JMP/Uxx/END can only be at the end. Else it is refused."
- Hermit's license: "Do what the frick you want with this code, but mentioning me if you use it would still be nice."
Relevance to the wiki#
The deepest documentation of SID-Wizard's internals anywhere: it corrects the manual-level picture on several points (jump-target semantics, the $40 self-jump rule as a converter heuristic, filter-controller arbitration, the tick timeline) and settles the subtune/orderlist behaviour from the editor's own source. For duet (m64's tracker, not yet published) it settled the design: no C64 emulation needed for playback (port the semantics, reSID renders), voice = channel with no NNA, multispeed maps onto DUET's frame lanes, .swi instruments in IT songs bind tick-locked (125 BPM × speed 6 = 50 Hz), 6581 as default model.
Pages touched#
sid-wizard · swm-format · instrument-tables · orderlist · pulse-width-modulation · filter-programming · vibrato · pitch-slide-and-portamento · chord-arpeggio · hard-restart · multispeed · shuffle-funktempo · adsr-envelope · detune · ring-modulation-and-sync · duet (maintainer) · hermit · sid-player-routine · song-init-and-looping