Desktop and web emulator for the AC6502 family of computer systems.
Runs on macOS, Windows, and Linux as a native Electron application, and in any modern browser via GitHub Pages.
https://acwright.github.io/6502-EMULATOR/
Sound starts on your first interaction with the page — browsers do not let audio play before that — so the emulator comes up silent and the toolbar's mute button is the indicator: it reads muted until sound can actually come out. Click it (or just start typing) and the machine is audible.
The same build also ships an embed.html page for putting a machine in an
<iframe> on your own site — see Embedding.
📖 Guide: AC6502 Documentation — the user's and programmer's guide for the whole family. The emulator chapter is the tutorial half of this README.
3.0 adds a second video card. The TMS9918A of 2.x is still here, unchanged, and is still the default. Beside it is the 6502-PICOVDP of docs/VDP-SPEC.md, which runs Text and Graphics I programs — the BIOS among them — unmodified, drops Graphics II and Multicolor, and adds layers, 256 colours, hardware scrolling and a built-in font. Pick one with
--vdp tms9918aor--vdp picovdp,vdp=in a web or embed URL, or Settings → VIDEO CARD. Each card boots its own bundled BIOS: the TMS9918A boots BIOS 1.6, and since 3.1 the PICOVDP boots BIOS 2.0, which is for the PICOVDP alone. Snapshots from 2.x load on the TMS9918A. Release 2.7.0 stays frozen at/v2/. docs/MIGRATING.md covers moving a program to the PICOVDP.
When the emulator starts it behaves exactly like the real machine being powered on, with the TMS9918A video card unless another is chosen. The card's bundled BIOS ROM loads and probes all I/O slots; what happens next depends on the card.
TMS9918A — BIOS 1.6: splash, ENTER/ESC, Monitor.
- A splash screen is displayed on the video card:
-- 6502 BIOS v1.6 -- - After a 5-second countdown the system auto-boots to the built-in BASIC interpreter.
- Pressing ESC at the splash screen drops into the machine-code Monitor instead.
6502-PICOVDP — BIOS 2.0: straight to BASIC, no Monitor.
- The screen clears and the
AC6502logo is drawn across the top. AC6502 BIOS v2.0,BASIC v2.0 30718 BYTES FREEand the cards found are printed, thenOK, about half an emulated second after power-on. There is no splash, no countdown and no Monitor.
| Component | Details |
|---|---|
| CPU | W65C02S, cycle-accurate, IRQ / NMI, full opcode set including the WAI / STP halt states |
| RAM | 32 KB system RAM + 2 × optional expansion banks |
| ROM | 32 KB (BIOS bundled, one per video card: 1.6 for the TMS9918A, 2.0 for the PICOVDP; replaceable via Load ROM) |
| Video | Either of two cards (--vdp). TMS9918A (the default) — the card of emulator 2.x: Text, Graphics I, Graphics II and Multicolor, 32 sprites, 16 KB VRAM. 6502-PICOVDP — a superset of the TMS9918A's Text and Graphics I: two tile layers at 1/2/4/8 bpp, four display modes up to 320×240, 64 sprites (up to 32 per line), 256 colours from 4096, hardware scrolling, scanline interrupts, 64 KB VRAM, and a built-in CP437 font loaded at reset (spec draft 0.5). See docs/VDP-SPEC.md |
| Audio | MOS 6581 SID — 3 voices, rendered at the output device's sample rate |
| Serial | Rockwell R6551 ACIA — configurable baud/parity/data/stop |
| Storage | CompactFlash 8-bit IDE — 256 × 1 MB banks (256 MB total, DISK n) |
| RTC / NVRAM | DS1511Y+ — real-time clock + 256 B battery-backed NVRAM |
| GPIO | 6522 VIA — two 8-bit ports, two 16-bit timers, matrix keyboard |
| Cartridge | 32 KB flat ROM cart (28C256/27C256), or the AC6502 Flash Cart — SST39SF010A/020A/040, 128K to 1 MB, an 8 KB window banked by a write to $E000-$FFFF over a fixed $E000-$FFFF, with the JEDEC command set and its busy windows so a cart can program itself |
The emulator captures the keyboard whenever the page has it. An embedded emulator captures it only while the frame has focus, so arrow keys and space still scroll the host page until the reader clicks into the machine.
| Button | Action |
|---|---|
| CPU chip | Load ROM (.bin / .rom) — replaces the default BIOS |
| Document+ | Load Cartridge (.bin / .crt / .cart) — 32K ROM or a flash cart |
| Document$ | Load Program into RAM at $0800 (.prg / .bas) |
| ▶ / ■ | Run / Stop emulation |
| ↺ | Reset — pulses the CPU RESET line only; RAM is preserved, mirroring the hardware reset button (a BASIC session survives) |
| ⏻ | Power Cycle — cold boot that zeroes RAM, forcing a clean BASIC cold start |
| 🔊 / 🔇 | Mute / unmute. Three states: dimmed and muted (audio hasn't started — in the browser this button, or any other click or keypress, is what starts it), muted, unmuted. The icon shows whether sound is audible right now, not the saved preference, so a browser reload reads muted until the AudioContext is genuinely running |
| ⌨ | Show / hide the on-screen keyboard — see below |
| Clipboard | Paste text — opens a modal that types the pasted text into the machine as keystrokes (e.g. to enter a BASIC program) |
| ⚙ | Open / close the Settings panel |
Because the emulator captures all keystrokes as emulated keyboard input, a normal ⌘V / Ctrl+V paste won't reach the terminal — use the Clipboard button and paste into the modal instead.
The ⌨ button raises the board's own keyboard: the same 67 keys in the same places at the same widths as the hardware, taken from the keyboard chapter and the keyboard matrix.
Presses go in as USB HID codes, so the machine cannot tell it from a real keyboard: Shift and Ctrl go down and up around the key rather than being resolved in the UI, and the keyboard attachments work out the matrix position and the ASCII exactly as the AB Controller does.
It behaves like the board, which means it does not behave like a phone keyboard:
- Letters are capitals, always. Shift changes the number row and the symbols and nothing else, because the ACE has no lower case.
- Shift, Ctrl, Alt and Fn latch. Tap once to arm for the next key, again to lock, a third time to release.
- Fn turns the number row into F1–F10, which is how the host is told about a
key no keyboard has a code for.
KeyboardMatrixAttachmentdecodes it back into a held Fn and a digit. - Caps Lock and Menu send nothing. They are switches in the grid that the controller drops, and so are Alt and Fn.
It is not a touch-only control. It is the only keyboard a phone has, and it is
also where Fn, Ins and the arrows are on a laptop that has moved them
somewhere else.
The layout follows the window rather than the device. The control bar wraps instead of running off the edge, and on a short viewport it becomes a single row that scrolls sideways. In landscape the keyboard sits beside the screen rather than under it — a 4:3 picture in a wide short window is limited by height and nothing else, so side by side lets both take that height instead of splitting it. The settings panel fills the screen below 560px, and every field is raised to 16px on a touch pointer, which is the threshold below which iOS Safari zooms the page in on focus and does not zoom back out.
The embed does all of the same things — it lays itself out for the
frame it is given rather than for the window — and opens the on-screen keyboard
by itself on a device that has no keyboard of its own. keyboard=1 / keyboard=0
override that.
Files (ROM / Cart / Program / Binary)
- Each row shows the currently loaded file and a Load button.
- When a non-default file is loaded, an ✕ button appears to unload it and return to the default: ROM reverts to the bundled BIOS, Cart is ejected, and Program is cleared (the machine power-cycles to wipe it from RAM).
- BIN loads raw bytes at an explicit hex address — the emulator's equivalent of BASIC's
BLOAD. Enter the address first; the Load button stays disabled until it is a valid RAM address. BASIC's state is left untouched, so run the code withSYS(or from the Monitor).
Video card (TMS9918A / 6502-PICOVDP)
- Changing the card is a power cycle with the other card in the slot: RAM is cleared and the machine boots again. CF and NVRAM are kept.
- The ROM follows the card while it is the bundled BIOS. A ROM you loaded stays; a BIOS 2.x ROM on the TMS9918A shows a warning, since 2.x needs the PICOVDP.
- The choice is saved (the desktop app's settings, or the browser's local storage for the web build). A
vdp=in the page URL applies to that load only.
.prg and .bas are the same format: the raw bytes that belong at $0800 upward — a tokenized BASIC line chain. A .prg is simply an image whose BASIC part is a one-line stub (10 SYS 2060) carrying machine code after the end-of-program marker. The extension is a note to yourself; nothing in the emulator or the BIOS inspects it.
Loading a program mirrors BASIC's own LOAD: the bytes are written to $0800 and BASIC's end-of-program pointers (VARTAB, ARYTAB, STREND) are moved past the whole image. That last part matters — BASIC allocates variables from those pointers, so without the fixup the first variable assignment lands on top of the program, and for a .prg the extent has to cover the trailing machine code, which a line-chain walk would miss.
A program can be loaded at any time, including before the machine has booted. BASIC's startup rewrites those pointers unconditionally, so when the machine is reset or stopped the fixup cannot be applied up front; the emulator keeps the image pending and applies it the moment BASIC finishes initialising. The Settings panel shows that it is waiting. This is what makes preloading a program and then booting — the shape a command-line launcher needs — come out correct rather than depending on BASIC's own chain walk, which stops at the end-of-program marker and would leave a .prg's machine code exposed.
One constraint worth knowing: don't edit a .prg's BASIC stub. Inserting or deleting a line block-moves everything above it, which shifts the attached machine code out from under its own absolute addresses. LIST, RUN and SAVE are fine. (The real machine has the same constraint.)
Raw machine code with no BASIC stub belongs in the BIN row with an explicit address, not in the program loader.
Serial Port
- Electron: choose port from the detected list, configure baud rate, data bits, parity and stop bits, then click Connect.
- Web: click Connect — the browser's port-picker dialog opens.
- Default: 19200 8-N-1 (matches the real machine's boot configuration). Serial is not connected on startup.
- The port is the far end of the emulated machine's cable — where a terminal or another computer would plug into the board — not a terminal for a real board. The machine does the handshake itself: its RTS drives the port's RTS line, and the port's CTS, DCD and DSR reach the card's pins wherever a jumper connects them to the cable. The port opens with no flow control of this computer's own, which would fight the machine for the RTS line. Before 3.3 the port had its own Flow Control setting; it is gone, and a settings file that has it is migrated once, keeping everything else.
- Serial card (both builds, saved) is the machine's end of the cable: the ACE's own R6551 (the default), the COB's Serial Card or its Serial Card Pro, and the jumpers that card has —
CTS ENon the ACE and the Serial Card,DCD ENon the ACE,DCD Selecton the Pro. At Ground, where every board is built, the pin is always asserted and the far end cannot reach it; that is the default, and the machine every earlier version emulated. At Cable the far end drives it: CTS dropped stops the transmitter, and DCD dropped turns the receiver off and loses what arrives. WithCTS ENon the cable and a far end that is not asserting CTS the machine looks dead — no banner, no echo — until CTS comes back, and then everything arrives; that is what the board does. With no port connected the lines are held asserted. Moving a jumper is not a power cycle. - Terminal honours RTS (both builds, saved) is the far end's manners, for when it has none of its own: on by default, as a terminal set up for the board should be. Bytes the port delivers wait on its side of the cable while the machine holds the ACIA's RTS high, and go in order when it drops. Off is a terminal that ignores RTS: bytes go regardless, a long paste can overrun the BIOS's buffer, and whatever arrives while the ACIA's receiver is off is lost. It is
--peer-rtson the command line (formerly--[no-]flow-control, which still works). A settings file from 3.1.1 or earlier is migrated to on once, because those versions saved the old default with any other change. With it on, a long paste into BASIC arrives whole on both bundled ROMs; with it off, lines are lost to overrun and neither hangs. The deadlock that used to make a long paste unsafe — raising RTS turns the R6551's transmitter off, so the next echo waits for good — is fixed from 6502-BIOSv1.6(BIOS.bin) andv2.0.1(BIOS2.bin) on, and what ships here isv1.6andv2.0.2.
CF Card
- Electron: Select… opens a file dialog; the chosen
.imgor.binis loaded into the emulator immediately and persisted across restarts. When a custom image is selected, an ✕ button reverts to the default image (the selected file is left untouched on disk). - Web: Load uploads a file from disk; Export downloads the current CF image.
- Default: a 256 MB blank image created in the app's data directory on first launch.
NVRAM
- Same pattern as CF Card (256 bytes, DS1511Y+ battery-backed registers).
Joystick
- Keyboard for
JOY(1)picks what backs the primary stick (VIA Port B — what a one-player cartridge reads): Numpad (8/4/6/2 move, 0 = A, . = B, 5 = X, Enter = Y), Arrows + Space (arrows move, Space = A, / = B, . = X, , = Y) or Off. Numpad is the default and is collision-free — the machine has no keypad, so nothing on it can reach BASIC as a typed character. Arrows is for a laptop without a numpad, and while it is selected the cursor keys drive the stick instead of editing a line in BASIC or the Monitor. - WASD keyboard for
JOY(2)arms the second stick (Port A): WASD move, Space = A, E = B, Q = X, R = Y. Off by default because every one of those keys collides with typing. - Gamepads need no setting: the first pad drives
JOY(1), the secondJOY(2), and a pad and the keyboard can drive the same stick at once. The control bar shows what is live on each.
Debug Server (Electron only)
- Start opens a JSON-RPC service on a loopback port so
6502 dbgand6502 attachcan drive this running machine — the same protocol and the same commands as a headless instance. The panel shows the connection URL, token included, with a button to copy it —6502 dbgand6502 attachfind a local server on their own, so the URL is for anything else that speaks the protocol. - Off until you start it. A shipped build never opens a socket on its own.
Command Line (Electron only)
- Install adds the
6502command to yourPATH—/usr/local/binwhere permitted, falling back to~/.local/binwith a hint if not. Uninstall removes it. The panel shows where it landed. - Once installed,
6502 run build/game.prgopens this app with the build already loaded — see With a window, or without. - On Windows and Linux
.debthe platform installer owns this, so the action reports that there is nothing to do rather than offering a broken button. - 3.1.1 renamed the app from 6502 Emulator to AC6502 Emulator. A command
installed from the old app points into
6502 Emulator.app; after upgrading, press Install again (and remove the old app). Settings and saved machines stay where they were: the app keeps the6502-emulatordata folder.
Press F11 (or ⌘ Return on macOS) to toggle fullscreen. The 4:3 VDP aspect ratio is always maintained via CSS letterboxing.
The web build ships a second page, embed.html, which is the same machine sized
for an <iframe> on somebody else's site — no settings panel, no serial, and
nothing written to disk unless asked for:
<iframe
src="https://acwright.github.io/6502-EMULATOR/embed.html?prg=game.prg&autostart=1"
width="640" height="520"
allow="autoplay; gamepad; fullscreen"
style="border: 0"
></iframe>The parameters most embeds need:
| Parameter | Default | Meaning |
|---|---|---|
prg |
— | URL of a .prg / .bas to load at $0800 (prg64= carries the bytes inline, needing no CORS) |
autostart |
1 |
Boot on load; 0 holds the machine until the reader clicks |
controls |
minimal |
full | minimal | none |
muted |
1 |
Start muted — browsers block autoplay in a frame regardless |
keyboard |
auto |
The on-screen keyboard: 1, 0, or auto — on for a touch-only device |
vdp |
tms9918a |
The video card: tms9918a or picovdp. Name it if the program needs one — the default will change in a later release |
docs/EMBEDDING.md is the full reference: every
parameter, the inline base64 forms, CORS and CSP, sizing, and the postMessage
control API for driving a frame from the surrounding page.
Release 2.7.0 (BIOS v1.6) stays at https://acwright.github.io/6502-EMULATOR/v2/
for good, for pages that must never change. See
Pinned legacy build.
6502 run boots a machine with your build output already attached — in the
desktop app, or without a window at all, with its console wired to stdin/stdout.
Either way it can be driven and inspected from a shell. That covers both ends of
the job: seeing a fresh build run, and having a build script, CI run or AI agent
test 6502 code end to end.
6502 run # boot a machine, optionally loaded with your build output
6502 dbg # one-shot debug commands against a running emulator
6502 attach # an interactive monitor sessionThree ways to get the command, in order of convenience:
# 1. Installed by the app: Settings → COMMAND LINE → Install
6502 --version
# 2. From a checkout — always works, and what CI should use
npm run build:cli # compiles to out/cli/
node out/cli/index.js run --help
# 3. During development
npm run cli -- run --headless --helpThe shim works because Electron already bundles Node: it runs the app binary with
ELECTRON_RUN_AS_NODE=1, so no Node runtime of your own is needed and the CLI
can never drift from the app version — it is the same file either way.
Further reading:
- docs/AGENTS.md — how to drive the machine from an agent or a test script, written to be copied into your own 6502 project
- docs/DEBUG-PROTOCOL.md — the JSON-RPC reference
- examples/ — runnable scripts, exercised by CI
6502 run opens the desktop app with your build already loaded. --headless
runs the same machine with no window at all, its console wired to stdin and
stdout.
6502 run build/game.prg # in the app: video, sound, keyboard
6502 run --headless build/game.prg # no window: the console is a byte streamThe media flags are the same either way — --rom, --cart (with --cart-save
and --no-cart-save), --program, --bin, --cf, --nvram — as are --vdp, --baud, --rtc, --pause,
--debug and --symbols. What differs is everything that only makes sense for
one of them:
| Window | --headless |
|
|---|---|---|
| Console | The video card and the keyboard | stdin/stdout, or --console video |
| Speed | Real time | Flat out, unless --realtime |
| Ends when | The window is closed | --timeout, --exit-on, --max-cycles, ^C |
| Also has | --fullscreen, --detach, --serial <port> |
--console, --empty, --input-after, --screenshot, --json |
Flags from the wrong column are refused with the reason, rather than accepted and quietly ignored.
Every slot is populated by default, which means the BIOS's hardware probe always
finds everything and the code it wrote for a machine that lacks something is
unreachable from a script. --empty fits an empty slot instead:
6502 run --headless --empty storage # DIR, LOAD "name" and friends -> ?NO DEVICE ERROR
6502 run --headless --empty sound # SOUND and VOL parse, range-check, and return silently
6502 run --headless --empty rtc,via # no clock, no keyboard or joystick GPIONames are ram1, ram2, rtc, storage, serial, via, sound, video, or
io1..io8, comma-separated. MEM reports the result in HW=$xx: a default
headless machine reads $7F (everything but video, which is how the console
knows to be serial), and --empty storage makes it $77.
--cart takes the AC6502 Flash Cart as readily as the 32K ROM cart. The size
picks the mapper and the name never does, so there is no flag to set:
| Target suffix | Bytes | Part | Mapper |
|---|---|---|---|
| (none) | 32,768 | 28C256 / 27C256 | flat |
-128K |
131,072 | SST39SF010A | banked |
-256K |
262,144 | SST39SF020A | banked |
-512K |
524,288 | SST39SF040 | banked |
-1M |
1,048,576 | SST39SF040 ×2 | banked |
A name whose suffix disagrees with the byte count is a warning, not an error:
Foo-512K.crt at 262,144 bytes loads as a 256K cart and says so. The name is
for whoever is reading ls; the bytes are what the mapper follows. Anything
that is not one of the five is refused, in the terminal, before the machine
boots.
A banked cart can program its own flash, and those writes go into a sidecar:
6502 run --headless --cart Cart-512K.crt # -> Cart-512K.sav
6502 run --headless --cart Cart-512K.crt --cart-save /tmp/x.sav
6502 run --headless --cart Cart-512K.crt --no-cart-save # throw them awayThe emulator never writes a .crt — not on save, not on eject, not on quit,
in the CLI, the app or the browser. That is the whole point of the sidecar: a
save written while testing must not be able to reach a real chip by accident,
and 6502-flash program has no flag to include one. The .sav carries the
image's size and CRC-32, so rebuilding the cartridge makes an old save refuse to
apply rather than landing over new code — the cart starts without it, says so,
and the stale file is left exactly where it was. A run that programmed nothing
writes no file at all.
A cart loaded through a file picker or an embed has no path to sit beside, so its saves go into the browser's storage under the image's checksum instead.
Two commands read the register a banked cart is otherwise invisible without —
$C000 means a different 8 KB depending on it:
6502 dbg cart # size, checksum, bank count, the register, the window
6502 dbg cart bank 9 # move the window, as a write to $E000 doessamples/flash-cart/ is a worked cartridge: three banks drawn through one
window, and a byte it programs into its own flash and reads back. The normative
rules for all of this are 6502-VCS PLAN.md §§1–4, and
docs/handoff/6502-VCS.md is what this emulator
guarantees against them.
A windowed run does not return until the window closes, which is what makes it
usable as a build step — assemble, look at it, close it, back to the shell.
--detach hands the terminal straight back instead.
6502 run --cart build/game.crt --fullscreen
6502 run --debug --pause --symbols build/game.lbl build/game.prg
6502 run --serial /dev/tty.usbserial-1420 --baud 9600 # ACIA out to real hardwareAnything the Settings panel configures, the command line can set too —
--vdp, --cf, --nvram, --baud, --serial-config (framing, as 8N1
or 7E2), --serial-card (standard, pro or ace) with its jumpers --cts
and --dcd (ground or cable), and --peer-rts (honour or ignore; the
older --[no-]flow-control still works). --serial-flow is deprecated and
ignored: a port opens without the OS's own RTS/CTS, and the machine drives the
port's RTS itself. --freq is deprecated too: the emulator runs at 1 MHz only, as the ACE does, so
--freq 1 is accepted with a warning and any other clock is refused. They show up in the panel as the values in effect, but apply to that
launch alone: nothing is written to your saved settings, and what you change in
the panel afterwards persists exactly as it always did. The machine does write
back to a --cf or --nvram image as it would to any card, so point those at a
copy of anything you want kept byte for byte.
The app it opens is the one that installed the command: the shim runs the CLI
inside the app's own Electron, so process.execPath is the app and the two
can never be different versions. From a checkout it launches the build in
out/; --app <path> or SIXTY5O2_APP override both.
The BIOS probes for a video card on boot, and finding none it sets IO_MODE to
serial and routes the console to the 6551 ACIA — and its IRQ handler feeds
received bytes into the same input buffer the keyboard uses. So a machine booted
with an empty video slot is a complete, bidirectional terminal session with no
firmware changes. --console serial (the default) is exactly that.
# Open the desktop app with a fresh build in it.
./bin/6502 run build/game.prg
# Boot to BASIC and run a line. ENTER at the splash skips the countdown.
printf '\rPRINT 2+2\r' | ./bin/6502 run --headless --exit-on 'OK[^]*OK'
# 6502 BASIC V2.0
# 30718 BYTES FREE
#
# OK
# PRINT 2+2
# 4
# Drop straight into the machine-code Monitor.
printf '\x1b' | ./bin/6502 run --headless --timeout 5s
# Load build output and give it a cycle budget.
./bin/6502 run --headless build/game.prg --max-cycles 5e6
# Raw bytes at an address, the equivalent of BASIC's BLOAD.
./bin/6502 run --headless --bin 0x7F00=code.bin
# Run a cartridge on the video card for five emulated seconds and keep the picture.
./bin/6502 run --headless --console video --rtc 2026-01-01T00:00:00 \
--cart build/game.crt --max-cycles 5e6 --screenshot game.png--screenshot needs --console video, and refuses a serial console rather than
quietly fitting a card: whether a video card is present decides which console the
BIOS picks. With --rtc and --max-cycles the PNG is the same bytes every run.
A full boot to the OK prompt on the default card's BIOS 1.6 takes roughly
50 ms and 450,000 cycles, against five seconds on the real machine — the emulator runs at about 11 MHz when it is
not pacing itself against the wall clock. (That is with a leading CR to answer the
splash. Letting the countdown expire costs 5,354,440 cycles, which is where
snapshots earn their keep.)
6502 run --headless --debug — or Settings → DEBUG SERVER → Start in the
desktop app — serves JSON-RPC over a loopback port and publishes the port and
token in ~/.6502/session.json. Every 6502 dbg command then needs no arguments:
6502 dbg regs # registers
6502 dbg mem 0x0300 32 # memory, any space: cpu/ram/rom/vram/nvram/cf
6502 dbg disasm main 20 # symbols work anywhere an address does
6502 dbg break main --condition 'A == $FF'
6502 dbg step --over
6502 dbg send 'PRINT 2+2\r' --wait 'OK' # over the serial console
6502 dbg send 'LIST\r' --wait 'OK' --since 4096 # ... from a cursor you already hold
6502 dbg wait --serial 'READY\.' --timeout 5s
6502 dbg screen png shot.png # when a video card is present
6502 dbg video # the card: mode, status, both ports
6502 dbg video regs --set 0x0D=4 # all 128 registers, written through the card
6502 dbg state save ready.state # snapshot the whole machine
6502 dbg state load ready.state # ... and restore it in ~1 ms
6502 attach # the same commands, interactivelyEach dbg invocation is a separate process that connects, calls, prints and
exits, so there is no session to manage — which is what makes this usable from a
shell script or an agent. Exit codes carry the outcome (0 ok, 1 error, 2
timed out, 3 no emulator, 4 breakpoint hit), and --json gives the raw result.
The server is off unless asked for, binds loopback, requires a token off
loopback, and refuses any request carrying a browser Origin — a loopback port is
reachable from every page the user has open. See
docs/DEBUG-PROTOCOL.md.
- The splash consumes keystrokes. BIOS 1.6's splash, on the TMS9918A (the
default), takes ENTER for BASIC or ESC for the Monitor and acts immediately;
anything else sent before that choice is made is swallowed. Lead with a CR, or
use
--input-after <regex>to hold input until a prompt appears. BIOS 2.0, on--vdp picovdp, has no splash and goes straight to BASIC. - Input is paced at the serial line rate, measured in emulated cycles rather than wall time, so input lands at the same point in the program whether the machine is running flat out or in real time.
- RTS/CTS flow control is on by default;
--peer-rts ignoreturns it off (as does the older--no-flow-control). Input waits while the machine holds the ACIA's RTS high (from reset until the firmware programs the ACIA, and whenever the BIOS's 256-byte input buffer is nearly full) and resumes in order when RTS drops; nothing is dropped. It applies to stdin andserial.write, and to what a host serial port in the app has already delivered. Off is a terminal that ignores RTS: a long paste can overrun the buffer, and input that reaches the ACIA while its receiver is off is lost, as on the board. Firmware that raises RTS and never lowers it stalls with flow control on, as it would at a terminal. - A long paste into BASIC is safe on both bundled ROMs. It once was not:
raising RTS turns the R6551's transmitter off (below), so firmware that raised
it and then echoed a character waited for good — on the board as here. 6502-BIOS
v1.6(BIOS.bin) andv2.0.1(BIOS2.bin) fix it, andv1.6andv2.0.2are what is bundled: the serial output path lowers RTS around each byte, declines to send at all while the input buffer is over its high mark, never laps the input ring, and reads the data register only when a byte is really there. With flow control on the whole paste arrives; with it off lines are lost to overrun and nothing hangs. Pasting more than the machine can swallow is still slower than loading a tokenized program with--prg. - The ACIA is an R6551. Command register bit 0 (DTR) clear, as after a reset,
disables its receiver, transmitter and interrupts: a program that polls the
ACIA directly must enable it first, as Wozmon's
$8Bdoes. Bits 3-2 at00raise RTS and turn the transmitter off, so a byte written then is never sent and TDRE never sets. Status bits 6 and 5 (DSR, DCD) read 0: the pins are low, asserted at ground or by a console that asserts its lines. - The serial card is the ACE's own,
--serial-card ace, withCTS ENandDCD ENat ground, as on every board built, so nothing at the far end can stop the machine.--serial-card standardandproare the COB's Serial Card and Serial Card Pro.--cts cableconnects CTS to the far end, and a far end that drops it then stops the transmitter as a real board's does: the machine prints nothing at all — not even its banner — until CTS comes back, and then all of it.--dcd cabledoes the same to the receiver, and loses what arrives meanwhile. Headless, the console asserts its lines;6502 dbg lines --cts offdrops one. In the app, a real port's own CTS, DCD and DSR reach the pins, and the machine's RTS drives the port's.6502 dbg infonames the card whenever it is not the ACE at ground. - Newlines are translated to CR on the way in, which is what a serial terminal sends for Enter. BASIC ends a line on CR and would otherwise never see one.
--binwrites before the machine boots. At$0800that is BASIC's program area and its cold start will read those bytes as a tokenized program; use--programfor images that belong there.--rtc <iso8601>makes a run reproducible. The real-time clock is the only part of the engine that reads the host's clock; fix it and the same ROM, input and cycle budget produce a byte-identical machine every time. It takes no timezone — it is the reading on the emulated clock's face, not an instant.- Don't type at a machine that hasn't booted. With flow control on, input
waits until the firmware programs the ACIA; after that, a boot menu can still
swallow it, and with flow control off it can be lost. The leading CR above is
fine because it is meant for the splash; with
--pauseor a debug server, wait for a prompt first. More traps in docs/AGENTS.md.
Exit codes: 0 ran to completion, 1 usage or load error, 2 timed out,
130 interrupted.
- Node.js v22+
- ImageMagick +
iconutil(macOS) — only needed to regenerate app icons
git clone https://github.com/acwright/6502-EMULATOR.git
cd 6502-EMULATOR
npm installnpm run devHot-reloads the renderer; the Electron window opens automatically.
npm run build:web
npm run preview:webnpm run typechecknpm test
npm run test:coverage
bash examples/run-all.sh # the worked examples, against a real BIOSThe Jest suite covers the emulator core (CPU, RAM, ROM, Cart, all I/O cards and attachments), the debug layer (session, breakpoints, disassembler, snapshots, symbols, the protocol method table and the WebSocket implementation), the headless host and the CLI. Several suites boot the real bundled BIOS rather than a stub, so a firmware-facing regression fails them.
examples/ holds runnable scripts rather than documentation fragments, and CI runs
them — a documented command that stops working stops the build.
The CPU core is also checked against four test suites written by other people, for real W65C02S silicon rather than for this emulator. They are the answer to a class of bug that hand-written tests are bad at catching: the flag that is right in the nine cases someone thought to write down and wrong in the tenth.
npm run test:conformance # fetches what it needs, then runs everything
npm run fetch:conformance # just the download| Suite | What it is |
|---|---|
Tom Harte's ProcessorTests (wdc65c02 v1) |
10,000 generated cases for each of the 254 single-steppable opcodes — initial state, final state, cycle count. 2.54 million cases. |
| Klaus Dormann's functional test | 30 million instructions of 6502 code that checks its own results. Every documented opcode and addressing mode. |
| Klaus Dormann's 65C02 extended opcodes test | The same, for everything the CMOS part added — including the undefined opcodes. |
| Bruce Clark's decimal test | Every pair of bytes added and subtracted in decimal mode, with both carries, against independently predicted results. 131,072 cases each way. |
| AllSuiteA (HMC-6502) | Fourteen basic instruction-set tests. Narrow, but a fourth independent author. |
Suites are downloaded to test-suites/, which is not in the repository — Harte's
is about a gigabyte. cc65 is needed for the decimal test, which ships as source
because which CPU it predicts results for is an assembly-time switch:
brew install cc65 # macOS; apt-get install cc65 on Debian/UbuntuThey run as a separate Jest project (jest.conformance.cjs) and a separate CI job,
so npm test stays fast. Anything that touches src/core/CPU.ts should be run
past npm run test:conformance before it is committed, and the same change made in
the KIMULATOR project — the two
copies of the file are kept byte-identical. The whole thing takes about ten seconds
once the data is on disk.
Interrupts are the exception: no suite here covers them. Harte's format cannot
express one, and Klaus's 6502_interrupt_test — besides needing an assembler that
does not run on macOS — is deliberately tolerant about when an interrupt arrives,
so it cannot settle the timing questions anyway. src/tests/Interrupts.test.ts
covers that ground by hand instead: 23 tests over the pushed frame, the stack, the
masking rules, level-triggered re-entry, nesting and the sampling rule. Its last
test records the one remaining known divergence — up to one instruction of extra
interrupt latency when a device raises the line mid-instruction — and explains what
fixing it would cost.
One divergence is deliberate and recorded in src/tests/conformance/harte.test.ts:
Harte's suite times undefined opcode $5C at 4 cycles, and the W65C02S data sheet
prices it at 8. The data sheet wins — it singles that opcode out where every other
three-byte undefined opcode in the same table is 4 — and the exception asserts both
numbers, so it fails if either side ever moves.
What the CPU suites do for the processor, src/tests/goldens/ does for the video
card: it holds captures of what real programs — the bundled BIOS on its video
console, the Wizards Lab cartridge, and the four cartridges in samples/, one
cycling through the four display modes, one scrolling two layers past sprites,
one drawing the built-in font and one that is about the cartridge rather than
the card, reading three banks through one window and programming a byte into its
own flash — actually put on screen, and npm test fails if the emulator
stops reproducing them. "No faults" is a claim about a picture, and pictures fail
quietly.
npm test -- src/tests/goldens # check them
npm run capture:goldens -- --check # report what would move, change nothing
npm run capture:goldens # re-capture, deliberatelyEach checkpoint stores the frame twice: as palette indices, compared exactly, and
as a PNG, compared within a tolerance and there to be looked at when the index
frame differs and the pixel number does not explain itself. A golden that moves is
either an intended change — re-captured in a commit of its own that says why — or a
bug. See src/tests/goldens/README.md.
The goldens are also what the 6502-PICOVDP firmware — the real card that
docs/VDP-SPEC.md specifies — is held to, and that project has
no 6502. Each fixture therefore keeps a trace beside
its goldens — every port access the program made to the card, timed to the tick,
in the firmware project's docs/TRACE.md format — recorded through
Video.observer, an optional hook that costs nothing while unset. Replayed into
a card with no CPU, a trace reproduces every golden byte for byte, and npm test
checks that it still does.
npm run record:traces -- --check # are the traces current?
npm run record:traces # re-record them, after a golden moves
npm run replay:traces # replay them with no CPU, against the goldens
PICOVDP_ADDON=…/6502-PICOVDP/host/node/Video.cjs npm run test:picovdpThe last runs src/tests/IO/Video.test.ts, unchanged, against the firmware's C
core through its Node adapter instead of against Video.ts.
The emulator has to keep up with the machine it emulates, in a browser tab on whatever device opened it, so throughput is measured rather than assumed:
npm run bench # every workload; exits 1 if one is below its floor
npm run bench -- wizardslab # just one
npm run bench -- --json # for a scriptEach workload is the whole machine — CPU, every slot, the SID included — run for a fixed stretch of emulated time in a process of its own, and reported as a multiple of real time at the machine's 1 MHz clock. The programs are the golden fixtures above plus a serial-console BIOS; the last is a synthetic worst case that holds §18's heaviest line — two scrolled 4bpp layers in Full mode, 64 magnified sprites competing for 32 places, detailed collision on — on every line of the picture, which no real program can do.
On an M-series Mac, Node 26:
| Workload | 1 MHz | Floor |
|---|---|---|
serial — BIOS prompt, no video card |
10.7× | 4× |
bios — BIOS prompt, video console |
8.8× | 4× |
wizardslab — Graphics I, legacy sprites |
8.7× | 4× |
vdp-modes — all four geometries |
8.1× | 4× |
vdp-layers — Full mode, two layers, sprites |
7.3× | 4× |
worst — §18's worst line, 240 times |
4.2× | 2× |
The floors are headroom for slower hosts, not a claim about any one of them. It
is not part of npm test or CI: a timing gate on a shared runner fails for
reasons that have nothing to do with the code. Run it before a change to
src/core/IO/Video.ts or the CPU is considered done, as the conformance suites
are.
npm run build # compile TypeScript + bundle renderernpm run dist:mac # requires Apple Developer ID cert in KeychainProduces dist/ac6502-emulator-<version>-mac-arm64.dmg (notarized).
npm run dist:win # requires Wine installed on macOSProduces dist/ac6502-emulator-<version>-win-x64.exe.
npm run dist:linux # requires Docker runningProduces dist/ac6502-emulator-<version>-linux-x86_64.AppImage and
dist/ac6502-emulator_<version>_amd64.deb.
npm run build:web # output → dist/web/Two entry points land in dist/web/: index.html (the full emulator) and
embed.html (the embeddable one). Both are deployed together.
GitHub Actions deploys automatically on every push to main
(workflow: .github/workflows/deploy.yml).
npm run icons # reads build/6502.png, writes build/icon.icns|ico|pngRequires ImageMagick (magick) and iconutil (macOS).
src/
core/ Emulator engine (CPU, RAM, ROM, all I/O cards) — no browser/Node deps
debug/ Platform-agnostic debug layer:
Session + Scheduler — own execution and pacing
Breakpoints, Disassembler, Snapshot, Symbols, Expression
server/ — JSON-RPC method table, WebSocket, HTTP, lock file
host/headless/ Windowless host; wires the console to a byte stream
cli/ `6502` command line — run, dbg, attach
main/ Electron main process (serial, storage, settings, debug bridge, CLI shim)
preload/ contextBridge — exposes window.api to the renderer
renderer/ Vue 3 UI (shared by Electron and web builds; the web build has
two entry points — index.html and embed.html)
shared/ Types, IPC channel constants, AppApi interface
docs/ AGENTS.md (agent recipes), DEBUG-PROTOCOL.md (protocol reference),
EMBEDDING.md (iframe parameters and postMessage API),
VDP-SPEC.md (the PICOVDP card), MIGRATING.md (to the PICOVDP),
handoff/ (what the BIOS, documentation and VCS repositories want)
samples/ Cartridges used as golden fixtures — three for the video card, one for the Flash Cart
examples/ Runnable worked examples, exercised by CI
assets/
roms/ Bundled BIOS binary (included in Electron extraResources)
bin/ `6502` CLI entry point
build/ electron-builder resources (icons, gen-icon.mjs)
scripts/ dist-win.sh, dist-linux.sh, capture-goldens.mjs, bench.mjs,
record-traces.mjs, replay-trace.mjs
jest.picovdp.cjs Video.test.ts against 6502-PICOVDP's C core (npm run test:picovdp)
Nothing in core/ or debug/ imports a browser or Node built-in, which is what
lets the same engine and the same debug protocol run in a bare Node process, in an
Electron renderer, and in a browser tab.
- AC6502 Hardware — the real machine, and the index of the whole family
- 6502 BIOS — firmware source; the bundled ROM is built from it
- 6502-PRG / 6502-CRT — templates for programs and cartridges; both have a
make runthat launches this app - 6502-ASM / 6502-BAS — example programs and BASIC listings to run
- cffs — builds the CompactFlash images the CF card accepts
- bastok — tokenizes BASIC listings into the
.prgimages the program loader accepts - docs/AGENTS.md — driving the emulator from an agent or a test script
- docs/DEBUG-PROTOCOL.md — the debug protocol reference
- examples/ — worked examples that CI runs
- 6502-DOCS — the documentation site, whose sample harness drives this CLI
The system uses 8 memory-mapped I/O slots:
IO1 RAM Card (Expansion)
IO2 RAM Card (Expansion)
IO3 RTC Card (DS1511Y+ Real-Time Clock)
IO4 Storage Card (Compact Flash 8-bit IDE Mode)
IO5 Serial Card (6551 ACIA)
IO6 VIA Card (6522 GPIO)
IO7 Sound Card (6581 SID)
IO8 Video Card (TMS9918A or 6502-PICOVDP, --vdp)
VIA (GPIO) Attachments — the VIA card supports pluggable inputs: Keyboard Matrix, Keyboard Encoder, and dual Joystick (A/B).
Linux packaging metadata is set but unverified on a Linux desktop.
electron-builder is now given linux.category: Development (so the app lands
under Development rather than the default Utility) and
linux.syncDesktopName: true with desktopName in package.json, so the
installed .desktop filename matches Electron's app_id / StartupWMClass and
a running window links to its launcher entry. Both are cosmetic and neither is
verifiable from macOS, so the association still wants a check on an actual Linux
desktop. See the electron-builder Linux docs.
The CLI shim installer is macOS-only; verified end to end there. On Windows
and Linux the platform installer already owns PATH, so Settings → COMMAND LINE
reports there is nothing to do rather than offering a button that would do the
wrong thing. The Linux AppImage should work unmodified once its installer wires
up PATH the same way, but that path hasn't been run on an actual Linux desktop.
An embedded program fetched cross-origin needs CORS on the host serving it.
?prg=https://example.com/game.prg is an ordinary fetch, so without
Access-Control-Allow-Origin from that host the browser refuses it and the embed
boots to a BASIC prompt instead. Nothing on the emulator side substitutes for the
header — use prg64= (the bytes inline in the URL) when you cannot set it.
An embed starts silent whatever muted=0 says. Browser autoplay policy will
not let an AudioContext start in a frame the reader has not interacted with, so
sound begins at the first click inside the frame. The mute button reports the
real state throughout.
No execution trace. The debug protocol has no trace family — nothing has
needed one yet. bp, reg, mem and disasm cover stepping and inspection.
The PICOVDP card is ahead of the hardware. The emulator's 6502-PICOVDP is a
specification (docs/VDP-SPEC.md) for replacement PICO9918 PRO
firmware, which is being written in the 6502-PICOVDP project against the same
document; a real ACE today runs a Pico9918 as a TMS9918A. Text and Graphics I programs behave the same on both, give or take
the differences MIGRATING.md lists. A program that
writes VMODE, uses layer 1 or scrolls works here and not on the board, and one
that puts more than four sprites on a line shows all of them here where the board
drops the rest. Graphics II, Multicolor and the F18A's registers go the other way.
The AC6502 documentation still describes the TMS9918A. That is the default card, so its video and graphics chapters, colour tables and the Graphics II and Multicolor samples still run as written. They will move to the PICOVDP once the firmware is confirmed working and the family moves to it. docs/handoff/6502-DOCS.md lists what needs to change there. docs/handoff/6502-BIOS.md is the matching list for the Kernal, which 6502-BIOS 2.x has since built; what is live in it now is how a changed BIOS comes back here.
The -1M flash cart has not met silicon. Two chips are modelled and
6502-CRT lays U2 out after the fixed bank; both pass their own tests and neither
has been burnt. docs/handoff/6502-VCS.md is what this
emulator guarantees to the hardware and where it stops short of it.
- CPU implementation adapted from OneLoneCoder's olcNES
- The video card grew out of a TMS9918 emulation based on vrEmuTms9918 by Troy Schrapel; its renderer is new
MIT License — see LICENSE for details.
This project pairs with the hardware and firmware linked above. Contributions, issues, and feature requests are welcome!