█▓▒░C64 READY.

Docs / The test suite

The test suite

How to run the tests, what the suite covers, the diagnostic and trace tools, and the VICE cross-checking workflow. Run the whole thing any time with npm test.

The document is in two parts: first the test & VICE runbook (running the suite, the diagnostic tools, cross-checking against VICE, coverage, fixtures); then, from "Debug console (DevTools)" on, the DevTools debug and inspection surface: console helpers and live model toggles for triaging behaviour in the browser.

The suite files are registered in the TESTS array of test/all-test.js and run by npm test. Between them they hold a few thousand labelled tests, plus some unlabelled internal assertions.

Running the suite

Guide screenshots use Phosphor dark. With the dev server running, npm run guide:shots refreshes the full set, including curated Library and Save States examples. The theme gallery keeps each named theme's dark and light views. node tools/guide-setup-shot.mjs regenerates all three ROM setup tabs and the theme picker images for Getting Started. Add --setup-only to capture just the setup tabs. The Fetch screenshot uses example URLs instead of the local deployment defaults. node tools/guide-monitor-shots.mjs refreshes the eight Live Monitor images using the external Nine demo. It also runs with npm run guide:shots.

# Full default suite (6-way parallel)
npm test                         # alias for: node test/all-test.js
node test/all-test.js

# Tune concurrency (keep it modest: saturating the machine skews any
# timing-sensitive run happening alongside)
node test/all-test.js --jobs=4
node test/all-test.js --jobs=1   # force sequential (isolated failure debugging)

# A single test file (fastest iteration during development)
node test/<folder>/<name>-spec-test.js

all-test.js spawns each spec file as its own Node subprocess, so failures are isolated. It prints PASS / SKIP / FAIL (ms) per file plus an overall summary, and on failure it echoes the last 15 lines of the offending file. The exit code is non-zero if any file fails; skips never fail the run.

Every file the tests use that is not part of the repository (the ROMs, hardware test programs, and demo fixtures) resolves through test/external-assets.json: edit the paths there, or set the per-entry environment variable, to match your machine. Spec tests that depend on such a file SKIP with a note when it is absent, so the suite passes without the optional fixtures. The ROMs are the exception: the runner checks for them up front.

test/docs-guide-jump-spec-test.js checks the User Guide and Inside the C64 navigation, including chapter menus, breadcrumbs, paging and overview grouping.

test/docs-redirects-spec-test.js checks that moved documentation URLs redirect to their canonical pages in dev and preview, using the production redirect rules.

A skipping test exits 0, which on its own is indistinguishable from a pass, so it announces the skip with a TAP-style directive that all-test.js picks up:

Directive Meaning Reported as
# SKIP <reason> at line start the file did no real work SKIP, listed under Skipped files
ok - <check> # SKIP <reason> the file ran; one check did not PASS, listed under Partially skipped

Both lists print the reason (from missingNote(key), which names the manifest entry and its environment variable), so a green run still shows exactly which fixtures went missing. A test that skips without a directive is reported as a plain PASS: that is the bug the directive exists to prevent.

Monitor coverage

Monitor tests use synthetic states and spec-derived expectations. Capture and sampling checks compare observed and unobserved execution, including machine state and pixels. Missing evidence must remain distinct from a rule mismatch.

Area Main checks
Snapshot and facade Read-only inspection, reused buffers, all four sampling rates, memory mappings, dual SID and REU.
Memory Actual matrix-read regions, repeated reads, source pages and read rasters, missing or partial evidence, captured versus static screen views and recording-preserving links.
Frame and provenance Complete-frame ownership, phase ordering, historical bytes, host-write invalidation, cancellation and observer cleanup.
Video and Bauer Evidence-qualified summaries, diverse findings and paging, frame stories, graphics sources, sprite intervals, borders, bus access, IRQ, collisions, lightpen, ghost-byte evidence and independent PAL phase predictions.
Code and Sound Recorded instruction identity, action explanations, cycle budgets, captured operands, paged evidence, fixed-duration histories, fast-rate label stability, bounded actual-write histories, captured SID write navigation, historical voice settings and independent SID paths.
Storage Bounded tape decoding, cartridge mapping, REU accesses, rate units, confirmed accelerated LOAD destinations and collection only while Monitor is active.
Browser Component-entry pause ownership and shared recordings, header state, shared captures, bounded paused-entry capture, final pause snapshots, held navigation, capped presentation, sprite-detail links, pause/replay, lazy analysis, saved sampling rate, desktop and touch layouts, dialogs, navigation and input routing.

Run individual files with node test/monitor-<name>-test.js, or use npm test for the registered suite. test/monitor-browser-test.js requires Playwright Chromium. Its real-emulator checks also need local ROMs and report SKIP when those assets are unavailable. The browser test builds the documentation and checks that component dialogs load the HTML guides, resolve document links, handle missing pages and keep the selected chapter when responses arrive out of order. Dialog size and position stay stable during chapter loading on desktop and phone layouts.

Dialog close controls

