name: ztrackerprime description: zTracker Prime (m6502/ztrackerprime) contributor skill — orienting context, key files, and pointers to deeper reference material. domain: repository-specific source_repo: https://github.com/m6502/ztrackerprime source_platform: github tags: [cpp, sdl3, tracker, midi, cross-platform] triggers: keywords: primary: [ztracker, ztrackerprime, zt, zTracker] secondary: [m6502, tracker, MIDI tracker, impulse tracker, SDL3 tracker]
zTracker Prime Development Skill
Last verified: 2026-06-15. If this date is more than ~7 days old when you load this skill, your first move is to check what merged since:
gh pr list --repo m6502/ztrackerprime --state merged --json number,title,mergedAt --jq '[.[] | select(.mergedAt > "<this date>")]'. Reconcile any architecture / shortcut / invariant claims below against currentmasterbefore acting on them. Then bump this date in the same PR that fixes the drift.What lives elsewhere on purpose: the open-PR list and merged landmarks are NOT in this skill — they go stale fast. For current state run
gh pr list --repo m6502/ztrackerprime(open) orgh pr list --repo m6502/ztrackerprime --state merged --limit 30(recent landings). The skill stays timeless: architecture, invariants, foot-guns, conventions.
What this is
zTracker Prime (m6502/ztrackerprime) is Manuel Montoto's maintained fork of zTracker — an Impulse-Tracker-inspired MIDI tracker originally by Christopher Micali (2000–2001) with contributions from Daniel Kahlin. SDL 3, cross-platform (Windows XP through 11, macOS, Linux). C++17, CMake.
MIDI-only. No sample playback, no audio mixing. Pattern data drives MIDI Out events.
Build
mkdir -p build && cd build
cmake -G "Unix Makefiles" .. # or Ninja, MSVC, MinGW
make -j8 # → build/Program/zt(.exe) or build/Program/zt.app
ctest --output-on-failure # unit-test harness (Linux CI runs this)
extlibs/ (libpng + zlib + lua) must be populated; CI fetches them from upstream tarballs (see .github/workflows/build.yml). SDL3 from Homebrew on macOS (brew install sdl3).
ZT_BUILD_TESTS CMake option (default ON) controls whether tests/ builds. Production binaries are unaffected.
Launching the app on macOS
open <path>/zt.app (or open <path>/zt) — gives the app its own session. Avoid ./zt & from a non-interactive shell; it can exit silently when stdin is detached.
scripts/zt-screenshot.sh (macOS) launches zt.app and captures F1/F2/F3/F11/F12/Ctrl+F12 via screencapture -R + AppleScript. Output at /tmp/zt-shots/. Use it to self-verify layout changes.
Headless framebuffer screenshots
zt has a built-in windowless renderer that dumps its real framebuffer to a PNG — no window, no compositor, no screen-recording permission. This is the most reliable way to verify page layout and the only method that works in headless / remote / CI contexts.
# Script = one command per line. Commands:
# key <chord> e.g. key F11, key shift+F3, key ctrl+s, key space
# mousemove <x> <y> / mousedown <x> <y> / mouseup <x> <y>
# click <x> <y> full left click (DOWN then UP on separate frames)
# wait <ms> let update()/draw() run a few frames
# shot <path> dump the current framebuffer to PNG
# quit tear down cleanly (always end with this)
# Coords are internal-resolution pixels — the col()/row() space, col(N)=N*8.
cat > /tmp/f11.script <<'EOF'
wait 400
key F11
wait 400
shot /tmp/f11.png
quit
EOF
build/Program/zt.app/Contents/MacOS/zt --headless --script /tmp/f11.script
# then Read /tmp/f11.png to eyeball it
--headless sets SDL_HINT_VIDEO_DRIVER=dummy, so it runs anywhere (no
display needed). The binary reads zt.conf relative to its location
(Contents/Resources/zt.conf for a .app) — edit that file to set up
preconditions (enabled flags, loaded song, etc.) before driving the UI.
Mouse injection (commands above) makes mouse-only controls testable. Many
F11 checkboxes/sliders are NOT in the keyboard Tab cycle (F11's Tab only toggles
title↔order-list), so drive them with click <x> <y>. Coordinates are
internal-resolution pixels — the same col(N)=N*8 / row(M)=M*8 space the
widgets use — so a checkbox the page positions at cb->x/cb->y is clicked at
click <cb->x * 8> <cb->y * 8> (aim a pixel or two inside it). Capture a shot
before and after to diff the resulting state change. Because click defers its
button-up to the next pump, a checkbox latches on DOWN and toggles on UP exactly
as it does under a real click.
click spans two frames (widgets latch on DOWN, act on UP), so a wait after
it before shot is good practice.
# 1. Relaunch a fresh binary (kill the old instance first, or you screenshot stale pixels)
pkill -f "zt.app/Contents/MacOS/zt"; sleep 1
open build/Program/zt.app && sleep 2
# 2. Bring zt to the front — screencapture grabs whatever is frontmost, and
# `open` does NOT guarantee focus over the terminal you launched it from
osascript -e 'tell application "zt" to activate' \
|| osascript -e 'tell application "System Events" to set frontmost of (first process whose name is "zt") to true'
sleep 2
# 3. Capture. Full-screen is the robust fallback; -o omits the window shadow, -x silences the shutter
screencapture -o -x /tmp/zt_shot.png
Then Read the PNG to eyeball it. Foot-guns learned the hard way:
cpis interactive. A barecp src dstpromptsoverwrite? (y/n)and, with no tty answer, silently does NOT overwrite — your swap never happened. Use/bin/cp -ffor any scripted overwrite.openthen capture grabs the terminal, not zt, unless youactivatezt first (step 2). The first capture attempt usually shows iTerm — bring zt forward and recapture.- A backgrounded
python3 - <<'PY' ... Quartz ...window-id lookup will fail on stock macOS; don't depend on it. Full-screenscreencaptureneeds no window id.
Screenshotting a specific palette / skin without driving the UI
To verify how a palette .conf (e.g. palettes/hotdog_stand.conf) or a
skin renders, you don't have to click through the Palette Editor. The
startup colours come from the skin named in zt.conf (skin: default),
loaded from <bundle>/skins/<name>/colors.conf — same slot format as
palettes/*.conf. So:
RES=build/Program/zt.app/Contents/Resources
/bin/cp -f "$RES/skins/default/colors.conf" /tmp/colors_backup.conf # back up
/bin/cp -f "$RES/palettes/hotdog_stand.conf" "$RES/skins/default/colors.conf" # swap in
# ... relaunch + capture per the recipe above ...
/bin/cp -f /tmp/colors_backup.conf "$RES/skins/default/colors.conf" # ALWAYS restore
COLORS.Background is the full-screen fill (main.cpp fillRect(0,0,…,COLORS.Background)),
so a wrong Background value shows instantly. Note that palettes/skins
are copied into the bundle by a CMake POST_BUILD step — edit the source
palettes/*.conf (repo root) and rebuild to propagate; editing only the
bundle copy is for throwaway verification and must be restored.
Palette gotcha (PR: Hotdog Stand): scripts/import-schism-palettes.py
maps Schism colour index 1 to Background, but some Schism palettes
(Hotdog Stand) put the desktop fill on index 2 — the importer left
Background black and Lowlight yellow, so the desktop was black and, worse,
black Text on a black desktop was invisible. Frames draw with both
Lowlight (top/left) and Highlight (bottom/right), so a Lowlight that
equals Background makes half of every frame vanish. Hand-tune imported
palettes against an actual screenshot; don't trust the 16→18 slot mapping.
Key files
| File | Purpose |
|---|---|
src/main.cpp |
Entry, main loop, key dispatch, switch_page, CLI parser |
src/CUI_Patterneditor.cpp |
Pattern editor (F2) + 10+ pattern operations + Ctrl+Shift+§ CC drawmode |
src/CUI_Midimacroeditor.cpp / CUI_Arpeggioeditor.cpp |
F4 / Shift+F4 editors |
src/CUI_KeyBindings.cpp |
Unified Shortcuts & MIDI Mappings page (Shift+F2 — moved from Shift+F3 to free that slot) |
src/CUI_CcConsole.cpp |
CC Console (Shift+F3) — Paketti CCizer file load + sliders/knobs send-out + MIDI Learn |
src/CUI_SysExLibrarian.cpp |
SysEx Librarian (Shift+F5) — .syx file send + auto-capture of incoming SysEx |
src/CUI_LuaConsole.cpp |
Lua Console (Ctrl+Alt+L) — Renoise-style interactive console over the embedded Lua engine. Tab cycling, rprint/oprint/help, scrollback wrapping. |
src/lua_engine.{cpp,h} |
Embedded Lua API: object model (zt, song, transport, pattern, track, cells, order list) + notifiers (zt.on/off/fire for play/stop/row/idle). lua/selftest.lua exercises the surface via --lua-test (a ctest target). |
src/ableton_link.{cpp,h} |
Ableton Link tempo + transport sync (PR #159). C API: zt_ableton_link_startup/teardown/pump/defer_play/available/get_tempo. Driven from playback.cpp; configured from F11 Song Config; conf keys in conf.cpp. Rebuilt on the shared phase_chase.h controller. |
src/phase_chase.h |
Shared closed-loop position controller (pure logic, SDL-free, unit-tested), used by both Ableton Link and MIDI clock sync. phase_chase_step() returns a bounded skew that cancels rate error (int-BPM rounding, crystal drift); deadband-tiered feedback. |
src/midi_clock_sync.{cpp,h} |
MIDI clock slave (midi_in_sync / midi_in_sync_chase_tempo). MIDI-in thread only records into atomic g_mclk_* counters; zt_midi_clock_pump() (main thread, once/frame) consumes transport, estimates tempo, phase-locks via phase_chase.h. Six-state zt_sync_state; zt_midi_clock_get_status(). Honors sync_offset_ms. |
src/midi_timestamp.h |
Pure helpers (SDL-free, unit-tested) to use a MIDI hardware timestamp as clock-arrival time. macOS mach_absolute_time → SDL_GetTicks() ms via zt_mach_stamp_to_sdl_ms, sanity-gated (garbage stamp falls back to software clock). |
src/CUI_*.cpp |
Other pages (Sysconfig, Songconfig, Help, InstEditor, MainMenu, Playsong, etc.) |
src/CUI_Page.{h,cpp} |
Base class for pages |
src/UserInterface.{h,cpp} |
Widget classes — CheckBox, ValueSlider, TextInput, Frame, Button, TextBox, ListBox, MidiOutDeviceOpener, SkinSelector, VUPlay. Fix rendering bugs HERE, not in callers. |
src/keybuffer.{h,cpp} |
Key state (KS_ALT/KS_CTRL/KS_META/KS_SHIFT) + KS_HAS_ALT macro. ZT_TEST_NO_SDL guard for SDL-free unit tests. |
src/font.cpp |
print() / printtitle() / printBG() drawing primitives |
src/zt.h |
STATE_/CMD_ enums, page globals (UIP_CcConsole, UIP_SysExLibrarian, ...), layout macros, g_cc_drawmode |
src/conf.{h,cpp} |
zt.conf parsing, zt_config_globals. Keys include ccizer_folder, syx_folder |
src/module.{h,cpp} |
Song data (patterns, tracks, events, instruments, arpeggios, midimacros). instrument carries ccizer_bank (per-instrument Paketti CCizer file). |
src/playback.{h,cpp} |
MIDI output timing, R-effect arpeggio + Z-effect macro dispatch. Z on a *.syx-named midimacro dispatches the file as SysEx (see sysex_macro.h). |
src/winmm_compat.h |
Cross-platform MIDI shim. Exposes midiOut* (WinMM-style API) on Win/macOS/Linux. Adds zt_midi_out_sysex(handle, bytes, len) for SysEx send. macOS zt_coremidi_read_proc parses incoming F0..F7 into zt_sysex_inq_push. |
src/sysex_inq.{h,cpp} |
Process-wide receive queue for incoming SysEx (16 slots × 8 KB; overflow drops oldest). std::mutex protected. |
src/sysex_macro.{h,cpp} |
Helpers for the *.syx-named-midimacro convention: predicate, path resolution against syx_folder, file read with F0..F7 framing check. |
src/ccizer.{h,cpp} |
CCizer parser, .cc-view slider/knob sidecar, .cc-midi MIDI Learn sidecar, folder scan, MIDI byte builder, zt_ccizer_current_file() (read by Pattern Editor for friendly slot names in CC drawmode status). |
src/preset_data.h |
F4/Shift+F4 preset arrays + pure apply functions |
src/preset_selector.h |
Pure listbox decision logic (click / arrow / Space / P-cycle) |
src/page_sync.h |
F4/Shift+F4 widget↔data sync helpers |
src/save_key_dispatch.h |
Global Ctrl-S dispatch decision |
src/editor_layout.h |
Shared character-grid constants for F4/Shift+F4 |
assets/ccizer/ |
Bundled CCizer banks (sc88st, microfreak, minilogue, monologue, Prophet6, deepmind6, waldorf_blofeld, tb03, se02, pro800, polyend_*, midi_control_example) — copied from Paketti. README.md documents the format. |
assets/syx/ |
Bundled SysEx files. request_universal_inquiry.syx = F0 7E 7F 06 01 F7 for first-test handshakes. README.md documents the librarian workflow. |
tests/ |
CTest unit-test executables — 14 suites: presets, selector, page_sync, save_key, keybuffer, ccizer, sysex_inq, sysex_stress, sysex_macro, ccbn_roundtrip, ccenv, keyjazz_map, ableton_link, lua_api. The lua_api suite drives the real zt binary headless (--headless --lua-test) against a scratch song and runs on macOS CI (PRs #154, #156); the rest are SDL-free and run on Linux CI. The MIDI clock phase-lock feature (integration PR #169) adds 4 more (phase_chase, midi_timestamp, midi_clock_sync, midi_clock_estimator → 18). (Count drifts as suites are added — ctest -N is the source of truth.) |
doc/help.txt |
In-app F1 help — update when adding keybinds or CLI flags |
doc/CHANGELOG.txt |
Release notes, chronological |
.github/workflows/build.yml |
5-platform CI matrix; Linux job runs ctest |
For the live PR queue: gh pr list --repo m6502/ztrackerprime.
Page / shortcut map (current)
| Key | Page | Notes |
|---|---|---|
| F1 | Help | |
| F2 | Pattern Editor | F2 again from PE → PEParms. F2 F2 toggles "PianoKey" — the Ableton/Logic piano keyjazz layout (PR #135, keyjazz_piano_layout). |
| F3 | Instrument Editor | F3 row 11 shows CCizer Bank: <basename> (per-instrument bank). F3 again → "Create 16 Channels" popup: fills the next empty instrument slots with the current device on ch 1..16 (PR #136). |
| F4 | MIDI Macro Editor | A macro whose name ends in .syx is dispatched as a SysEx file send by the playback engine; data grid is ignored (hint shown). |
| Shift+F2 | Shortcuts & MIDI Mappings | Was Shift+F3 historically; moved 2026-04-30 to free the slot for CC Console. |
| Shift+F3 | CC Console | Loads CCizer .txt, sliders/knobs send CC out, L to MIDI Learn, B to assign as current instrument's bank. |
| Shift+F4 | Arpeggio Editor | |
| F5 | Play Song | |
| Shift+F5 | SysEx Librarian | Lists .syx files. Enter sends. Incoming SysEx auto-saved as recv_<timestamp>.syx. |
| Shift+F6 | CC Envelope Editor | Per-instrument, CCizer-aware CC envelopes (Schism-style). New page, PR #132. Persisted in the optional INSE chunk; editor loads/saves .env presets from a configurable folder. |
| F6 / F7 / F8 / F9 | Play Pattern / From Cursor / Stop / Panic | |
| F10 | Song Message Editor | |
| F11 | Song Configuration / Order | Hosts the Ableton Link controls (enable checkbox, start/stop-sync checkbox, quantize-offset slider). See "Ableton Link" below. |
| F12 | System Configuration | Includes CCizer Folder text input (binds to ccizer_folder zt.conf key). |
| Ctrl+Alt+L | Lua Console | Interactive Lua over the embedded engine. Sibling MainMenu chords: Ctrl+Alt+K (Keybindings), Ctrl+Alt+N (New Song). |
| Ctrl+Shift+§ | CC drawmode toggle | While ON, incoming CC writes Sxxyy and PB writes Wxxxx at the cursor row. Per-session UNDO_SAVE. [CC DRAW] badge shown top-right of Pattern Editor while active. Extended by PRs #123–#129: mouse drag-to-draw drawbars (MD_CC_DRAW), CCizer-slot cycling, one-key arm-and-draw, double-click reset, Ctrl+F2 slot cycle, keyjazz audition while mouse-drawing. Windows fires the toggle via Ctrl+Shift+`` ``. |
Coordinate system
row(N)=N * 8pixels (Y offset). Named for character rows but returns pixels.col(M)=M * 8pixels (X offset).print(x, y, str, color, drawable)— x first, y second.- Some legacy code calls
print(row(2), col(13), ...)— this works only becauseFONT_SIZE_X == FONT_SIZE_Y == 8. Confusing; don't propagate.
Layout macros: INITIAL_ROW=1, PAGE_TITLE_ROW_Y=9, TRACKS_ROW_Y=11, SPACE_AT_BOTTOM=8.
CCizer + SysEx architecture (2026-04-29 → 2026-04-30, PRs #78–#84)
Seven-PR feature stack landed end of April 2026 wiring Paketti-style CCizer banks and a complete SysEx librarian into zTracker. The architecture has three layers worth keeping in mind:
1. Per-page (UI)
- Shift+F3 CC Console (
CUI_CcConsole.cpp) — file list of.txtCCizer banks, slot grid with sliders/knobs, V toggles slider↔knob, L to MIDI Learn ((status, data1)→ slot), U to unbind, B to assign loaded file as current instrument's bank.[/]adjusts MIDI channel. - Shift+F5 SysEx Librarian (
CUI_SysExLibrarian.cpp) — file list of.syxfiles insyx_folder. Enter/S sends. Receive log auto-pops fromzt_sysex_inqand writes new files asrecv_<TS>.syx. R rescans, C clears log. - Pattern Editor CC drawmode (
CUI_Patterneditor.cpp) — Ctrl+Shift+§ flipsg_cc_drawmode. While on, the existing MIDI-in pump in update() routes0xB0..0xBF/0xE0..0xEFtoupdate_event(...)writingS<cc><val>/W<14bit>. Cursor advances by EditStep. Single UNDO_SAVE per session (file-staticg_cc_draw_session_snapped).
2. Process-wide
g_cc_drawmode(inzt.h) — toggle.zt_ccizer_current_file()(inccizer.h) — last-loaded file pointer, so the Pattern Editor's drawmode status can show friendly slot names ("Cutoff = 92") instead of raw CC numbers.zt_sysex_inq_*(insysex_inq.h) — ring buffer of incoming complete SysEx messages. Filled by the macOS CoreMIDI read_proc; drained byCUI_SysExLibrarian::drain_recvevery update().MidiOut->sendSysEx(dev, bytes, len)— virtual onOutputDevice, default no-op.MidiOutputDeviceimpl asserts F0/F7 framing and callszt_midi_out_sysexinwinmm_compat.h. Backed by CoreMIDIMIDIPacketListAdd(256-byte chunks), WinmidiOutLongMsg, ALSAsnd_seq_ev_set_sysex.
3. Persistence
ccizer_folderzt.conf key — folder of.txtfiles. Resolution: override →<exe-dir>/ccizer→ macOSResources/ccizer→ Linuxshare/zt/ccizer→~/.config/zt/ccizer. F12 page exposes the text input.syx_folderzt.conf key — folder of.syxfiles. Resolution: override →./syx(next to binary or in.app Resources) →~/.config/zt/syx. Auto-created.<file>.cc-viewsidecar — per-CCizer slot slider/knob choice. Written next to the.txt.<file>.cc-midisidecar — per-CCizer slot MIDI Learn binding. Format:<slot> <status hex> <data1 hex>.instrument.ccizer_bank[256]— per-instrument CCizer file. Persisted via new optionalCCBNchunk in.zt. Format:short int countthen per entryunsigned char inst_idx, short int length, bytes path. Fully backward+forward compatible: old zTracker reading new.ztskips the unrecognized chunk viareadblock's built-in advance-past behavior.
SysEx-by-filename convention (sysex_macro.h): a midimacro whose name ends in .syx (case insensitive, requires len > 4) is dispatched by the playback engine's Z effect handler as a file send. The macro's data array is ignored. No .zt format change — the existing MMAC chunk already round-trips the name. Old zTracker sees an empty data array and silently does nothing — safe forward-compat.
Cross-platform status (verified 2026-05-02):
- SysEx send: works on macOS (CoreMIDI
MIDIPacketListAdd), Windows (WinMMmidiOutLongMsg+ MHDR_DONE poll, PR #87), Linux (ALSAsnd_seq_ev_set_sysex). - SysEx receive: works on macOS (CoreMIDI read_proc parsing F0..F7) and Windows (
MIM_LONGDATAaccumulator, PR #90). Initial Linux ALSA MIDI input landed in PR #113 (marked "needs hardware verification") — so the Linux-in platform layer is no longer a bare stub, but confirm SysEx-receive-on-Linux against that path on real hardware before relying on it. - Audit cluster (PRs #87–#93) hardened the foundation: Windows MHDR_DONE polling, macOS heap-alloc on receive, recv_seq survives restart, recv rotation, *.syx macro caching at load, lifetime docs, CCBN roundtrip test.
Test suites added:
tests/test_ccizer.cpp— 51 checks: parser, comments/blanks, out-of-range, view sidecar round-trip, MIDI byte builder, folder resolution, dir scan.tests/test_sysex_inq.cpp— ~25 checks: empty / push-pop / FIFO / overflow / buffer-too-small / invalid input.tests/test_sysex_macro.cpp— ~20 checks: predicate, path resolution, valid/malformed/oversized/missing file read.
Lua scripting (PRs #148–#157, 2026-05-31)
An embedded Lua engine + interactive console landed across ten PRs at the end of May 2026. Treat it as a first-class subsystem.
- Engine (
src/lua_engine.{cpp,h}) — exposes a Renoise-flavoured object model:zt(top-level),song,transport,pattern,track, plus full cell access, the order list, note names, and constants. Notifier API:zt.on(event, fn)/zt.off(...)/zt.fire(...)forplay/stop/row/idleevents, pumped from the main loop. MIDI macros expose:send()(PR #157). - Console (
src/CUI_LuaConsole.cpp, Ctrl+Alt+L) — Tab cycling,rprint/oprint/helphelpers, Renoise-style object printing, and scrollback that wraps (PR #153) rather than clips (PR #152) long lines. - Self-test —
lua/selftest.luaexercises the whole API surface; runs via--lua-testand is wired into ctest, and the macOS CI jobs run it (PRs #154, #156). When you add or rename a Lua binding, updatelua/selftest.luain the same PR — CI will catch a regression there.
Ableton Link (PR #159, merged 2026-06-10)
Tempo + transport sync with Ableton Live and other Link-aware apps over the local network.
- Core (
src/ableton_link.{cpp,h}) — C API:zt_ableton_link_startup/_teardown/_pump/_defer_play(row, pattern, pm)/_available/_get_tempo. Pumped fromplayback.cpp; the pump can movesong->bpmunderneath the UI, so F11 re-reads it each frame. - Config (
conf.cpp, keys persisted inzt.conf):ableton_link_enable(OFF by default — no surprise LAN traffic),ableton_link_start_stop_sync(ON by default; only effective once Link is enabled),ableton_link_quantum(default 4 = one bar of 4/4),ableton_link_offset_ms(fire quantized starts early by this much). - UI — F11 Song Configuration (
CUI_Songconfig.cpp) hosts the enable + start/stop-sync checkboxes and the offset slider. - Precedence: if both Ableton Link and MIDI Sync are enabled, Link wins (
conf.cpp).
MIDI clock sync & phase-lock (integration PR #169, documented ahead of merge)
External MIDI-clock slave sync built on the same phase_chase.h controller as Ableton Link, so a fractional-tempo master cannot drift the engine. midi_in_sync follows start/stop; midi_in_sync_chase_tempo derives tempo and phase-locks; the --midi-clock CLI flag turns both on. The engine is the merge of two independent rewrites that converged on an identical phase_chase.h: Chris Micali's tempo estimator (cmicali/midi-sync-improvements) under the hardening branch's observability + robustness.
- Observer architecture (
midi_clock_sync.{cpp,h}) — the MIDI-in callback thread only records intostd::atomiccounters (g_mclk_*, same advisory contract asplayer::stop_count); all decisions run inzt_midi_clock_pump()on the main thread once/frame, next tozt_ableton_link_pump(). ThreadSanitizer-clean (TSan CI job). Relies only on per-counter atomicity, tolerating a bump seen a frame late. - Tempo estimate (Chris's) — least-squares regression over a ~1 s sliding window of clock-arrival samples (a jitter-delayed edge carries only 1/n weight), plus retune anti-flap hysteresis (>0.6 BPM held, >3.0 BPM immediate) so a master on a rounding boundary like 124.5 doesn't flap the nominal.
- Position, not just rate — 24 clocks/beat = 4 subticks/clock; clocks-since-START is the exact expected position (MIDI-clock analogue of Link's
beatAtTime), fed throughphase_chase_step()intoplayer::chase_skew_us. - Hardware timestamps (
midi_timestamp.h, macOS) — CoreMIDI packet stamp →SDL_GetTicks()epoch removes callback scheduling jitter and feeds the estimator's arrival edge; sanity-gated so a bad stamp falls back to the software clock. - Hardening — warm re-lock + snap-on-discontinuity recover cleanly from a dropout or transport jump.
- Observability — six-state lock model (
zt_sync_state: off/waiting/dropout/transport/chasing/locked) viazt_midi_clock_get_status()→ F11 lock meter + Luazt.transport.sync_state/sync_bpm/sync_offset_ms+ thesyncnotifier.sync_offset_msconf key (legacy aliasableton_link_offset_ms). - Tests —
phase_chase,midi_timestamp,midi_clock_sync, and Chris'smidi_clock_estimator(the 4 suites that take the harness from 14 → 18 when this lands).
INVARIANTS for any new or modified page
These rules are non-negotiable. Every one of them was learned the hard way during the CCizer + SysEx + CC-Console work (PRs #78–#84, #95, #96). Don't re-learn them.
Every key handler that mutates visible state ends with
need_refresh++;— includingenter()and any background pump that changes what should be on screen. Forget this and arrow keys silently mutate state without a redraw; the page looks frozen until some other path bumps the flag.main.cppgatesActivePage->draw()onneed_refresh != 0.Interactive elements are real widgets, not ASCII art. A slider is a
ValueSliderregistered withUI->add_element, drawn byUI->draw(S), mutated byValueSlider::mouseupdate, and absorbed by the page'supdate()reading thechangedflag. Never shipprintBGtext bars as a stand-in for "we'll iterate later" — visually identical in one screenshot, completely unclickable to the user. The pool-of-N pattern (pre-allocateNwidgets in the ctor, position the visible window per-frame, hide off-screen ones withxsize=0) is the idiom for variable-count grids;CUI_CcConsole.cpp::position_slidersis the canonical worked example.Any list pane uses a real
ListBoxsubclass. Never hand-draw> filenamewith a custom highlight. F11 SkinSelector / F4 / Shift+F4 / Shift+F3 all share one pattern, documented inreferences/list-pane-idiom.md. New list-driven pages copy that pattern verbatim — anonymous-namespace: ListBoxsubclass,is_sorted=true,use_checks=true,use_key_select=true, Space-applies override onupdate(), never callListBox::OnSelectfrom the subclass.Per-frame
need_redraw = 1on always-visible widgets.UserInterface::drawskips elements withneed_redraw == 0. If any sibling firesneedaclear++the whole element area is cleared and stale-need_redraw widgets vanish. The page'supdate()(orposition_sliders()) stampsneed_redraw = 1on the listbox, the grid-focus stub, and every visible pool widget, every frame. Worked example: PR #96.Each visual column is its own
print/printBGanchored to its owncol(CC_*_COL)constant. Never pack adjacent columns into one packedsnprintf— adjacent fields will smash into each other (PBPitchbend,LeUani) the first time a string is longer than expected. Headers and data rows reference the same constants so they stay aligned.Tab cycles
UI->cur_elementbetween IDs. Page-level "focus" is derived fromcur_elementeach frame, not stored. Use a 1x1UserInterfaceElementstub (e.g.MmGridFocus,CcSlotGridFocus) for non-list panes that need Tab focus.Pages whose own ESC handler matters add their
STATE_*to the global ESC exclusion list inmain.cpp::global_keys(the list withSTATE_HELP / ABOUT / LUA_CONSOLE / SONG_MESSAGE / KEYBINDINGS). Otherwise the global handler eats ESC first and the page's "ESC cancels capture" hint is a lie. PR #95 was a one-line fix forSTATE_KEYBINDINGS.Visual verification before claiming done. Build green + ctest green ≠ "the page works." Use the headless framebuffer screenshots (
--headless --script ... shot, see "Launching the app") when you need to visually verify features. Drive each user-visible control:keyfor F-keys / arrows / Tab / Space / Enter / Esc, andclick <x> <y>for mouse-only widgets, thenshotandReadthe PNG. If you genuinely cannot do it, say so explicitly — don't ship hope.
The full list-pane construction pattern (with code) lives in references/list-pane-idiom.md. Read it before adding any new page that has a list, a grid, or both.
Deeper references (load on demand)
references/list-pane-idiom.md— REQUIRED READING before adding any list / grid page. The full pattern:ListBoxsubclass + grid-focus stub + Tab cycling + per-frameneed_redraw=1+ column-per-print + global-ESC exclusion + visual-verification gate.references/foot-guns.md— recurring bug classes: ListBox mousestate fragility, widget-upstream rule, vanishing-sibling onneedaclear, user-reported-inputs-as-ground-truth.references/keyjazz-slot.md— Shift+F4 keyjazz UIE pattern.references/test-harness.md— sdl_stub / module_stub /ZT_TEST_NO_SDL.references/cli-flags.md—zt_parse_cli+ Copy Relaunch Command.references/keyboard.md— Cmd→KS_META,KS_HAS_ALT, § remap, Cmd-Q strip.references/conventions.md— coding conventions, status-bar diagnostic idiom.recipes/add-test-suite.md— adding a new CTest executable.glossary.md— abbreviation lookup (TPB, MMAC, ARPG, KS_/CMD_/STATE_, etc.).effects.md— pattern-effect letter table.
PR discipline
- Push topic branches to
origin, open PRs againstmaster, don't merge without maintainer approval. - Keep PRs small and focused: 1 feature, ~30–300 LOC.
doc/CHANGELOG.txtgets a section per PR.- Tag AI-assisted commits:
Co-Authored-By: Claude <noreply@anthropic.com>(or current model identifier). - Don't duplicate content across PRs.
Bug reports = branch + PR + push, no asking
When Esa reports a bug, the default response is: investigate → fix → branch → commit → push → open PR. Do not ask "want me to fix?" or "should I open a PR?" — that's the implicit ask. Just do it.
The only acceptable preamble is one or two sentences naming the root cause and the fix, then go.
- Branch:
esa/<short-descriptive>(e.g.esa/cc-console-file-switch-leak). - Build clean + run
ctestbefore pushing. - PR body: summary, root cause, fix, test plan checklist. Quote the user's own description of the symptom verbatim if they gave one — that anchors the PR to the report.
- Push to
origin; Esa has write access.
Ask for clarification only if the bug report itself is ambiguous about the symptom — never about whether to fix it. Reference: PR #106 (CC Console file-switch slider value leak, 2026-05-02).
Paketti as a feature reference
Paketti (a Renoise tool) is a reference for tracker quality-of-life features ported / portable to zTracker. Already landed: Replicate at Cursor, Clone Pattern, Humanize Velocities, MIDI Macro + Arpeggio editors. Future candidates: Fill-with-note-at-cursor, Find/Replace note, Transpose selection, Groove templates, Chord memory. zTracker is MIDI-only, so sample-editor features are N/A.
Update this skill (and the relevant reference file) when the project's context shifts materially: new architecture area, new feature region, new recurring foot-gun.