DUET research: SID-Wizard player.asm deep dive (summary)

author m64
date 2026-08-01
source url local repo duetracker @ commit 85155dd (2026-08-28, not yet published)
created 2026-08-30 · updated 2026-08-30

m64's implementation-grade appendix to s-duet-research-sidwizard: three exhaustive extraction passes over sid-wizard 1.97's player.asm (4062 lines, 64tass), with 6502-exact pseudocode including carry and flag artifacts. All line numbers refer to that file. Assumed build: NORMAL player (PLAYERTYPE=0), exported, 1 SID — with those settings every ghost store is a direct SID register write at that point in the frame. Where the companion summary carries the model, this one carries the exact algorithms.

Key claims#

Feature-flag baseline#

NORMAL player (settings.cfg 222–259) has ON: arp speed, gate-off pointers, PW/filter reset switches and keyboard tracking, HR types, 1st-frame waveform, multispeed, fine (11-bit) filter sweep, filter-control FX, filter shift. OFF: FILTERALWAYS/PULSEALWAYS/VIBSLIDEALWAYS (the EXTRA player turns these on → no table pausing on hard-restart frames), all-ghost-registers, FASTSPEEDBIND (tempo 1–2), DELAYSUPPORT (big-FX $1D/$1E exist only in EXTRA). The MEDIUM player drops vibrato types; LIGHT also drops calculated vibrato.

SETPWID — pulse-table execution (2332–2394), per instrument-tick#

Row [cmd, param, kbtrack]; positions (PWTPOS) and jump targets are instrument-base-relative.

  • Set $80..$FD: PWHIGHO = cmd & $7Fseven bits, not a nibble (the SID ignores bits 4–7 of $D403, but the ghost keeps all 7 bits, so a later sweep runs from the full value); PWLOGHO = param. The row advances the same tick; the next row executes next tick — a set row costs exactly 1 tick. Column 3 is read into PKBDTRK only when a row completes.
  • Sweep $01..$7F: PWEEPCNT counts UP from 0; each tick with counter≠cmd: counter++, one signed 16-bit add of param into PWHIGHO:PWLOGHO; counter==cmd → advance the row with NO add that tick. A row [N,xx,kb] performs N additions over N+1 ticks. $00 is a valid 1-tick NOP that still loads column 3. The ghost can run through the whole 16-bit space; the SID sees bits 0–11.
  • $FE target: self-jump = hold forever; landing on a set row executes it the SAME tick, landing on a sweep row starts next tick. $FF: end — position held, output continues.
  • Output stage, every tick, gate-independent: pulse-hi written to the SID = PWHIGHO adjusted by keyboard tracking — index PKBDTRK+DPITCH into EXPTABH, add the first difference EXPTABH[y]−EXPTABH[y−1] with 6502 borrow semantics (a flat table region yields a −1 artifact, an index wrap +1); the low byte goes out raw; the ghosts themselves are never modified. PTNGATE does not affect the pulse program — it keeps running through release; gate-off merely redirects PWTPOS = inst[$0D] if nonzero.
  • Quirk: PWEEPCNT is reset on row advance but not at note start — residue can shorten a first-row sweep (pulse-width-modulation).

FILTPRG — the filter program (2175–2328), one per SID, controller-gated#

