Frontends, wasm port, debugger, harness
The emulator core is one Go module. This document covers everything that drives it: the desktop GUI, the headless CLI, the browser wasm surface, the debugger backend and its three surfaces, the test harness, and the builds.
Diagrams: frame-loop.drawio, wasm-integration.drawio, debugger-surfaces.drawio.
Entry points
cmd/zxplay_go/entry_desktop.go(//go:build !js):main()callsdesktopMain()ingui_desktop.go, which parses flags, handles the dev diff modes, branches to headless, or builds the Fyne app.cmd/zxplay_go/entry_js.go(//go:build js && wasm): registers the wasm exports and parks forever. A sleeping timer goroutine keeps the Go scheduler alive so exported callbacks stay serviceable.
The emulator struct in main.go is the machine assembly used by every surface: CPU + memory + ULA + keyboard + audio + peripherals, plus the Next stack when ModelNext (wired in next.go).
Desktop loop
(*emulator).run() drives a 20 ms wall-clock ticker (50 Hz). Each tick: debugger pause gate, then one frame (or several: RZX playback/record, ZX8x and SAM take their own paths, and fast-tape turbo runs multiple frames per tick while a loader is actively polling port $FE, with audio muted). Rendering is separately gated to ~50 Hz and posted to the Fyne canvas. Audio is pulled by oto on its own callback; the ring buffer absorbs the rate mismatch.
Headless
--headless runs the same machine with no UI: run N --frames, drive the guest with --press-key "KEY@FRAME,..." (matrix key names plus the Kempston joystick buttons kfire/kup/kdown/kleft/kright — some game menus only accept joystick fire), save --save-screen PNGs, --dump-state, watchpoints, memory dumps, uninitialised-read detection, crash-detect heuristics, time-travel, snapshot-every, and trace channels. Env hooks reproduce browser behaviours headlessly (ZX_GO_RUN_BAS_FILE runs the same importAndRunBas path the Play page uses; ZX_GO_RUN_NEX_FILE=path[@frame] runs the same importAndRunNex path the browser's game-zip open uses, staging the file under its parent directory's name for the Browser launch — the runner behind the Next game-compatibility triage in docs/compatibility.md). This is the CI surface: the boot tests, screenshot oracles and soak tests all run through it.
The wasm surface
Build: GOOS=js GOARCH=wasm go build ./cmd/zxplay_go via scripts/build-wasm.sh → dist/zx.wasm + wasm_exec.js. The Go wasm module runs on the browser MAIN thread. There is no Go-side loop: the CPU advances only inside zxFrame() calls from the page. While a tape load is in progress (deck playing with blocks left), one zxFrame() call runs as many frames as fit a ~12 ms wall budget (the desktop tick's fast-tape turbo upgraded to a budget loop; shared gate/state via tapeTurboActive/tapeFrameHook, #192) — with the LD-EDGE trap making loader frames nearly free, a multi-minute custom-loader tape loads in a few seconds. The page's audio-clock pacing is unaffected because the render/audio flush still runs once per call, pushing one display frame's worth of (muted) samples; the loader-activity auto-pause is what ends the burst window when a program stops loading.
Exports (wasm_js.go, all globals; zxReady is set last):
| Group | Exports | |
|---|---|---|
| Boot / machine | zxRegisterROM(name, bytes), zxBootNext(sd), zxBoot48(), zxBoot128(), zxBoot(model), zxReset(), zxModel() | |
| Frame / audio | zxFrame(dst?) → {w,h,debug,paused,pc}, zxPullAudio(dst) → n, zxFastBoot(), zxMacroActive(), `zxMacroProgress() → 0..1 \ | -1` |
| Input | zxMatrixKey(row, mask, down), zxType(rune), zxKeyName(name, down, shift), `zxJoystickType(name) → "" \ | err, zxJoystickState(bits)` |
| Tape | zxLoadTap, zxTapeInsert, zxTapePlay, zxTapeStop, zxTapeStatus, zxTapeTraps | |
| Programs / files | zxLoadSnapshot(bytes, ext), zxRunNex(name, bytes), zxRunBas(name, bytes), zxPutFile(path, bytes) | |
| Debug | zxDebugAttach/Detach, zxDebugCmd(line), zxDebugState, zxDebugMem, zxDebugDisasm, zxDebugPaging, zxDebugStepFrame | |
| Diagnostics | zxAudioDebug, zxAudioLevel, zxPerfSplit() → {execMs, renderMs, frames} (drains the zxFrame execute-vs-render wall-time accumulators; GoEmulator.js polls it once a second for the window.__zxgoExecMs / __zxgoRenderMs readouts, #187) |
Boot calls run in goroutines (audio setup blocks until the JS loop turns), so the page polls zxModel() for completion. zxPutFile stages files onto the SD image before zxRunBas/zxRunNex (their reboot re-reads the card). zxRunNex picks its launch route from the name: a FOLDER-QUALIFIED name ("TX-1696/main.nex", the game-zip flow) stages the file under that folder and drives the NextZXOS BROWSER to launch it — cursor rows computed from the card's real sorted listings (sdcard.ListDir) — because some games only run as <original folder>/<original name> (TX-1696) or F_OPEN their own filename; a ROOT-ANCHORED name ("/program.nex", the IDE compile flow) stages at the card root and drives the typed .nexload command line so the program's directory stays the root its zxPutFile-staged assets resolve against; a BARE name (a .nex opened directly) is imported as the fixed root /zx.nex and takes the same typed launch (#184 — the fixed 8.3 name sidesteps the ~N aliases the typing macro cannot produce). On js builds pkg/next/install disables disk access and takes ROMs via InjectROM.
The consumer, packages/emulator/src/zxgo/GoEmulator.js, is a drop-in replacement for the JSSpeccy3 Emulator class:
- Pacing: requestAnimationFrame, audio-clock paced against
AudioContext.currentTimeat 44.1 kHz with a self-widening ~60 ms cushion, wall-clock fallback while the context is suspended, and the fastboot fast-forward during Next boots. - Audio:
zxPullAudiodrains mono int16 into an AudioWorklet served as a real static file (CSPscript-src 'self'blocks blob/data worklet modules). - Keyboard: a worker-shaped shim feeds the JSSpeccy3 KeyboardHandler's
{row, mask}messages intozxMatrixKey. - Gamepad (r77, #161):
src/zxgo/gamepad.jspollsnavigator.getGamepads()once per rAF tick — before the frame executes, so a button press isn't a frame late — and pushes the FPGA's 12-bit i_JOY vector (bits 11..0 = MODE X Z Y START A C B U D L R) throughzxJoystickState. The transport is STATE-based: the core diffs snapshots, so a dropped poll cannot strand a held direction. Standard-mapping pads map by meaning (dpad 12-15, face buttons onto the Megadrive six); unrecognised devices fall back to axes 0/1 plus "any button fires", since their button order is device-specific —window.__zxgoPads()dumps raw pad state for mapping work. The interface is chosen withzxJoystickType; the default 'None' means "decide for me" and resolves to Kempston on EVERY model, because a Kempston interface is fitted at construction on classic machines too (newEmulator) and the Next FPGA always decodes port $1F. What a selection DOES differs by model (r98, #202): on classic machines Sinclair 1 (keys 6-0) / Sinclair 2 (keys 1-5) / Cursor inject matrix keys frontend-side, while on the Next every scheme rides the vector and the selection is written to the machine's own NR$05 joystick-1 mode (applyNextJoystickMode; Kempston maps to the MD-1 superset) — the FPGA model routes it from there, to the ports or to membrane keypresses (pkg/ula joymembrane.go). 'None' leaves NR$05 alone. Boots reseed NR$05, soGoEmulator.applyJoystickTypere-runs after every boot path. That timing is load-bearing, not incidental: games probe for a Kempston by polling $1F in a tight loop and judging whether it reads consistently (Manic Miner does exactly 256 reads at startup), so the interface must be present before the guest's FIRST read. An earlier r77 attempt to DETECT the port being polled and arm the interface mid-loop made such games conclude "no joystick" and stop looking — the failure it was meant to fix. Safe because the decode is A7..A5 low while the conventional floating-bus port is $FF (A7..A5 high), so games sampling the bus on purpose (Arkanoid, Sidewize) are unaffected. NOTE there is deliberately NO joystick picker in this package's menu bar: every consumer callshideUI()on mount, so anything added there is unreachable. Each app owns its own picker instead, driven through thesetJoystick/onJoystickChangehandle — a Joystick menu in both apps'Nav, persisted in their Redux stores under thejoysticklocalStorage key, threaded intoemuParamsat construction so the choice is live before any program runs, and mirrored back throughonJoystickChangeso engine-initiated changes keep the checkmark honest (the same two-action pattern asmachine). The apps offer the four real interfaces and default to Kempston; 'None' is not exposed, since it now resolves to Kempston everywhere and a second entry with identical behaviour would be a lie. Desktop (Fyne/GLFW) has no pad source yet. Diagnostics:window.__zxgoJoy()reports the host-side and core-side vectors CUMULATIVELY (live state reads idle by the time anyone can inspect it), whether they agree (boundaryOK), how often the guest read the Kempston port, how often it did so while a button was held, and the busiest ports no device answered. Between them these separate "input never arrived" from "the game never looked" from "the game had the input and ignored it" — the last being common and NOT an emulator fault: many titles require selecting the joystick in their own control menu (Arkanoid needsJ) and will ignore a perfectly emulated stick until you do. - Assets: the PRIMARY source is currently the staged
/next/assets (ROMs + zipped trimmed image) —SPECNEXT_DISTRO_PATHinGoEmulator.jsis null until SpecNext host a small emulator-targeted distro (r97). Setting it restores the r60 distro flow as primary: the official SpecNext distro zip (ROMs + full card image) fetched through the same-origin/specnext/Caddy proxy route (specnext.com sends no CORS headers; the CSP pinsconnect-src 'self') and kept in the browser Cache API so it downloads once, with staged assets as the automatic fallback. Staged assets are also the only source gif-service's Node harness uses. Either way the image is STREAMED into a sparse card (r55): JSZip'sinternalStreamfeeds chunks tozxSdIngestBegin/ChunkandzxBootNext()mounts the result — the flat image is never materialised; only its real content is resident (~136 MB for the full official card, ~8 MB for the staged trimmed one). On the distro pathzxSdPrepDistro()runs between ingest and boot: it deletes the pristine card's/nextzxos/autoexec.1st(first-boot welcome pager, re-shown every boot until disabled — it stalls the menu macros) and seedsmachines/next/config.iniwhen absent (cmd/zxplay_go/distro_prep.go); staged/user images mount untouched. The zipped bytes are kept and re-inflated per boot so a machine switch gets a fresh card — and every game load does too (#186):openNexGameZip/openNEXFilecallbootNext()unconditionally, so a previous load's staged folders and in-game writes never leak into the next one (a boot already in flight is joined, not duplicated). Fallbacks: a zip without size metadata inflates flat; deployments with only the rawtbblue.mmcmount it flat viazxBootNext(bytes). The boot drives the UI's loading pill through its stages with byte-accurate fractions (Downloading NextZXOS → Preparing SD card → Starting NextZXOS, r61); a boot that opened the pill closes it, one running under a game-launch overlay leaves closing to that flow. ENGINE_REVis logged at boot; bump it on engine or translator changes (webpack-dev-server does not reliably rebuild workspace package edits).
The debugger: three surfaces, one backend
remoteDebugger (cmd/zxplay_go/debugger.go) is the shared backend. newDebuggerCore installs the CPU hooks with no listener; handleCommand(line) is the single command dispatch used by all surfaces, ~130 commands across the *_cmd.go files.
- GUI: the Fyne visual debugger (
pkg/debugger,!js): registers, Z80+Z80N disassembly, hex view, paging diagram, backtrace, M1 history and heatmap, NextReg panel, time-travel, and the Next inspectors (palette, sprites, Layer 2, tilemap). - Telnet:
--debugger-port=N, one command per line, scriptable. - wasm:
zxDebugCmdreuseshandleCommandunchanged; a stand-in goroutine supplies the pause handshake, and pause transitions are reported throughzxFrame's return fields so the page can stop its loop and emitdebugpause.
Shared state that makes this work: one BreakpointSet (pkg/debugger/bpset.go, atomic copy-on-write map, lock-free on the M1 hot path) handed to every surface, shared register watches, the M1 history ring, tracepoints, and the pause handshake (WaitIfPaused + resume/step/ack channels).
Source-level breakpoints for the zxcode IDE:
- Interpreted BASICs (
basicbp_cmd.go): a RAM-write hook watches the PPC system variable ($5C45, bank 5), edge-triggered on the assembled 16-bit line value, halting when an armed line is entered. Machine independent (no ROM addresses). - Compiled Boriel BASIC (
linecallbp_cmd.go): anchors on the program's CHECK_BREAK runtime call PC with the line number in HL (linecall-anchor, re-sent per build), checked fromBreakpointCheck. - Address-map languages — sjasmplus, Pasta80, z88dk C, sdcc, zmac, pasmo (
stepline_cmd.go): the IDE uploads its line→address map as an anchor set (step-line-anchors, chunked, re-sent per build), andstep-linearms a one-shot halt at the next anchor the PC reaches — the source-line step. Fires on any next mapped line (basic-stepparity; unmapped ROM/library calls run through);step-line overadds an SP guard to run through mapped callees. Arming while paused steps one instruction first so the run always makes progress. Checked fromBreakpointCheck, one atomic load while disarmed.
Other notable tooling: time-travel ring (CPU + visible 64K per snap; Next upper state is a catalogued phase-2 gap), provenance/xref/callgraph analysis, crash-detect heuristics, bisectFirstDivergence for ours-vs-reference hunts, and the SD/NextReg/paging tracers.
Test harness (pkg/testharness)
A deterministic, goroutine-free scripted machine for integration tests: construct per model, poke programs, RunFrames/RunUntil, press keys, read memory, capture the screen, and OCR it (ScreenText, RunUntilText). next.go assembles a full Next (dispatcher, palette, layers, compositor, Copper, DMA, divMMC, esxDOS, sdcard) so register- level tests can assert on rendered RGBA. This is the canonical way to test hardware behaviour; see CONTRIBUTING.md for the pattern.
Support packages
pkg/trace: typed events (CPU fetch, NextReg, ports, bank switches) to a pluggable emitter;--traceinstalls a JSON-lines emitter.pkg/zxlog: slog handler + startup banner.pkg/config: persisted desktop settings (model, scale, joystick, recent files, SD locations). Not used on wasm.
Build notes
- Desktop needs cgo (Fyne/GLFW/OpenGL).
go test ./...runs the full suite (~3-4 min);-shortskips conformance and real-ROM boots. - The wasm binary is ~31 MB (Fyne linked as dead code; shrinking it means splitting the core out of
package main, catalogued as a later optimisation). - The prebuilt
dist/fallback lets npm builds succeed without a Go toolchain; Docker builds compile the wasm in a golang stage. - Port design notes and the wasm-safe edit list:
packages/emulator-core/wasm/STATUS.md.