test/rom-fetch-spec-test.js checks URL validation, download failures, ROM size validation, browser storage and retries that preserve loaded ROMs. test/rom-setup-browser-test.js checks the setup tabs, keyboard navigation, download results, missing configuration and phone layout with synthetic ROMs. It also checks fresh-browser startup with a delayed 1541 ROM and audio initialization, keeping the powered-off banner out of the running display.

The setup dialog reads optional default links from public/rom-sources.json. This file is git-ignored and must be supplied separately on a deployment builder before running npm run build, or served as /rom-sources.json by the host. Vite copies it into the build when present. Its format is:

{
  "kernal": "",
  "basic": "",
  "charRom": "",
  "drive1541": ""
}

Fill the values with HTTP or HTTPS ROM URLs. Defaults are fetched only when setup opens and are kept out of the service worker cache. Missing or invalid configuration leaves the inputs available for manual URLs. Tests supply their own configuration and never depend on the deployment's links.

test/analytics-markers-spec-test.js checks that marker pages exist and bypass both browser and service worker caching. /roms-fetched.html fires after a Fetch ROMs click installs at least one ROM and leaves the required trio ready. /roms-vice.html similarly fires once after a VICE folder import installs ROMs and leaves the required trio ready. Neither fires on cached return visits or attempts that leave required ROMs missing. The setup browser test checks successful fetches, VICE imports and cached returns.

test/theme-picker-browser-test.js checks the theme gallery on desktop and phone, keyboard selection, cancellation, persistence, themed docs and the side-by-side mobile guide images. The panel media test checks that first ROM setup shows the picker before the equipment hint. ?THEME_PICKER=1 previews the picker after ROM setup without changing its one-time setting.

test/close-button-spec-test.js checks accessible names, non-submit activation, repeat initialization and title-bar drag isolation. test/close-button-browser-test.js checks consistent icon and target sizes in scaled and unscaled dialogs on desktop and phone, close-button padding, wrapping titles, keyboard activation, cancellation, nested Escape and focus restoration. Options and a populated Library also check header, content and footer alignment with both overlay and reserved scrollbar space. Both run in npm test, with browser checks skipped when Chromium is unavailable.

Default panels and media controls

test/panel-order-spec-test.js checks default visibility, explicit visibility preferences, existing custom setups and panel revelation. test/panel-media-browser-test.js checks the fresh layout, hidden initialization before restoration, Load Any, drag and drop, width-based stacking in desktop windows and portrait phones, Library and saved-state restoration in a headless browser. New device controls alternate between columns, including multi-device states. The equipment hint waits for ROM setup, stays until + is clicked and respects reduced motion. ?EQUIPMENT_HINT=1 previews it without changing the saved one-time setting.

Drive 8 queue tests

test/disk-queue-spec-test.js covers ordering, persistence, write protection, write preservation, storage failure, concurrent operations and queue actions that leave the running machine alone. test/disk-queue-browser-test.js starts its own dev server and checks Library and Assembly64 buttons, drag sorting, arrows, keyboard shortcuts, reload, offline insertion and phone layout. It uses fixture catalog data and local ROMs for live-machine checks, and skips browser checks when Playwright Chromium is unavailable. Both are in npm test.

User port plugin controls

test/device-install-trust-browser-test.js verifies that neither install review executes plugin code, including after cancellation. Code runs only after trust is accepted and installation proceeds. Metadata tests reject getters and computed expressions without evaluating them.

test/userport/userport-card-browser-test.js checks the LED board chooser and Custom URL, with the included modem available only in the main Device dropdown, install trust, reload persistence, export and removal. It covers dropdown bounds, trust checkbox alignment and device-owned relay controls. Scanner tests distinguish binary decoding from code execution, XML namespace identifiers from network URLs and embedded print documents from changes to the main page.

IEC plugin tests

test/cli/cli-iec-spec-test.js covers repeatable address arguments, exports, overwrite protection, fault cleanup and KERNAL printing with two IEC devices and the User Port. test/cli/cli-iec-browser-test.js checks serve --device against a production build, including connector selection, reload and power switches. Both run with npm --prefix cli test and the CLI package build.

test/iec/iec-device-spec-test.js checks address reservation and conflicts, wired-AND pulls, detached handles, isolated errors, timers, snapshots, addressed byte reception, ATN acknowledgement with CLK high and EOI. With local ROMs it runs unmodified KERNAL PRINT# on the physical IEC lines, both without drives and alongside physical drives 8 and 9, and switches between two printer addresses. Existing IEC edge-latency and two-bit drive tests cover shared bus timing. The receiver helper is tested against the published serial rules.

test/iec/iec-printer-spec-test.js checks plugin bus validation and standalone loading, MPS pin order, graphics density, repeat columns, position commands, text and bitmap feeds, business case, width and partial commands. test/iec/print-job-spec-test.js checks bounded storage, persistence, snapshot restore, backpressure recovery and Receiving/Idle status. test/iec/iec-card-browser-test.js checks the shared IEC and User Port install review layout on desktop and phone, metadata, findings, replacement and trust, independent addresses, collision handling, modem coexistence, real KERNAL text and bitmap output, direct browser print invocation, safe two-page PDF output, empty controls, activity status, clearing, the IEC card's switch, power-cycle persistence, reload, disconnection and phone layout. Browser tests skip without Chromium, and live-machine checks skip without local C64 ROMs. All four run in npm test.

