█▓▒░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

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

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:

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.

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.