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

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.shdist/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):

GroupExports
Boot / machinezxRegisterROM(name, bytes), zxBootNext(sd), zxBoot48(), zxBoot128(), zxBoot(model), zxReset(), zxModel()
Frame / audiozxFrame(dst?) → {w,h,debug,paused,pc}, zxPullAudio(dst) → n, zxFastBoot(), zxMacroActive(), `zxMacroProgress() → 0..1 \-1`
InputzxMatrixKey(row, mask, down), zxType(rune), zxKeyName(name, down, shift), `zxJoystickType(name) → "" \err, zxJoystickState(bits)`
TapezxLoadTap, zxTapeInsert, zxTapePlay, zxTapeStop, zxTapeStatus, zxTapeTraps
Programs / fileszxLoadSnapshot(bytes, ext), zxRunNex(name, bytes), zxRunBas(name, bytes), zxPutFile(path, bytes)
DebugzxDebugAttach/Detach, zxDebugCmd(line), zxDebugState, zxDebugMem, zxDebugDisasm, zxDebugPaging, zxDebugStepFrame
DiagnosticszxAudioDebug, 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:

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.

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:

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

Build notes