D71 virtual-drive tests

test/drive/d71-format-spec-test.js covers geometry, split BAM, cross-side files, full-disk rollback, formatting, write protection and virtual channels. test/drive/d71-trap-load-spec-test.js covers KERNAL loading on devices 8/9 and machine/media restore. Both run in npm test; D64/D81 tests cover the shared allocation code. Run node test/assembly64/browser-check.mjs --d71 against the dev server for file pickers, TDE routing, format/export, Library and state restore.

VIC renderer tests

Compact history, sprite scheduling and fetch-fed graphics have no runtime switches. Tracing uses dense history and RAM-based graphics; cartridges also use RAM-based graphics. Batching is automatic; tracing and armed collision IRQs select live rendering. Test-only reference implementations live in test/vic2/_vic2-reference-*.js and are excluded from the application build.

Expected hardware behavior follows the VIC-II specification, not reference-path output alone.

Collision overlay

Reload with ?VIC_COLLISION_OVERLAY=1 to tint collision buffers over the screen: green = graphics foreground, blue = sprite pixels, red = sprite/graphics or sprite/sprite overlap. Includes pixels hidden by borders or sprite priority. A top-left COLLISION label stays visible for 200 ms after the latest sprite-to-sprite collision, including while paused. Sprite/graphics overlap does not trigger or extend the label. These are completed-line buffer snapshots, not the delayed, sticky collision registers. Raw framebuffer exports and save states exclude the tint. Default off; ?VIC_COLLISION_OVERLAY=0 disables it. Works with WebGL and Canvas 2D.

node test/vic2/vic2-collision-overlay-spec-test.js checks classification, live/deferred state parity and presentation isolation.

Assembly64 integration checks

The default suite includes the Assembly64 and media-browser tests. They use generated media and mocked API responses, covering query validation, normalization, cancellation, pagination, request limits, local persistence, media validation and dispatch, source metadata, progress and safe ZIP extraction. Run just these tests with npm run test:assembly64.

With npm run dev -- --host 127.0.0.1 --port 5173 running, use npm run test:assembly64:browser for the headless browser checks. Add -- --api-load to audit request counts or -- --details for release links and file actions. Add -- --tde for focused checks of the D64 compatibility prompt, including both drive file pickers, acceptance, decline and loading with TDE already enabled. The full run also drives the NEOS mouse from the UI side (neos-check.mjs): the port select plugs it into the machine, the buttons reach it and POTX, motion reaches it under pointer lock, and it follows a port swap and a RESET. The harness needs Chrome and local ROMs in roms/; it mocks Assembly64 and makes no live catalog requests. With the dev server on localhost instead of 127.0.0.1, pass node test/assembly64/browser-check.mjs --url=http://localhost:5173. Leave the source alone while it runs: the dev server reloads the page on any change it watches, and the run fails partway. Use -- --url=http://127.0.0.1:5174 to select another local port.

After npm run build, run npm run preview -- --host 127.0.0.1 --port 4173 and npm run test:assembly64:pwa to check offline reload, favorites, named searches, cached ROMs, saved D64/REU loading, and exclusion of media/API responses from the service worker cache. Browser capture output goes in investigation/assembly64/.

Chat, accounts and achievements tests

These live in test/chat/ and test/achievements/.

The browser tests drive the app in headless Chromium against a dev server, with Chromium's virtual authenticator as the passkey device:

The browser tests keep their accounts in memory (VITE_ACCOUNTS_IN_MEMORY=1), never in server/.data. Only achievements-browser-test.js needs the ROMs and the Uridium cartridge (uridium-crt in test/external-assets.json). All of them skip without Playwright's Chromium.

The app connects to the production chat on Railway, in development too. The browser tests point it at the dev server's own chat instead (/chat, set in localStorage as c64emu.chat.server), so they never reach production. To try a change to the server by hand, do the same in DevTools: localStorage.setItem('c64emu.chat.server', 'ws://localhost:5173/chat'), with the dev server's port. Its admin console is at /admin on the dev server. Until it has a passkey, the dev server prints a setup key when it starts: enter it at /admin under Set up this device. The chat log is off in development: run server/main.mjs with CHAT_LOG_DIR set to try it.

Test categories

The VIC-II tests live in test/vic2/, the SID ones in test/sid/, the CPU ones in test/cpu/, the CIA ones in test/cia/, the 1541, IEC, D64, G64 and NBZ ones in test/drive/, the datasette, tape, turbo, WAV, DMP and T64 ones in test/tape/, the interrupt ones in test/irq/, and the CLI's in test/cli/ (its own runner, test/cli/all-test.js, is what npm test in cli/ runs); everything else sits in test/ itself.

