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#
# 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.
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.
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.
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/.
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. |
| 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.
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.