Only the track equal to FLTCTRL executes the table; FLTPOSI/CWEPCNT/CKBDTRK and the cutoff ghosts are per-SID state, and FLTPOSI is an offset into whatever instrument is currently on the controller channel (stale-offset quirk when that changes without a reset).

  • Set $80..$FD: FLTBAND = cmd & $70 → the $D418 high-nibble band bits ($9r LP, $Ar BP, $Br LP+BP, $Cr HP, $Dr notch, $Er BP+HP, $Fr all, $8r none; $D418 bit 7 / 3OFF is unreachable); RESONIB = r<<4$D417 high nibble; cutoff-hi = param, cutoff-low-3-bits = 0. Same one-tick cost and col3-on-completion as the pulse table.
  • Sweep $01..$7F: same N-adds/N+1-ticks counter model on an 11-bit cutoff = (CTFHGHO<<3)|CTFLGHO, adding the signed param with exact low-3-bits + arithmetic-shift-right-3 math (the "fine filter sweep").
  • Column 3 on row completion: $00–$7F keyboard-track up / $90–$FF down (8-bit wraparound); $80–$8F = filter-switch override — replaces the ENTIRE $D417 routing nibble including the external-in bit, and zeroes CKBDTRK.
  • Note-start arbitration (inside STRTSND, 1931–2009; skipped entirely when ctrl bit 7 is set and no instrument was selected this row): read the FIRST byte of the instrument's filter table — $00passive: only the channel's own routing bit is OR-ed on (bit0=v1, bit1=v2, bit2=v3), controller untouched; $FFunfiltered: own bit AND-ed off, and if this track WAS the controller its position parks on the $FF row; anything else → take over: FLTCTRL = this track, FLTPOSI = inst[$0B], own bit on; the first row executes NEXT tick. CWEPCNT is NOT reset by a take-over (quirk). Init sets FLTCTRL = $0F, a "nobody" sentinel — no filter program runs until an instrument takes control. Leftmost priority is emergent: tracks are processed ch3→ch2→ch1, so on simultaneous starts ch1 writes FLTCTRL last and wins.
  • Gate-off ($7E): only the controller redirects FLTPOSI = inst[$0E] (if ≠ 0). Big-FX $0B sets FLTPOSI without a controller check (quirk); FSWITCH is also written raw by big-FX $1F (filter-programming).

Note start: TABLRST, ctrl bits 6/7, counters (1736–2054)#