Category What's locked in
CPU Every opcode and cycle count; legal and illegal cycle audits; Klaus Dormann's exhaustive 6502 functional test (binary resolves via test/external-assets.json, skips if absent; the suite's only external-asset test).
VIC-II core / cycle timing Master-cycle ordering, BA/AEC handshake, bad-line and sprite DMA steals, per-cycle bus-kind accounting.
VIC-II raster + IRQ Bauer §3.12 mid-line $D011/$D012 fires, edge-triggered raster IRQ, IRQ pipeline and entry under BA/RDY.
VIC-II stable raster Double-IRQ jitter absorption, stable-raster realignment, BA-contour timing, raster-time spinner and dejitter.
VIC-II rendering Pixel-accurate text and bitmap modes, g-access shifter, border-veto windows, mid-line mode flips, colour bars.
VIC-II sprites Sprite crunch (cycle-15 latch + cycle-58 disable), multiplexer, BA, X/Y wrap, multicolor priority, mid-line data row, sub-pixel phase, idle-bus leak.
VIC-II bad-line / FLI / FLD Full-line bad-line edge detection, good-line/bad-line transitions, FLI bad-line-every-line, FLD and linecrunch abort, RC-reset timing.
SID synthesis Waveforms, ADSR, ADSR-bug timing, sync, ring mod, test bit, filter mode and cutoff curve, resonance Q, combined waveforms (non-flat byte spread per period), 6581 vs 8580 dim, NOISE+combined LFSR clobbering.
SID digi / readback / shadow / paddle $D418 DC step, 1-bit PWM digi that survives voices playing underneath (correlation > 0.7), cycle-exact $D41B/$D41C against a synchronous reference, POTX/POTY 512-cycle sample-and-hold, cycle-sync-on-first-event hook.
Audio lifecycle / recording Foreground/background mute policy; recorder audio uses an independent media clock and tears its bridge down cleanly; browser support is capability-detected; the event backlog is bounded so audio lateness cannot accumulate.
CIA / 6526 Timer A/B modes, force-load edge, port arbitration, TOD, IRQ/NMI, SDR stub semantics, VIC bank switch via CIA2 PA.
Input & UI logic Paddle byte builder, 1351 button mapping and POT step of 2 per mouse unit (the real driver arithmetic recovers the delta), NEOS strobe/nibble protocol with right button on POTX and idle reset of the sequencer, light-pen latch, ROM-cache localStorage round trip.
Shared bus / open-bus Open IO1/IO2 and Color-RAM upper-nybble latch reads, $00/$01 quirk, CPU-internal-cycle bus drive, sprite idle-fetch leak.
1541 / IEC / D64 / GCR 1541 boot, IEC wired-AND and edge-latency model, true drive-clock ratio (including save/restore phase continuity), 2-bit transfer, fast loaders, GCR read path, GCR write-back round trip with write head and write-protect polarity, end-to-end SAVE through the real DOS (drives 8 and 9), G64 images (header and table checks, half-track streams, recorded bit rate, DOS LOAD and SAVE from raw tracks, the read circuit: density mismatch, weak-bit noise, per-byte speed maps), nibbler dumps to G64 (LZ stream, NIB table, track cycle and alignment, killer, unformatted and fat tracks, sync reduction, real c64pp dumps when present), SOE gating, no-DOS bootstrap, wildcard LOAD, where a trap-served $FFD5 LOAD stores the file (secondary address 0 vs. 1, and VERIFY storing nothing), D81 images (layout, BAM, allocation, partitions, error table, the empty-1541 rule, trap loads), and the virtual drive (status, files, directory, writes, commands, blocks) with its KERNAL-level channel test (OPEN, CHKIN, CHRIN, channel 15, SAVE, a D81, TDE on leaving the bus to the 1541).
Cartridges Device-registry loading; Generic / Action Replay / Final Cartridge III / Magic Desk / EasyFlash banking; Ultimax mapping; cartridge I/O, RAM, RESET/FREEZE, and NMI behaviour.
User port and Hayes Modem CIA2 port pins against a plugged-in device (wired-AND, PC2 one cycle after a $DD01 access, FLAG2 into ICR and NMI, timers, error unplugging, save states). Plugin checks, and the install review categories, Base64 information, XML namespaces and encoded-code execution. The modem's 8N1 framing and bit timing, autobaud, Hayes settings, +++ guard times, Telnet negotiation, phonebook, RTS and DTR. The KERNAL's RS-232 driver against it at 300 and 1200 baud. The LED board example as installed. The browser host's sockets through the relay, and paste pacing. c64rdy serve (site proxy, offline cache, relay origin check) and run --userport. Speaker synthesis, shared Datasette icons, remembered sound selection, master mute, and unplugging cleanup. The User Port card and its device store in headless Chromium on a dev server of its own (SKIP without Playwright's Chromium).
RAM Expansion Unit 8726 REC register map and readback, stash/fetch/swap/verify transfers, DMA bus arbitration (CPU halted, VIC DMA takes precedence) and the documented transfer rates.
Datasette .tap v0/v1/v2 playback, FLAG pulses into CIA1, seeking to a file's lead-in, KERNAL tape LOAD and SAVE, turbo-tape record and load round trip at cycle resolution, .wav cassette import (level tracking, edge polarity, sample rates, repair of damaged blocks) and export, DC2N .dmp import.
Memory / PLA / banking PLA routing through the 6510 port, all 32 banking configurations, colour RAM, reset state.
Integration / demo motifs Synthetic re-creations of demo tricks: Nine's multiplexer chain and startup collision probe, FPP's late-bad-line matrix rule, The Hat's open-border MCM rule, hyperscreen motifs. No demo binaries.

