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.
vic2-render-history-spec-test.js: snapshot isolation, pixels and collision/IRQ timing on both PAL variants.vic2-fetch-feed-spec-test.js: fetch boundaries, XSCROLL, memory/bank changes, save states, and live/deferred/tracing comparisons.vic2-graphics-foreground-spec-test.js: colors and foreground classification across all graphics modes, including collisions beneath borders.
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/.
chat-server-spec-test.jsruns the chat server with real WebSocket clients and a clock it drives. A hello needs an account, and its username is the name in the chat. It checks the one channel, which never closes, the grace period, games mentioned in messages (and that unlocks stay out of it), and votes, counted by address, that remove someone for an hour. Then limits, bans, the log and its retention, the admin console, and requests that try to pollute prototypes, smuggle fields or write control characters to the log or a notification. Accounts are stubbed there.accounts-spec-test.jschecks passkey sign-up and sign-in against the WebAuthn rules with a software authenticator making real keys, sessions, CORS and rate limits, more passkeys per account, and the recovery code: shown once, kept as a hash, single-use, and locked for an hour after five wrong tries.admin-console-spec-test.jschecks the admin console: the passkey sign-in, setting up the first passkey with the single-use setup key (only its hash on the server), device keys that last 10 minutes, the players API (overview, players, a player's details and login history with address and browser), and that each action needs a reason from the console's own page, does what it says and is logged. A banned address reads only what is public, is flagged in the players list, stays banned in the database, and is let back in when the ban is lifted. A software authenticator (_authenticator.js) stands in for the passkeys.games-api-spec-test.jschecks the games API: sets loading and checked, games found by file hash, unlocks, leaderboards with shared ranks, the activity feeds, profiles, and what is told to the chat.test/achievements/holds the rcheevos module's own checks, the engine (a file without a set never leaves the browser), and one spec test per achievement set, which synthesizes RAM frame by frame.now-loaded-spec-test.jschecks how loaded media are named.
The browser tests drive the app in headless Chromium against a dev server, with Chromium's virtual authenticator as the passkey device:
achievements-account-browser-test.js: signing up, the recovery code, signing in again, recovering on a second device, managing passkeys, the floating window and its shortcut.achievements-browser-test.js: loading the Uridium cartridge opens the window on its set (on a phone it flashes the button instead), then the activity feed, profiles and game pages.chat-window-browser-test.js: signing in to chat, mentioning a game with @ and opening it from the message, dragging and resizing on a desktop, the phone view, messages, the unread count, hiding a member and signing out.chat-injection-browser-test.jsattacks the chat and the admin console: HTML, script and URL payloads in usernames, messages and a mentioned game's title must all show as text, never run or become elements, and a forged mention is dropped. It also checks that the admin's online list follows joins, disconnects, reconnects, removal and leaving.
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:
- VICE's monitor
CYCcolumn is 0-based (0–62); ourcycleInLineand Bauer's spec are 1-based (1–63). So VICECYC= ourcycleInLine − 1: a write VICE reports atCYC 12lands at ourcycleInLine 13, the same physical cycle. Don't mistake the off-by-one for a timing bug. - Match the two runs by demo state (which scene, pose or digit is on screen), never by absolute frame or cycle count. Our boot timing differs from VICE's, so the runs drift apart in wall-clock time.
- VICE flag polarity is the reverse of common CLI intuition:
-NAMEenables a resource and+NAMEdisables it. For example,-drive8truedriveturns true drive on.
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:
- Palette mismatch. Testprogs VICE screenshots are usually Pepto, while the emulator default is Colodore. Raw RGB diffs are palette noise; the shared tool quantizes both sides to their own 16-colour palette. If you capture a raw PNG yourself for RGB diffing or eyeballing, set the emulator palette to Pepto first with
setVicPalette('pepto'). - Crop offset. VICE PNG row 0 is raster 16 while our framebuffer row 0 is raster 15. The comparator searches small
dx,dyoffsets;PERFECT (1-line crop offset)is a pass.
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.
- Remote text monitor (
-remotemonitor -remotemonitoraddress ip4://127.0.0.1:PORT): connect, wait for boot or a-limitcycleshalt, send newline-terminated commands.break exec $XXXXsets a checkpoint; on each hitrdumps registers including LIN/CYC (raster and cycle-in-line),m d011 d011reads memory,save "<file>" 0 <start> <end>dumps memory while stopped,xresumes,screenshot "out.png" 2saves a PNG. - Binary monitor (
-binarymonitor -binarymonitoraddress ip4://127.0.0.1:PORT): a framed binary protocol. Setexec/store/loadcheckpoints, advance one instruction (then read registers forLIN/CYC), or store-watch a register. Lower-level, but scriptable to thousands of samples (per-instruction(PC, LIN, CYC)traces and per-line register store-watches).
The workflow that works:
- Reach the scene in VICE: checkpoint the demo's inner-loop routine and advance N hits to a stable frame.
- Capture VICE ground truth: a per-instruction
(PC, LIN, CYC)trace, and/or per-line register stores with theirCYC, and/or a screenshot. - 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 eachcpu.atInstructionBoundary(). Boot with about 200 warm-up frames beforeloadPRG+injectRun, or KERNAL init clobbers the injected RUN. - Diff by PC, not by time; the instruction stream is identical. A divergent
CYCfor the same PC points straight at the cycle where our timing differs. ACYCgap 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:
- Cross-model failures are by design.
detect,osc_topbit,waveforms-10/-20andoscsample0/1ship as-old/-newor-6581/-8580pairs that each assert one specific chip, so running a variant on the other chip correctly goes red. VICE behaves the same. Judge each variant only on its own chip. - Some programs compare against reference data on their bundled
.d64, so with no disk attached they fail on theLOADrather than on the SID. Their-dumpcounterparts pass without one. bitfade/delaynoiseis timing-dependent. The same binary returns different values purely from a different frame budget (2.5M–4.4M cycles apart on the 8580), so a single-shot comparison measures sampling noise.- Readme reference values can predate your VICE by years.
bitfade's are from VICE r32106 and listdelayenv3as$2c, where both VICE 3.10 and this emulator give1. Re-measure before treating a mismatch as a gap.
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:
- A PSID whose
initruns before the KERNAL has booted stays nearly mute. - A driver called as a plain subroutine instead of from an interrupt can sit in its stopped state, rewriting
$D418to zero every frame. Driveplayfrom a raster IRQ, and reinstall the vector after everyinit, since a driver's own init may restore the KERNAL default. - Wrapping a tune that loads inside the PRG image (anything near
$1000) overlaps the payload copy with its own source, so it must run descending. This silences VICE too, which is the fastest way to tell a broken wrapper from a broken engine.
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:
- ~97 % of the code lines in the
src/files the tests import, every imported file above 90 %. What is left uncovered is mostly defensivecatchblocks and engine-specific branches. - About half of all of
src/. The rest has no Node entry point:main.js,media.js,input.js, the Retro Vibes scenes, the dialogs, tooltips and splash run only in a browser, and the suite does not drive one; the screenshot and demo tools cover them by hand. Two entries in that list are artefacts:sid-filter.jsandsid-worklet.jsare exercised, but through a loader that evaluates them in avmcontext, so V8 credits the evaluated script rather than the file.
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.
driftcovers the last 8 report periods,driftAvgthe span it names. Anything inside roughly +/-100 ppm is two clocks in step.- Both read
n/auntil their first measurement window fills, and after a clock re-anchor (init, reset,resync, desync snap, backlog fast-forward) voids the history. A re-anchor is never reported as a rate. - A period with no writes clears
driftuntil a fresh window fills.driftAvgretains its last reading and span; its anchor survives the gap. - A main-thread stall is a position step, not a rate difference. It shows up as
a temporary change in
driftwhile the step is inside its window, whereas a real rate difference persists. LongerdriftAvgspans dilute the step. backlogFFclimbing over a long session is the signature of accumulating lateness (each correction skips the audio it collapsed). It is cumulative, so a steadybacklogFF=1is one correction at startup and nothing since.
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.