Tick dispatch: SPDCNT vs tempo (1344–1381) → A=0 TICK_0 (row fetch + HR check for timer 2), A=1 TICK_1 (pattern/orderlist advance + HR timer 1), A=2 TICK_2 (note start), A>2 CNTPLAY. At tick 2 an instrument number in the row masks the control byte with $3Freset happens iff (ctrl bit 6/7 == 0) OR an instrument was selected this row. STRTSND then: SLIDEVIB = ctrl & $30; if ctrl bit 3, SID freq HI = FREQTBH[DPITCH] (hi byte only — the low-byte register keeps the previous note's value, and the FREQ ghosts are not updated either) and WFGHOST = inst[$0F]; WFTPOS = $10; PTNGATE = $FF; ARPSCNT = $FF (the multispeed first-frame marker); ARPSPED = inst[7] (full byte including the multispeed bits); vibrato and chord init; PW reset unless bit 6; filter arbitration unless bit 7; SID.SR = inst[4] first, then SID.AD = inst[3]; pattern/instrument FX; waveform write. No table row executes on the note-start tick. Sweep counters (PWEEPCNT/CWEPCNT) are never initialised at note start.

Hard restart mapping: ctrl bit 1 → HR fires on tick 0, bit 0 → tick 1 (both bits → both ticks); HR = PTNGATE=$FE, gate cleared in the waveform ghost, ghost ADSR ← inst[1]/inst[2]; ctrl bit 2 (staccato) additionally writes waveform $18 and skips the whole chain that tick (hard-restart, adsr-envelope).

Scheduling matrix (NORMAL player)#

frameconditionwhat runs
tick ≥ 3alwaysfull chain: VIBSLIDE → FILTPRG → SETPWID → WFARPTB → writes
tick 0/1no new note pendingfull chain
tick 0/1new real note pendingonly the WF table (vibrato, filter, pulse skipped — even for HR-timer-0 instruments); a staccato-HR frame skips even that. EXTRA player: nothing skipped
tick 2real note → STRTSNDtables reset, no table row executes
tick 2legato / portamentotables skipped this frame
tick 2empty note columnfull chain runs
tick 2note-FX $60–$7E (incl. gate-off)full chain, from redirected positions, same frame

COMMONREGS filter write-out and EXPTABH (1085–1141, 2957–2982)#

$D417 = FSWITCH | RESONIB; $D418 = MAINVOL | FLTBAND; $D416 = EXPTABH[CKBDTRK + DPITCH-of-the-CONTROLLER-track] (+ index carry) + CTFHGHO + FLSHIFT (carries chain); $D415 = the raw low 3 cutoff bits (not kb-tracked). EXPTABH = 11 zero bytes, then FREQTBH continues the table, then a slope tail $F9..$FF,$FF; equal temperament makes indexing at note+k a multiply by 2^(k/12) — the filter tracks with the absolute curve value, the pulse with its first difference. Out-of-range indices (e.g. down-tracking values with a low note) read the adjacent table data deterministically.

The multispeed chain — MULPLY / MULCNTP (1240–1327)#

MULPLY: for ch3, ch2, ch1 call MULCNTP, then COMMONREGS (the filter/volume registers are rewritten every subframe). MULCNTP: return if ARPSCNT ≥ $80 (the note's first frame); then enter the chain by instrument byte 7 — bit 7 → FILTPRG (which falls through pulse and WF), bit 6 → SETPWID, else WFARPTB only. The chain is one fall-through: FILTPRG → SETPWID → WFARPTB → WRPITCH (freq ghost + detune → SID) → WRWFGHO (waveform & PTNGATE → SID). Sweep and arp counters tick per player call (frame × framespeed), not per video frame (multispeed).

WFARPTB — the waveform/arpeggio/detune runner (2396–2609)#

Every call that reaches WFARPTB decrements ARPSCNT; only on underflow does a table step happen, and the counter reloads with ARPSPED & $3F — a row is held (speed+1) calls, speed 0 = step every call. Small-FX $Cx sets the speed AND forces ARPSCNT=$FF (instant step next call). Big-FX $09: WFTPOS = $10 + 3×value.

  • WF column $00–$0F: one-shot per-row speed override — ARPSCNT = value after the step; the waveform is NOT written for such a row. $10–$FD: waveform → WFGHOST masked with PTNGATE on the step entering the row only (but WRWFGHO copies the ghost to the SID every call); on chord rows the WF column is re-read and re-masked every step. $FE target: the target is a raw WFTPOS coordinate (instrument-base-relative, $10+3×row); target ≥ $80 → permanent halt parked on the $FE row; otherwise the target row executes fully in the SAME step. There is no self-jump guard — the $40 "self-jump" note in SWMconvert.c is a converter heuristic for GoatTracker export, not player behaviour (player threshold: $80, line 2422). $FF: parks forever; the last waveform/pitch/detune persist and slide/vibrato own the pitch from then on.
  • Arp column (after the $7F chord check): $00–$7E relative up (index = (DPITCH+v) & $7F), $80 NOP (pitch untouched, including accumulated vibrato/slide), $81–$DF absolute (index = v & $7F), $E0–$FF relative down (2's-complement 8-bit add). A pitch-yielding step is a plain FREQTBL/H lookup into the frequency ghost — which overwrites whatever displacement vibrato/slide accumulated: that is the whole "arp cancels slide/vibrato" mechanism; on hold/end ticks the displacement survives.
  • Detune column: stored to DETUNER on the row's step, $FF = keep — EXCEPT on chord rows, where it is stored unconditionally ($FF = literal +255, quirk Q-3). DETUNER is added at output (WRPITCH: SID freq = FREQHI:FREQLO + DETUNER + a path-dependent carry-in — no CLC, quirk Q-1: deterministic ±1 LSB jitter). DETUNER is never reset by note start or init (Q-5) (detune).
  • Chords (arp $7F): the row does not advance; every table step (same ARPSCNT gating — chord speed == arp speed) re-writes the WF column, stores the detune, and reads CHORDS[CHORDPOS]: $7E → reset chord position to base, WFTPOS += 3, and process the next WF row immediately in the same step; $7F → reset to base and use the first entry; else use the entry (DPITCH-relative) and advance. CURCHORD comes from inst[8] at note start; small-FX $7x / big-FX $07 select (chord-arpeggio).

Gate-off / gate-on note-FX (3111–3175)#

$7E gate-off, on tick 2: if inst[$0C] ≠ 0, ONLY redirect WFTPOS there — PTNGATE stays $FF and the gate bit is NOT cleared; the pointed rows must carry gate-0 waveforms (Q-2); else PTNGATE=$FE and the ghost's gate bit clears immediately. Then inst[$0D] ≠ 0 → redirect PWTPOS (unconditional); inst[$0E] ≠ 0 → redirect FLTPOSI (controller only). $7D gate-on: PTNGATE=$FF, WFGHOST |= 1 — the envelope re-attacks from its current level, nothing else is touched. Note-FX rows count as "no new note" on ticks 0/1 (no HR) and fall through into the instrument/FX columns.

First frames of a note#

Tick 2 writes the freq hi byte only (if ctrl bit 3) plus the 1st-frame waveform (typically $09 = test+gate, freezing the oscillator so the stale low byte is inaudible). WF row 0 executes on the following player call (in multispeed tunes the remaining sub-calls of the note-start frame are suppressed, so row 0 lands on the next frame's first call): its pitch-yielding arp (stock instruments use $00) writes both frequency bytes — the first time the low byte gets the new note — releasing the test-bit lock with the true 16-bit pitch in the same write-out. If row 0's arp were $80 NOP, the previous note's pitch would remain.

FREQTBH / FREQTBL (2961–3001)#

96 entries each, PAL; index 0 is a dummy — C-1 is index 1 ("be aware: C-1 note is the second value!"); index 1 (C-1) = $0116, index $5F = $F810. The full byte dumps are in the raw file. Arp indices masked & $7F can read $60..$7F = bytes physically after each table — reproduce the adjacency for bit-exactness. The startup menu can overwrite entries 1..$5F with NTSC / Verdi-432 / just-intonation tables; FREQTBH's upper half doubles as the exponent table for calculated vibrato/slide and keyboard tracking.

Vibrato, slides, portamento, legato (2085–2171, 2890–2941, 3094–3099)#

SETVIBR: rate nibble f → triangle period = 2f frames; amplitude nibble → FREQMOD = freq16(4×amp + DPITCH − 107) (both via EXPTABH; the same SETFMOD gives slides freq16(speed/2 + DPITCH − 107)). Per frame the triangle adds FREQMOD in its lower half and subtracts it in the upper, straight into the 16-bit pitch accumulator. The four types differ only in phase seed and byte-6 role:

type (ctrl & $30)delayseedtraced trajectory
$00 increasingnone; byte 6 = per-frame amplitude incrementrate/2symmetric, amplitude ramps unbounded
$10 normalbyte 6 + 1 framesrate/2symmetric around the note
$20 "down-oriented"byte 6 + 1rateoscillates ABOVE the note
$30 "up-oriented"byte 6 + 10oscillates BELOW the note

Open question: the source comments call $20 "DOWN-ORIENTED … GUITAR TREMOLO-ARM" and $30 "UP-ORIENTED … STRING STRETCHING" — the opposite of the traced arithmetic; implement as traced, A/B against real playback.

FX interplay: note column $60..$6F and small-FX $8x set the amplitude only (instrument rate kept, phase reset, the delay counter is NOT reloaded), and also cancel a running slide/portamento by restoring the instrument's vibrato type; $9x sets the rate only; big-FX $08 sets both nibbles; $16 selects the type only. Legato/portamento rows skip the vibrato re-init entirely.

Slides $01/$02: exact ±FREQMOD per frame, never self-terminating (16-bit wrap possible), ended only when SLIDEVIB is rewritten. Portamento $03: toward freq16(DPITCH), step FREQMOD+1 (the +1 is a carry quirk — plain slides step exactly FREQMOD; value 0 creeps 1 LSB/frame), borrow-detected overshoot snaps to the exact target and stays pinned; a note on the $03 row sets DPITCH but skips the sound init, and hard restart is suppressed. Note-column $78: fixed speed 110 (≈ noteFreq/20 per frame), and the next real note becomes a glide without retrigger. Legato $3F: FREQMODH = $7F (any target reached in ≤ 2 frames), no STRTSND / ADSR / table / vibrato reset, gate untouched — the pitch snaps on the next frame while the envelope continues (pitch-slide-and-portamento, vibrato).

INSPTFX — small-FX and big-FX (3179–3801)#

All FX run once per row at tick 2. Small-FX (base = the instrument's bytes): $2x/$3x AD nibbles, $4x waveform high nibble, $5x/$6x SR nibbles, $7x chord select, $8x/$9x vibrato amplitude/rate, $Ax main volume (also written to the sequencer-volume shadow so the tick-0 restore keeps it), $Bx FLTBAND = x<<4, $Cx arp speed + instant step, $Dx DETUNER = x×8, $Ex control nibble, $Fx RESONIB = x<<4. Big-FX beyond those: $04 raw waveform ghost (no $FF-legato special case — that is a GoatTracker-conversion notion); $05/$06 raw AD/SR; $09/$0A/$0B table jumps ($0A/$0B also zero the sweep counter; $0B ignores the filter controller); $0E PWHIGHO = vv & $0F (PW bits 8–11 only, low 8 bits kept); $0F cutoff-hi; $16 vibrato type; $17–$1B alias to $1C; $1C FLSHIFT — a signed shift added to the cutoff-hi every frame in COMMONREGS, persistent; $1D/$1E (EXTRA only) shift the track timeline / only the note start by vv frames; $1F raw $D417 (FSWITCH = vv & $0F, RESONIB = vv & $F0), persistent.

Tempo and orderlist FX (3586–3696, 3007–3049)#

TEMPOTBL: positions 0,1 = the main tempo/funktempo pair, 2–7 = per-track pairs, then the programs. Runtime: plain byte = tempo for one row, advance; bit-7 byte = tempo (& $7F) then loop to the program base. $10 main tempo (all tracks), $11 main funktempo (funktempo = a nibble pair: hi nibble plain, lo nibble | $80 → rows alternate hi,lo,hi,lo), $12 main tempo program, $13/$14/$15 the per-track variants. Orderlist FX are executed during the tick-1 look-ahead, hence the delayed shadows: transpose $80..$9F → value − $90, copied into effect exactly on the new pattern's first note; volume $A0..$AF applied at the next tick 0; tempo $B0..$EF likewise ($B0 is a sentinel no-op) (shuffle-funktempo, orderlist).

Quirks (bit-exact port must reproduce)#

Q-1 WRPITCH carry-in not normalized (±1 LSB by code path) · Q-2 gate-off with a WF pointer leaves the gate on · Q-3 chord rows store detune unconditionally · Q-4 no WF-table self-jump/underflow guards · Q-5 DETUNER persists across notes · Q-6 ticks 0/1 with a pending note still run the old note's WF table (with PTNGATE=$FE if HR ran); a staccato-HR frame skips even that. Plus: minimum tempo 3 for notes in NORMAL; SETFMOD's clamp reads FREQTB*[96] (one past the end); a $FE/$FF row reached by a jump is executed as a set command. Open items: the exact raster ordering of main vs MULPLY calls (IRQ-host territory), and the carry-in on multispeed WFARPTB entry (unspecified but deterministic).

Practical takeaways#

  • The load-bearing formulas: vibrato = a triangle straight into the 16-bit pitch accumulator, period 2×rate frames, step freq16(4×amp + DPITCH − 107); slides step freq16(speed/2 + DPITCH − 107) per frame with no termination; portamento the same speed +1 toward freq16(DPITCH) with a borrow-clamped snap; the four vibrato types differ only in phase seed (r/2, r/2, r, 0) and byte-6 role.
  • What a composer's instrument really pays per row: a set row = 1 tick, a sweep row [N,xx] = N+1 ticks with N additions — off-by-one knowledge that makes table lengths add up.
  • The release phase is programmable because gate-off redirects the three tables instead of just clearing the gate — and when the WF gate-off pointer is set, the table rows themselves must carry the gate-0 waveforms.
  • Sync/ring note flags and pattern detunes outlive their note (nothing resets DETUNER or the control bits except a table row or another FX) — a feature and a trap.

Notable quotes#

  • On the frequency tables: "be aware: C-1 note is the second value!" (player.asm 2961/2985).
  • The author's comment at the ADSR write: keep the ADSR write "very close or very far" from the waveform write (1832–1834).

Relevance to the wiki#

Algorithm-level ground truth for every SID-Wizard technique page: exact table execution costs, the filter arbitration protocol, the calculated-vibrato/slide formulas, the FX dispatch with its aliases and quirks. It also corrects two documentation-level claims from SW's own materials: table jump targets and gate-off pointers are instrument-base-relative (SWM-spec.src's comments are wrong), and the WF-table $FE "self-jump ≥ $40" rule is a converter heuristic — the player halts only on targets ≥ $80. Written for duet's player port (m64's tracker, not yet published); the model-level companion is s-duet-research-sidwizard.

Pages touched#

sid-wizard · instrument-tables · pulse-width-modulation · filter-programming · vibrato · pitch-slide-and-portamento · chord-arpeggio · hard-restart · multispeed · shuffle-funktempo · adsr-envelope · orderlist · detune · ring-modulation-and-sync · swm-format · duet (maintainer) · sid-player-routine

source file wiki/summaries/s-duet-research-sidwizard-deep.md · graph