Diagnostic / trace tools (not gated)

Run these on demand to dump telemetry. Output is usually written to /tmp/ or printed to stdout.

The reference-demo screenshot pass (test/commit-screenshots.mjs) runs the fixed DEMOS table headlessly and writes timestamped framebuffer PNGs to a git-ignored output directory, created on demand. It is a human visual check, not a spec test: the filename is plain .mjs, all-test.js does not gather it, and it makes no assertions. It never deletes old screenshots; successive runs accumulate, so a before/after pair can coexist. After each run it diffs every new shot against the previous run's matching shot and prints which demo seconds changed. Changed shots also get diff-<demo>-sNN.png overlays, with unchanged pixels dimmed and changed pixels tinted magenta. Run it only when explicitly requested, including before/after comparisons for render or timing changes.

node test/commit-screenshots.mjs

The demo crash/hang status board (test/demo-status.mjs) boots each tracked demo (disc 1) headless and classifies the outcome as CRASH (JAM/KIL opcode, PC and time), runs clean, or DISPLAY FROZEN (a possible silent hang; it is framebuffer-based, since PC sampling cannot tell a silent hang from a healthy interrupt-driven spin). Each verdict prints immediately when its worker finishes, including the disc and elapsed wall time; the final table keeps the complete overview. Each outcome is compared to its expected status (✓ / ✗ CHANGED); most entries expect RUNS, so the board is first a regression detector over demos that must keep running clean. Each demo loads via the chunked keyboard buffer (the UI path) and runs its per-demo frame budget (around eight minutes of demo time), saving a screenshot every 10 s plus the end frame (<demo>-<YYYYMMDD-HHMMSS>[-sNNN].png, to a git-ignored output directory created on demand), so a demo's progression, and exactly where it visually breaks, is visible. The demo disk images resolve through the collection roots in test/external-assets.json. SID defaults to 8580 to match the UI. Multi-disc demos boot their crash disc directly (for example Mojo disc 4). It is a *.mjs tool, so the all-test.js runner skips it, like commit-screenshots.mjs.

# Crash/hang status board over the tracked demos (screenshots + ✓/✗ vs expected)
node test/demo-status.mjs                 # all tracked demos
node test/demo-status.mjs coma next       # filter by demo name
SID=6581 node test/demo-status.mjs        # force the original 6581 SID

# Convert raw Float32 audio to listenable WAV
node test/tape/f32-to-wav.js /path/to/audio.f32 /tmp/out.wav

# Exercise the worklet's power-cycle / reset / cycle-sync paths
node test/sid/sid-power-cycle-trace.js

Cross-checking against VICE (reference oracle)

VICE (x64sc) is the ground-truth oracle for VIC-II, CPU, sprite and timing questions. Install it from your platform's package manager (for example brew install vice) or from the VICE project. When writing one-off capture scripts, reuse the existing connection boilerplate rather than re-deriving the monitor protocol.

Always pass -VICIImodel 0. x64sc -pal defaults to VICIIModel=1, the 8565 ("new" VIC), but this project models the 6569 ("old" VIC). The 8565 samples registers one cycle earlier, so an uncorrected compare shows a spurious 1-cycle / 8-pixel offset that is not a bug. Verify in the monitor with resourceget "VICIIModel" (0 = 6569, 1 = 8565, 3 = 6567 NTSC).

Always run scripted VICE launches headless. Set SDL_VIDEODRIVER=dummy (and SDL_AUDIODRIVER=dummy when sound is irrelevant) in spawned VICE processes so no window appears on the desktop. Spawn the binary inside the app bundle: on macOS the bin/x64sc next to it is a launcher wrapper, so killing that reaps only the wrapper and orphans the real emulator, which then piles up across runs. Check with pgrep -fl x64sc afterwards, and kill strays before retrying if ports or monitor sockets conflict.

Three conventions that bite every time:

For testprogs suites that ship a references/ directory of VICE screenshots, reach for the shared comparator first, instead of writing a new render-and-diff script:

node test/ref-compare.mjs <prg> <refPng> [boot=200] [run=80] [refPalette=pepto|colodore]

test/ref-compare.mjs boots the KERNAL, loads and runs the PRG, and compares in palette-independent colour-index space. It handles the two common false positives for these references:

VICE's external Pepto palette still runs through its gamma and contrast curve. Tiny residual index diffs confined to 1px features, where position matches and only colour differs, are usually this curve rather than a rendering bug. For those cases, read the VIC registers over the monitor (m d027 d02e, masking colour-register reads with & 0x0f) before chasing pixels.

When the shared comparator does not fit (no reference PNG, or you need breakpoints and traces rather than pixels), capture raw:

Capture style A, headless one-shot screenshot (fast, fully deterministic; best for pixel diffs):

x64sc -VICIImodel 0 -warp -autostart-warp +drive8truedrive -autostartprgmode 1 \
  -limitcycles <N> -exitscreenshot /tmp/vice.png -autostart "/path/to/demo.prg"

The PAL screenshot is 384×272, the same crop as our frameBuffer, so pixels align directly. -limitcycles + -exitscreenshot is reproducible; warp + a hand-driven monitor is not. Autostarted PRGs need enough budget to clear boot, injection, RUN, and settle: about 9M cycles for a 9 s run. Around 3M cycles often lands on bare READY. before the program has run.

Capture style B, monitor (breakpoints, register and memory watches, single-step): launch with a monitor socket and script it over TCP.

The workflow that works:

  1. Reach the scene in VICE: checkpoint the demo's inner-loop routine and advance N hits to a stable frame.
  2. Capture VICE ground truth: a per-instruction (PC, LIN, CYC) trace, and/or per-line register stores with their CYC, and/or a screenshot.
  3. Build the same trace headlessly from our emulator: drive machine._runMasterCycle() in a loop (19656 cycles = one PAL frame) and record (cpu.pc, vic2.raster, vic2.cycleInLine − 1) at each cpu.atInstructionBoundary(). Boot with about 200 warm-up frames before loadPRG + injectRun, or KERNAL init clobbers the injected RUN.
  4. Diff by PC, not by time; the instruction stream is identical. A divergent CYC for the same PC points straight at the cycle where our timing differs. A CYC gap on one side is a CPU stall (bad-line or sprite-DMA BA) that the other side doesn't have.

CIA testprogs

test/cia-testprog-sweep.mjs runs every old-6526 PAL program that VICE's testbench/c64-testlist.in lists under CIA/ and interrupts/, plus Lorenz's CIA programs. It reads each verdict from the debug cartridge ($D7FF) and records the cycle it came on and a hash of the screen. Save a run before a CIA change and compare after it: any change in verdict, cycle or screen is listed.

node test/cia-testprog-sweep.mjs --out=investigation/cia-before.json
node test/cia-testprog-sweep.mjs --baseline=investigation/cia-before.json

RAM Expansion (REU) testprogs

The VICE testprogs REU/ directory is the oracle. Those programs are hardware-derived, and several print their expected register dumps outright, so no VICE run is needed.

node test/reu-testprog-run.mjs <prg> [--size=512] [--image=<file.reu>]
node test/reu-testprog-sweep.mjs                       # all of them

Paths come from test/external-assets.json (the suite itself, and BluREU's blu.reu; reudetect checks for that data file, not just for the hardware, and skips without it).

Both dump the border colour and the decoded text screen, which is how these tests report. Run QuickReuTest-1.1.1 (Wolfgang Moser) first: it prints TEST CLASSES WITH FAILURES: n and cites the CSG8726R1 reference section for each mismatch.

SID testprog sweep (VICE testprogs/SID)

The SID testprogs are driven headlessly: boot, load, run, then read the border / $d7ff verdict. Each program's own captured register stream is also re-rendered through the shipping worklet, to check that the selected engine actually carried it. Anything that fails or looks odd is re-run under VICE 3.10 and compared.

A full sweep covers all 26 folders across both chip models. In outcome: every folder with an automatic verdict passes, lands on the real-hardware value, or fails exactly as VICE 3.10 does; the failures that remain (noisewriteback / wb_testsuite's combined-noise family) are a shared reSID limit, not a local gap, and the trade-off went to noiselfsrinit matching hardware. The bitmap-plot and analog-meter programs have no automatic verdict. The standing summary lives in Component status; what stays here is how to drive the suite and what trips it up.

Gotchas that make this suite look broken when it isn't:

Comparing rendered audio against VICE. Waveform correlation is the wrong tool: the two sides come from separate machine runs, so phase is unrelated, and short repeating test patterns give a lag search many near-equal maxima. Landmark fingerprinting (Shazam-style: keep spectral peaks, hash peak pairs, then recover the offset from a histogram of hash time-deltas) works instead. It is insensitive to level, and the alignment falls out of the match rather than having to be fitted first. Calibrate before reading any score: a file against itself scores 100 % at zero offset, two unrelated tunes score about 17 % at a scattered offset, and two correct implementations of the same audio land around 75 % at one sharp offset. On that scale the WASM engine against VICE reSID scores a median 71 % over the six PRG scenes on both models and 84 % over the wrapped csid-light-tests tunes, each at a consistent offset: the signature of the same audio through two implementations.

Rendering .sid files for such a comparison is the unreliable part. A silent or near-silent render will still produce a plausible-looking score from coincidental hash collisions, so always check that both sides actually contain audio before trusting a number. Failure modes seen so far, none of which announce themselves:

The mouse/ testprogs are driven the same way. mouse/neos/ and mouse/1351/ ship reference drivers extracted from real software, plus programs written specifically to break emulators: arkanoid.prg for strobe timeout handling, krakout.prg for a crack that never initialises DDR, krakoutbug.prg for a poll-independence bug VICE once had. Synthetic mouse deltas can be injected headlessly, and the drivers then print what they reassembled, so these verify a mouse end to end without a host pointer. Two caveats cost time: several of them select the control port with mouseport = 0, which is $DC00 and therefore port 2, and arkanoid.prg clobbers its own port index partway through a read, so it only works when the X delta happens to be 1. Per-device outcomes are in Component status.

Confirm the VICE reference really is reSID before trusting a comparison: -sidengine 1 selects it (0 is FastSID). -sidmodel has an empty -help description, so prove it took effect by outcome: sidcheck.prg self-reports the chip, so -sidmodel 0 must print (6581). Audio comparisons also depend on -residsamp, where the difference between fast and resampling is wide enough to swamp a real finding.

Coverage

There is no coverage tool in the dependencies; Node's own V8 coverage is enough. Every test process writes a JSON file into the directory named by NODE_V8_COVERAGE, and tools/coverage.mjs folds them into line coverage per src/ file (a code line counts when any of its characters ran in any process):

NODE_V8_COVERAGE=/tmp/cov node test/all-test.js
node tools/coverage.mjs /tmp/cov

The instrumented suite runs about eight times slower than plain npm test.

Read the number in two parts:

Shared fixtures

Underscore-prefixed files in each subsystem's folder hold what several tests build the same way: VIC-II construction and render harnesses and a full-frame equivalence compare, tape and turbo fixtures, G64 and NIB images built from the format layouts, and a small stand-in DOM for the UI tests (elements, classes, a selector engine, events, rects the test assigns). The DOM stub does no layout and does not pretend to be a browser; what it does not model, the tests do not assert on. None of them are registered as tests.

PRG-building helpers

A few specs need a tiny 6502 program injected into a fresh machine. The build-*.mjs scripts emit those PRGs on demand:

node test/sid/build-osc3-cycle-test-prg.mjs       # → test/sid/osc3-cycle-test.prg
node test/sid/build-sid-feature-test-prg.mjs      # → test/sid/sid-feature-test.prg
node test/build-raster-prgs.mjs               # → test/*-raster.prg
node test/build-vice-prgs.mjs                 # → test/vice-*.prg

Test runner registration

Every new test must be listed in the TESTS array of test/all-test.js. The runner does not find files that are not explicitly registered.


The rest of this document is the DevTools debug and inspection surface: console helpers and live model toggles for triaging behaviour in the browser.

Debug console (DevTools)

The running machine is the machine global (window.machine), and the UI facade over it is window.c64; the trace and inspection helpers below are c64Trace / c64Vic / c64Bus.

// Machine lifecycle. softReset = a /RESET-line pulse: preserves RAM (the KERNAL
// re-inits screen + zero page). No UI button, and allowSoft:true is REQUIRED (a
// bare softReset() throws) so a soft reset never fires by accident.
machine.softReset({ allowSoft: true })
machine.reset()                          // cold boot / power cycle (regenerates RAM)
// VIC frame trace: enrich the debug snapshot with whole-frame + per-raster
// data (see "VIC frame trace and state snapshots" below for workflow + perf).
c64Trace.enable() / .disable() / .status()

// SID write trace: capture every SID register write for inspection
c64Trace.sidStart(20000)       // capture next N writes
c64Trace.sidDump(0x18)          // pretty-print first 40 $D418 writes
c64Trace.sidStats()             // per-register count + Hz rate summary

// VIC-II model toggles (off by default: unstable / variant-specific)
c64Vic.bankDelay(true|false)    // NMOS: DDR-driven single-bit 0→1 that decreases
                                // VIC bank by 1 or 2 delays one cycle (VIC-Addendum
                                // "Video bank and C64C"). Unstable on real chips.
c64Vic.bankGlitch(true|false)   // C64C / 8565: VIC-bank 10↔01 transitions blip
                                // through bank 3 for one cycle. Only active when
                                // vicVariant='8565'.

// Capture diagnostic: check aliased snapshots against live state
c64Vic.captureDedupVerify(true|false)

// Shared external-data-bus diagnostics
c64Bus.status()                 // live latch bytes and trace state
c64Bus.traceStart(1024)         // enable per-cycle bus trace ring
c64Bus.traceStop()              // disable + free
c64Bus.traceDump(64)            // print + return last N entries (oldest first)

SID transport and clock drift

The worklet sends one summary report per second of its own clock. Console logging is off by default; opt in with:

c64Trace.sidDiag = true    // plain property, unlike avMarkerOn(); a reload clears it
[sid] cy=… applied=… future=… drained=…      event flow
      pending=…/…                            mirror occupancy (unapplied events, peak)
      oldestFutureΔ=…                        cycles to the next queued write
      drift=…ppm driftAvg=…ppm/…s            CLOCK health: the two clocks' rate difference
      lateMax=… late=…                       SCHEDULING health: applied after their stamp
      overrun=… pendDrop=…                   TRANSPORT health: events lost outright
      backlogFF=…                            backlog fast-forwards, cumulative for the session

drift is positive when the main thread produces emulated time faster than the audio device plays it, and it is the only field that answers "are the two clocks running at the same rate". Read it, not oldestFutureΔ: a player writes its registers in one burst per frame, and against a once-a-second report that grid alone walks oldestFutureΔ down by 2448 cycles per report and wraps it every 8 seconds on perfectly locked clocks. That sawtooth is not drift. A single 2448-cycle step is worth about 2500 ppm, so an eyeballed slide reads as a fault an order of magnitude larger than most real ones.

A/V sync marker

Measures how far recorded audio trails the picture. Every 10 s it drops the SID volume for 60 ms, then gates all three voices at 2.9 kHz on the same rAF tick as a white full-screen flash. The notch matters: it manufactures silence, so the pip is a clean onset even under a game's music.

c64Trace.avMarkerOn()      // this session only; ?avmarker=1 also works
c64Trace.avMarkerOff()
c64Trace.audioLatency()    // baseLatency + outputLatency of the live path

The marker persists nothing (no localStorage, no cookie, no window flag), so a reload clears it and you have to switch it back on. That is deliberate: a debug tap that survives a reload comes back on a later visit as an unexplained flash and pip. It also means avMarkerOn() is the only way in; assigning c64Trace.avMarker does nothing. The rAF loop calls avMarkerEnabled() every presented frame, so it reads one module-local boolean and nothing else. Do not reintroduce a storage or URL lookup behind it.

Record with the marker on, then find the flashes and the pips in the file and compare their times. A growing gap is the audio clock falling behind; a constant one is pipeline and device latency:

# Flash times: white frames stand out in per-frame average luma
ffprobe -v error -f lavfi -i "movie=rec.mp4,signalstats" \
  -show_entries frame=pts_time:frame_tags=lavfi.signalstats.YAVG -of csv=p=0

Detect the pip in a bandpass around 2.9 kHz, not on broadband energy, which inside music locks onto tune transients. Measure on the BASIC prompt for a clean baseline, and note that the recorder taps upstream of the output device, so device latency shows up live but never in the file.

VIC frame trace and state snapshots (Cmd+Shift+S)

Cmd+Shift+S on macOS, Ctrl+Shift+S elsewhere (both work on either), downloads a debug snapshot of the machine: a c64-snapshot-<timestamp>.json state dump plus a sibling c64-snapshot-<timestamp>.png of the rendered frame. It is handled before the "is the machine running" gate, so a JAMmed or paused machine can still be inspected. The JSON embeds the same PNG as framebufferPng, so the one file is self-contained; the sibling .png is just for quick preview. (Bound in input.js; the download itself is downloadSnapshot() in media.js.)

The snapshot's vicFrameDebug block is where the VIC frame trace lands: per-pixel borderBuffer / graphicsPriorityBuffer / spriteOwnerBuffer maps plus per-line register and flag traces (frameTraceHBorder, frameTraceVBorder, frameTraceLineD011, frameTraceLineD016, frameTraceLineD015, …), indexed raster * 64 + cycle. Combined with framebufferPng this answers questions like "is this side-border garbage the border being open or closed?" pixel by pixel.

Enable the trace first for a whole-frame map. With the trace off, vicFrameDebug falls back to the VIC's line-sized live buffers, so only the last rendered line is meaningful, and the snapshot records traceEnabled: false. c64Trace.enable() switches the VIC to accumulating each rendered line into a full-frame map, so the snapshot then covers the entire frame. The intended loop:

c64Trace.enable()    // start accumulating whole-frame trace data
// …run the demo to the exact moment of interest…
// press Cmd+Shift+S / Ctrl+Shift+S to download the snapshot (JSON + PNG)
c64Trace.disable()   // stop, restore the fast path
c64Trace.status()    // check whether it's currently capturing

Performance. Tracing selects live per-cycle rendering, dense capture and RAM-based graphics, and accumulates whole-frame diagnostic maps. Sprite idle skipping and selective fixups remain active. Expect a frame-rate drop with tracing enabled. c64Trace.disable() restores the normal rendering path.

Shared external-data-bus model

The shared 8-bit bus latch is updated by CPU reads, CPU writes except $00/$01, and VIC fetches including refresh. Unmapped IO1/IO2 reads sample this latch. Color RAM reads combine its upper nibble with the stored low nibble and re-drive the latch. Writes to $00/$01 leave the VIC phi1 byte in underlying RAM.

Sprite idle fetches sample the separate VIC internal latch. CPU internal cycles perform discarded reads. These behaviours are always enabled; c64Bus.status() and bus tracing expose their state.

Per-cycle bus trace

A debug-only ring buffer that records phi1/phi2 owner, BA/AEC/RDY, the current CPU microop kind, and both bus latches per master cycle. Off by default because it allocates one entry per cycle (about 985 kHz).

machine.enableBusTrace(1024)     // start capturing into a 1024-entry ring
machine.busTraceSnapshot(64)     // return the most recent 64 entries (oldest first)
machine.disableBusTrace()        // stop + free the ring

Useful when you suspect an open-bus or BA/AEC timing issue. Each entry has frame, raster, cycle, ba, aec, rdy, cpuBlocked, cpuOp, phi2Owner, externalDataBus8, vicInternalBus8.