Introduction
Emma65 is a software emulator for the 65C02-family of 8-bit microprocessors. It provides a complete execution environment suitable for running and debugging programs written for classic 65C02-based systems, with support for flexible memory configuration, a rich set of virtual I/O devices, and expression-based watchpoints. The project ships six tools built on the same emulator core:
emma65— a command-line emulator for running programs directlyemma65-debugger— a graphical debugger (registers, disassembly, memory, stack, watchpoints, and a live execution trace, in a native desktop app) for interactively developing and troubleshooting programsemma65-tracer— a utility that decodes a recorded binary execution trace into a human-readable, symbol-annotated disassembly listingemma65-display— an SDL2 peripheral process that renders the character display device (display) in its own window when runningemma65standalone (no debugger)emma65-led-matrix— an SDL2 peripheral process that renders the RGB LED matrix device (display/matrix) in its own window when runningemma65standalone (no debugger)emma65-lcd-display— an SDL2 peripheral process that renders the character LCD device (display/lcd) in its own window when runningemma65standalone (no debugger)
Together they form a foundation for building retro-computing tools, educational simulators, and hardware-in-the-loop test rigs.
Install
Rust toolchain
Install Rust via rustup:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.org | sh
This installs the latest stable toolchain; Emma65 uses the 2024 edition, which
requires Rust 1.85 or newer. Verify with rustc --version and
cargo --version, and see the rustup book
for updating an existing installation.
System libraries
The plain emma65 and emma65-tracer binaries have no system library
dependencies beyond Rust itself. Building the rest of the workspace needs
additional development packages: emma65-display, emma65-led-matrix, and
emma65-lcd-display need the SDL2 libraries (emma65-led-matrix also needs
SDL2_gfx), and emma65-debugger needs Tauri’s Linux dependencies (WebKitGTK,
GTK, libayatana-appindicator, librsvg).
Ubuntu Linux
sudo apt-get update
sudo apt-get install -y \
build-essential \
libsdl2-dev \
libsdl2-gfx-dev \
libwebkit2gtk-4.1-dev \
libssl-dev \
libayatana-appindicator3-dev \
librsvg2-dev
Fedora Linux
sudo dnf install -y \
gcc gcc-c++ make \
SDL2-devel \
SDL2_gfx-devel \
webkit2gtk4.1-devel \
openssl-devel \
libappindicator-gtk3-devel \
librsvg2-devel
Build and install
Build the whole workspace in release mode:
cargo build --release --workspace
Or build only what you need — each crate’s system library requirement is independent of the others (see above):
cargo build --release # emma65 + emma65-tracer only
cargo build --release -p emma65-display
cargo build --release -p emma65-led-matrix
cargo build --release -p emma65-lcd-display
cargo build --release -p emma65-debugger
Install the binaries onto your PATH (cargo install has no --workspace
flag, so each workspace member is installed with its own invocation — they
all land in the same place, ~/.cargo/bin by default):
cargo install --path . # emma65, emma65-tracer
cargo install --path display # emma65-display
cargo install --path led-matrix # emma65-led-matrix
cargo install --path lcd-display # emma65-lcd-display
emma65-debugger isn’t installed this way; build it as a packaged desktop
app with cargo tauri build instead (see The Debugger). On
Linux this produces installable packages under
target/release/bundle/ — a .deb and a .rpm:
sudo apt install ./target/release/bundle/deb/emma65-debugger_*.deb # Ubuntu
sudo dnf install ./target/release/bundle/rpm/emma65-debugger-*.rpm # Fedora
as well as a self-contained .AppImage under
target/release/bundle/appimage/ that needs no install step — chmod +x it
and run it directly (or use a tool like AppImageLauncher to add it to your
desktop menu).
The Emulator Core
At the heart of Emma65 is a CPU model that faithfully emulates the 65C02
instruction set and interrupt behavior, paired with a flexibly configurable
memory bus and a growing library of virtual I/O devices — everything the
emma65 command-line emulator, the debugger, and the tracer are all built
on. Memory and devices are mapped into the 16-bit address space however a
program needs them, devices talk to real or emulated peripherals over
pluggable transports, and execution can be inspected and controlled through
expression-based watchpoints and a recorded instruction trace. The following
sections describe this core in detail, starting with how closely it matches
real 65C02 hardware.
Correctness
Emma65 passes the Klaus Dormann 65C02 test suite, which exhaustively exercises every instruction, addressing mode, flag computation, interrupt sequence, and decimal-mode operation defined by the 65C02 architecture. It also passes the Bruce Clark decimal mode test, which independently verifies all 256×256 ADC and SBC operand combinations in BCD mode against predicted CMOS 65C02 results. Users can rely on Emma65’s instruction-level behavior matching real hardware.
Features
Instruction Set
Emma65 emulates two variants of the 65C02 processor family:
-
CMOS 65C02 — the standard CMOS variant, including all instructions added over the original NMOS 6502:
BRA,STZ,TSB,TRB,PHX,PHY,PLX,PLY, accumulator-modeINCandDEC, zero-page indirect addressing, andJMP (abs,X). -
WDC 65C02 — the Western Design Center variant, which adds 34 opcodes to the CMOS baseline:
STP(stop the processor),WAI(wait for interrupt),BBR0–BBR7andBBS0–BBS7(branch on bit clear/set), andRMB0–RMB7andSMB0–SMB7(reset/set memory bit).
All 16 addressing modes are supported, including the zero-page relative mode used by the WDC bit-branch instructions. Invalid opcodes can be configured to either silently act as NOPs or to halt execution with an error.
Emulating the original NMOS 6502 — its undocumented opcodes, its read-modify-write double-write behavior, and the various other quirks that NMOS-focused emulators go to great lengths to reproduce — is explicitly not a goal of this project. Both variants above are CMOS designs, and “invalid opcode” above means exactly that: an opcode with no defined CMOS behavior, handled by configuration rather than by reproducing whatever the NMOS die happened to do with it. Projects that do emulate the NMOS 6502’s undocumented behavior, if that’s what you’re looking for, include:
- VICE — a suite of Commodore computer emulators with a cycle-exact 6510/8500 core, illegal opcodes included
- Mesen — a NES/Famicom emulator whose 2A03 CPU core reproduces the NMOS 6502’s unofficial opcodes cycle-accurately
- Visual6502 — a transistor-level simulation of the original NMOS 6502 die, the reference many other emulators validate their undocumented-opcode behavior against
- the NESdev wiki’s unofficial opcodes reference — a well-maintained catalog of the quirks themselves, useful background on what’s being left out here
Interrupt Support
Emma65 implements the full 65C02 interrupt model:
- RESET — restores the CPU to its power-on state: every device on the
bus is reset to its own power-on state, the stack pointer is set to
$FF, the status register toI(interrupts disabled, every other flag clear), the cumulative cycle counter is zeroed, and anySTP/WAI-halted state is cleared. The program counter is then loaded from the reset vector at$FFFC/$FFFD. Emma65 issues one automatically before running the first instruction of a session, and the debugger’s CPU/Bus panel exposes it as an on-demand control. - NMI — edge-triggered and latched: the first falling edge sets a pending flag that is consumed exactly once, with highest priority over simultaneous IRQ. Any device capable of signaling an NMI (for example a VIA’s CA1 line) can trigger one.
- IRQ — level-triggered and multi-source: multiple devices can independently assert and release the IRQ line; the interrupt fires when any source is active and the I flag is clear. Each device’s IRQ state is polled after every instruction.
- BRK — software interrupt; sets the B flag in the pushed status byte so interrupt handlers can distinguish a BRK from a hardware IRQ.
On interrupt entry the D flag is cleared, matching CMOS 65C02 hardware behavior.
Clock Speed Simulation
Free-running execution throttles to a configurable target clock frequency by comparing accumulated emulated cycles against elapsed wall time, sleeping as needed to match the target rate. Throttling is batched over roughly 1,000 instructions at a time, keeping sleep-syscall overhead negligible while maintaining sub-millisecond timing granularity. This comfortably covers the clock speeds of all historically common 6502-based systems, and headroom on modern hardware goes well beyond that. In both cases below, accuracy held to within 0.02% of target right up to the boundary shown, then fell off sharply once the requested speed exceeded what the host could execute unthrottled — so the boundary itself, not some margin below it, is the practical ceiling:
| device complement | measured accurate ceiling |
|---|---|
| bundled default (32K RAM, 32K ROM, VIA, two ACIAs, LFSR, console) | ~34 MHz |
| minimal (32K RAM, 32K ROM, console only) | ~85 MHz |
(release build, on a mid-range 2023 laptop CPU — AMD Ryzen 5 7530U). Every
polled device adds per-instruction overhead — it’s given a chance to advance
its own state after every single instruction — so trimming the default
complement down to just RAM, ROM, and a console more than doubled the
ceiling here. Use these two points to
interpolate a rough expectation for your own configuration: more polled
devices pulls the ceiling down toward the low end, a bare-bones setup pushes
it toward the high end. The ceiling also depends on the host CPU and the
build profile — a debug build is roughly an order of magnitude slower than
release and hits its own, much lower ceiling — so treat it as “however fast
your configuration runs unthrottled on your machine in a --release build,”
not a fixed number.
The target clock speed is set with the clock-speed-hz TOML/CLI setting (see
Running the Emulator); some familiar reference
points:
| Setting | Speed |
|---|---|
clock-speed-hz = 1000000 | 1 MHz — Apple II speed |
clock-speed-hz = 1843200 | 1.8432 MHz — common UART baud-rate crystal |
clock-speed-hz = 2000000 | 2 MHz — BBC Micro speed |
| omitted | Maximum throughput; no throttling |
Memory and Bus Configuration
The memory bus is organized around named address regions mapped into the 16-bit address space. Regions can be RAM, ROM (write-protected), or I/O device windows, configured via TOML or CLI flags (see Running the Emulator). The bus uses a most-specific-wins overlap policy: a smaller region always shadows a larger one at the same addresses, which makes it easy to place a device register window inside a ROM region. Ambiguous overlaps (same-size regions at the same addresses) and ROM size mismatches are caught when the configuration is loaded, before the program ever runs, and reported as a startup error rather than a silent misconfiguration.
Address resolution is a one-time cost paid when the configuration loads, so
reads and writes at runtime are effectively free regardless of how many
devices are configured — bus overhead stays out of the way of maximum
emulated CPU throughput. By default, accesses to addresses not covered by any
configured region are silently ignored (reads return 0xFF, writes are
discarded), matching how unpopulated address space typically behaves on real
hardware. Set unmapped-policy = "error" (TOML) or --unmapped-policy error
(CLI) to instead treat these as bus errors — useful when tracking down a
program that’s straying outside its intended memory map. Bus errors (this
setting, and ROM write violations) are reported back to whichever tool is
running the CPU (the emma65 CLI, the debugger, or the tracer) so it can
decide how to respond — typically by halting and reporting the error.
Memory-Mapped I/O Devices
The built-in ram, rom, console, and other device types configurable
from TOML/CLI (see Running the Emulator) share one
configuration surface, so adding a new device type is a matter of plugging
into that same surface — see
Adding a Custom Device Module
under For Contributors for how to build one.
A device has a small set of capabilities beyond plain memory: it can advance internal timers and counters in step with CPU time (a VIA’s two timers and a PTM’s three are both built on this), assert or release the shared IRQ line and signal an NMI, restore itself to a power-on state on reset, and — for devices that talk to the outside world — begin closing down its connection when the emulator shuts down. See Interrupt Support above for how the CPU combines IRQ/NMI signals from every device on the bus.
Execution Tracing
The CPU can record every register snapshot and bus read/write to a compact binary trace format as it executes — writing is offloaded to a background thread so recording does not slow down execution. Two tools consume these traces:
- The
emma65binary writes a trace directly to a file with--trace-file - The debugger’s Trace window records and displays a scrolling, live view of recent execution without stopping the CPU
- The standalone
emma65-tracerbinary decodes a previously recorded trace file into a disassembly listing, optionally annotated with symbols from a VICE label file and per-instruction bus operation detail
Memory Devices
Programs need somewhere to live and somewhere to work — a plain ram region
and a plain rom region are usually enough for that. Some real
single-board-computer designs go further, though, using a bank-switching MMU
to give a 64 KB 6502 address space access to a much larger pool of physical
memory; the Finch, Phoebe, and Vireo devices emulate three such designs.
Every memory device is placed on the bus with a TOML [[devices]] table —
type selects the device (shown in parentheses in each heading below) — or
the equivalent --device type@address,key=value,... CLI flag; see
Running the Emulator for the general TOML/CLI/env
conventions shared by every device type.
RAM (ram)
A plain block of read/write memory mapped into any address range on the bus.
[[devices]]
type = "ram"
address = 0x0000
size = 32768 # or the quoted string "32K"
size(required) — how much address space the region occupies, either a plain integer number of bytes or a quoted string with aK/ksuffix for kibibytes (e.g."32K"). The suffixed form must be a TOML string —size = 32Kwithout quotes is invalid TOML, not a valid size.image(optional, path) — a binary, Intel Hex, or Motorola S-Record file loaded atoffset(default0) within the region at startup; see Running the Emulator for the recognized file extensions. Bytes the image doesn’t cover fall back tofill.fill(optional, byte) — value used to initialize memory the image doesn’t cover; omitted entirely (and noimagegiven), the region starts with random contents, mimicking real RAM’s power-on state.offset(optional, signed integer, default0) — byte offset withinimage(or, if negative, before it) at which loading begins.labels(optional, path) — a VICE-format label file, for symbol resolution in the debugger and tracer.
ROM (rom)
A block of read-only memory mapped into any address range on the bus. Writes are silently discarded.
[[devices]]
type = "rom"
address = 0x8000
size = 32768
image = "~/roms/my.bin" # .bin, .rom, .hex, .ihx, .ihex, .s19, .srec
size(required) — how much address space the region occupies, either a plain integer number of bytes or a quoted string with aK/ksuffix for kibibytes (e.g."32K"). The suffixed form must be a TOML string —size = 32Kwithout quotes is invalid TOML, not a valid size.image(required, path) — a binary, Intel Hex, or Motorola S-Record file loaded atoffset(default0) within the region at startup; see Running the Emulator for the recognized file extensions.fill(optional, byte) — value used to initialize any bytes the image doesn’t cover.offset(optional, signed integer, default0) — byte offset withinimage(or, if negative, before it) at which loading begins.labels(optional, path) — a VICE-format label file, for symbol resolution in the debugger and tracer.write-policy(optional,"ignore"or"error", default"ignore") — what happens when the 6502 program writes to this ROM region: silently discard the write, or report it as a bus error.
Bank-Switched Memory Modules
Finch, Phoebe, and Vireo are complete memory subsystems — RAM, ROM, and a
bank-switching MMU — rather than plain ram/rom regions, each modeled on a
different real single-board-computer design. Each claims the entire 64 KB
address space when configured, so no separate ram/rom entries are needed
alongside them, and their address device-spec field is unused — the
addresses that matter are the ones given to their control register(s)
instead. address is still a required part of the device spec (TOML and
CLI alike), so give it any value — 0x0000 in the examples below. All three
share these attributes:
image(required, path) — a ROM image loaded atoffset(default0) within the module’s ROM region.labels(optional, path) — a VICE-format label file, for symbol resolution in the debugger and tracer.write-policy(optional,"ignore"or"error", default"ignore") — what happens when the 6502 program writes to ROM: silently discard the write, or report it as a bus error.fill(optional, byte) — value used to initialize any ROM bytes the image doesn’t cover.
Finch bank-switched MMU (mem/finch)
512 KB RAM and 512 KB ROM behind a simple MMU: the top four bits of the 6502
address bus (A12..A15) index into 16 one-byte bank registers, each
selecting which 4 KB segment of the module’s 1024 KB memory space is mapped
into that 4 KB window of the 6502’s address space — banks 0x00–0x7F are
RAM, 0x80–0xFF are ROM. The bank registers support both read and write,
so a program doesn’t need to keep a shadow copy.
6 5 0 2 A d d r e s s B u s
A15 A14 A13 A12 A11 A10 A9 A8 A7 A6 A5 A4 A3 A2 A1 A0
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │ │ │ │ │ │ │ │ │ │ │ │
┃ MMU Bank Registers (0..15) ┃ │ │ │ │ │ │ │ │ │ │ │ │
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │ │ │ │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
B7 B6 B5 B4 B3 B2 B1 B0 │ │ │ │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
M19 M18 M17 M16 M15 M14 M13 M12 M11 M10 M9 M8 M7 M6 M5 M4 M3 M2 M1 M0
E f f e c t i v e M e m o r y A d d r e s s
At reset, the MMU is disabled and a fixed mapping is used instead: the low
32 KB of RAM (banks 0x00–0x07) fills the lower half of the address
space, and the low 32 KB of ROM (banks 0x80–0x87) fills the upper half
— so the reset vector always comes from ROM regardless of what’s currently
in the bank registers. Setting bit 7 (MMUE) of the control register
switches to the MMU-driven mapping; program the bank registers first, since
flipping this bit mid-configuration changes what’s mapped where
immediately. Because the control register may share its address with other,
unrelated configuration bits on real Finch hardware, read-modify-write the
register rather than writing it outright:
LDA $FFD8 ; fetch the config register state
ORA #$80 ; set the high order bit (MMUE)
STA $FFD8 ; store the new config register state
[[devices]]
type = "mem/finch"
address = 0x0000
bank-registers = 0xFC00
control-register = 0xFFD8
image = "rom.bin"
labels = "rom.lbl"
bank-registers(required, aliasbanks) — base address of the 16 one-byte bank registers (must be paragraph-aligned, i.e. a multiple of 16).control-register(required, aliasctrl) — address of the 8-bit MMU control register (bit 7 = MMUE, the rest reserved).
Phoebe bank-switched memory (mem/phoebe)
56 KB RAM and 32 KB ROM. The ROM is split into four 8 KB banks (numbered
0–3, at offsets 0x0000, 0x2000, 0x4000, 0x6000 in the image file);
bank 3 is permanently mapped into the upper half of a 16 KB switchable
region at 0xC000 and must contain the 6502 machine vectors (NMI at
0x7FFA, Reset at 0x7FFC, IRQ at 0x7FFE, relative to the bank start). A
single control register selects what occupies the lower half of that
region:
| Bit 1 | Bit 0 | Selection |
|---|---|---|
| 0 | 0 | ROM Bank 0 |
| 0 | 1 | ROM Bank 1 |
| 1 | 0 | ROM Bank 2 |
| 1 | 1 | RAM (the 8 KB of RAM sharing this region becomes visible) |
Only these two bits are significant — the rest of the register is ignored
on write and always reads 0. The register resets to 0 (ROM bank 0) at
system reset.
[[devices]]
type = "mem/phoebe"
address = 0x0000
control-register = 0xFFF7
image = "rom.bin"
control-register(required, aliasctrl) — address of the 8-bit bank-selection register (bits 1–0 only).ram-fill(optional, byte) — value used to initialize RAM at startup, separate fromfill(which covers ROM).
Vireo bank-switched memory (mem/vireo)
128 KB RAM and 32 KB ROM behind a bank-switching scheme with four configurations, selected via one control register:
| Mode | 0x0000–0x7FFF | 0x8000–0xFFFF |
|---|---|---|
| 0 | RAM 0x00000–0x07FFF | ROM |
| 1 | RAM 0x10000–0x17FFF | ROM |
| 2 | RAM 0x00000–0x0FFFF (whole address space) | — |
| 3 | RAM 0x10000–0x1FFFF (whole address space) | — |
In every mode, the 8 KB region at 0xC000–0xDFFF can additionally be
pointed at any 8 KB, 4 KB-aligned segment of whichever RAM half the current
mode doesn’t otherwise map — giving a program access to RAM beyond the 64 KB
address space without leaving its current mode. Control register bit layout
(bit 7 always reads 0):
┌────┬────┬────────┬────────────────┐
│ -- │ WI │ M1 M0 │ S3 S2 S1 S0 │
└────┴────┴────────┴────────────────┘
- WI (bit 6) — Window Inhibit:
1disables the0xC000window, exposing whatever it normally shadows instead. - M1–M0 (bits 5–4) — selects Mode 0–3 from the table above.
- S3–S0 (bits 3–0) — which 8 KB segment of the other RAM half is
mapped into the window; the complement of mode bit
M0plus this 4-bit field form the segment’s base address (e.g. in Mode 0 or 2, segment0x9maps physical address0x19000). Segment0xFwraps: its upper half comes from the top of the region and its lower half from the bottom.
[[devices]]
type = "mem/vireo"
address = 0x0000
control-register = 0xFFF4
image = "rom.bin"
control-register(required, aliasctrl) — address of the 8-bit control register described above.ram-fill(optional, byte).
I/O Devices
Emma65 includes a number of built-in devices. Most — a simple console, 6522 VIA, and 6551 ACIA among them — are register-window devices that can be mapped into any address range on the bus; each integrates with the interrupt controller and most of them exchange data with the outside world over a configurable Transport. RAM, ROM, and the bank-switched memory subsystems that replace them are covered separately in Memory Devices.
Every device is placed on the bus with a TOML [[devices]] table — type
selects the device (shown in parentheses in each heading below) and
address is where it’s mapped — or the equivalent
--device type@address,key=value,... CLI flag; see
Running the Emulator for the general TOML/CLI/env
conventions shared by every device type.
Most IRQ-capable devices below also accept an irq attribute. It isn’t a
vectored interrupt number a 6502 program can read anywhere — it’s just a
bookkeeping slot (0–63) the emulator uses at startup to make sure two
IRQ-capable devices don’t collide on the shared interrupt line. Every device
type ships with its own default slot, so you only need to set irq=
yourself if you configure enough IRQ-capable devices that two collide (the
emulator refuses to start and tells you so). Whatever the slot number, a
6502 program still identifies which device is interrupting the same way
it would on real hardware: by polling each device’s own status register,
since the 6502 has only one hardware IRQ line. That’s the case for the
default bus configuration, which routes every IRQ-capable device to the
6502’s single shared line — but a
Priority Interrupt Controller
(pic/finch) can be configured instead to rank IRQ sources and dispatch
each to its own vector, emulating a vectored interrupt controller.
Console (console)
A simple polling console device for byte-stream I/O over a configurable Transport. It occupies 2 bytes of address space:
| Offset | Register | Read | Write |
|---|---|---|---|
| 0 | Data | Returns the latch’s value if non-zero, else the next buffered input byte, else 0; either way, clears the latch and interrupt status | Sends the byte to the transport (no-op if unconnected) |
| 1 | Latch | If the latch is currently zero, pulls the next buffered input byte into it (a one-byte lookahead); returns the latch; clears interrupt status | Overwrites the latch and drains the input buffer; if the value matches the configured break key, raises IRQ instead of clearing it |
- Input is buffered in a 64 kilobyte ring buffer internal to the device as it arrives from the transport, so bytes aren’t lost between polls – even when pasting large blocks of text into the associated terminal.
- An optional break key (e.g. ASCII Ctrl+C) can be configured: when that byte is seen in the input, the buffer is drained, the break key value is latched, and the CPU’s IRQ signal is asserted — a “stop the program” key that works even while the buffer holds unread bytes.
- This is the device behind the debugger’s built-in terminal emulator, so a
consoledevice configured with notransport=gets stdin/stdout wired straight to it automatically — to the process’s own terminal in the plainemma65CLI, or to the debugger’s Terminal panel.
Configuration
[[devices]]
type = "console"
address = 0xFFF8
break = 0x03
break(optional, byte) — the break-key code described above.transport(optional) — see Transport Options; omit it to use the default wiring described above.irq(optional, default3).
6522 Versatile Interface Adapter (via/6522)
A faithful emulation of the WDC 65C22 Versatile Interface Adapter (VIA) — the same 16-register map, timers, shift register, and handshaking behavior a 6502 program would see on real 65C22 hardware:
| Offset | Register | Purpose |
|---|---|---|
$0 | ORB | Port B input/output |
$1 | ORA | Port A input/output (with CA1/CA2 handshaking) |
$2 | DDRB | Port B data direction |
$3 | DDRA | Port A data direction |
$4 | T1CL | Timer 1 counter low (read) / latch low (write) |
$5 | T1CH | Timer 1 counter/latch high |
$6 | T1LL | Timer 1 latch low |
$7 | T1LH | Timer 1 latch high |
$8 | T2CL | Timer 2 counter low (read) / latch low (write) |
$9 | T2CH | Timer 2 counter high |
$A | SR | Shift register |
$B | ACR | Auxiliary control register (latching, shift mode, timer modes) |
$C | PCR | Peripheral control register (CA1/CA2/CB1/CB2 edge/level select) |
$D | IFR | Interrupt flag register |
$E | IER | Interrupt enable register |
$F | ORA | Port A, bypassing CA1/CA2 handshaking |
- CA1, CA2, CB1, CB2 support every edge/level-triggering combination selectable via PCR.
- Timer 1 supports one-shot and free-run modes (with optional PB7 square-wave output); Timer 2 supports one-shot and pulse-counting modes.
- The shift register supports all seven standard modes (input or output, clocked by T2, PHI2, or an external clock).
- IFR/IER give independent enable/mask control per interrupt source.
The VIA has no display or console of its own — whatever real (or emulated) hardware would be wired to its ports and control lines connects instead over a Transport, exchanging port/pin state via the VIA Peer Protocol. On connection, the VIA sends a full state dump so the peripheral starts with an accurate picture of every pin and control line.
Configuration
[[devices]]
type = "via/6522"
address = 0xFF80
transport = "unix:~/.emma/sock/via6522"
protocol = "ascii"
transport(optional) — must betcp:orunix:;pipe:/pty:are rejected because the peer protocol tags messages per connected peripheral, which only a multi-client transport supports.protocol(optional,"ascii"or"binary", default"ascii") — wire encoding for peer-protocol messages; see VIA Peer Protocol.irq(optional, default1).
R6551 Asynchronous Communication Adapter (acia/6551)
An emulation of the Rockwell 6551 Asynchronous Communications Interface Adapter (ACIA) — four addressable registers:
| Offset | Read | Write |
|---|---|---|
| 0 | RX data register | TX data register |
| 1 | Status register | Programmed reset (any value written) |
| 2 | Command register | Command register |
| 3 | Control register | Control register |
Status register (offset 1 read): bit 7 interrupt pending, bit 4 TDRE, bit 3 RDRF, bit 2 OVRN.
Command register (offset 2): bit 1 disables the receive interrupt when
set; bits 3–2 01 enables the transmit interrupt (any other value disables
it).
Control register (offset 3): bit 4 selects the receiver clock source —
0 external (the device polls the transport every tick), 1 internal
(baud rate selected by bits 3–0, 0x1 = 50 baud … 0xF = 19200 baud).
TX is immediate, like the MC6850. RX in internal-clock mode is timed to the selected baud rate; in external-clock mode (the default) it’s polled every tick, for maximum responsiveness.
WDC 65C51 bug compatibility: the real WDC 65C51 has a well-known silicon
bug where TDRE gets stuck permanently set and never reflects transmit-busy
state, so software written for real 65C51 hardware uses fixed timing delays
instead of polling TDRE. This emulation defaults to correct TDRE behavior
(clears on write, restored after one byte period), but with-tdre-bug opts
into bug-compatible mode for software that expects the real chip’s quirk.
Configuration
[[devices]]
type = "acia/6551"
address = 0xFFF0
transport = "pty:~/.emma/dev/ttyS0"
transport(optional) — any Transport kind.with-tdre-bug(optional bool, defaultfalse) — see above.with-overrun(optional bool, defaultfalse) — whentrue, a new byte arriving before the previous one is read sets the OVRN status bit (matching some real hardware); whenfalse(the default, matching the common real-world case where OVRN doesn’t reliably work), the old byte is simply kept until read and OVRN never sets.irq(optional, default5).
MC6850 Asynchronous Communications Adapter (acia/6850)
A faithful emulation of the Motorola MC6850 Asynchronous Communications Interface Adapter (ACIA) — two addressable registers, matching the real chip:
| Offset | Read | Write |
|---|---|---|
| 0 | Status register | Control register |
| 1 | RX data register | TX data register |
Control register (write offset 0): bits 1–0 select the counter divide
ratio (11 triggers a master reset); bits 4–2 select word format (data
bits/parity/stop bits); bits 6–5 enable/configure the transmit interrupt;
bit 7 enables the receive interrupt.
Status register (read offset 0): bit 0 RDRF (receive data register
full), bit 1 TDRE (transmit data register empty), bit 5 OVRN (overrun), bit
7 interrupt pending. DCD, CTS, FE, and PE always read 0 in this emulation
— there’s no real serial line to report a carrier, clear-to-send, framing,
or parity condition from.
TX is immediate: a byte written to the TX register goes straight to the transport; TDRE clears on write and is restored on the next CPU tick. RX is polled from the transport once per tick.
Configuration
[[devices]]
type = "acia/6850"
address = 0xFFF4
transport = "pty:~/.emma/dev/ttyS1"
transport(optional) — any Transport kind.irq(optional, default4).
MC6840 Programmable Timer Module (ptm/6840)
A faithful emulation of the Motorola MC6840 Programmable Timer Module (PTM): three independent 16-bit timers, each capable of continuous or single-shot generation (square-wave or pulse-width output), as well as frequency/period or pulse-width measurement against an external gate/clock.
The PTM occupies 8 bytes of address space. Offset 0 is shared between two of the three control registers:
| Offset | Write | Read |
|---|---|---|
| 0 | CR3 (if CR2 bit 0 clear) or CR1 (if set) | — |
| 1 | CR2 | Status register |
| 2 | Timer 1 latch MSB buffer | Timer 1 counter MSB (also loads the LSB buffer) |
| 3 | Timer 1 latch LSB (transfers the latched 16-bit value) | Timer 1 counter LSB buffer |
| 4 | Timer 2 latch MSB buffer | Timer 2 counter MSB |
| 5 | Timer 2 latch LSB | Timer 2 counter LSB buffer |
| 6 | Timer 3 latch MSB buffer | Timer 3 counter MSB |
| 7 | Timer 3 latch LSB | Timer 3 counter LSB buffer |
To load a 16-bit latch: write the MSB to the timer’s MSB-buffer offset, then the LSB to the timer’s own offset — the full value transfers atomically on the LSB write. To read a 16-bit counter: read the timer’s own offset (which also snapshots the LSB into its buffer), then read the adjacent LSB-buffer offset. All three counters are big-endian in this register map (MSB first), the opposite of the 6502’s own little-endian convention.
Like the VIA, the PTM has no display or console of its own — a virtual peripheral connects over a Transport to exchange gate/clock/output signal state via the PTM Peer Protocol, with a full state dump sent on connection.
Configuration
[[devices]]
type = "ptm/6840"
address = 0xFF90
transport = "unix:~/.emma/sock/mc6840"
transport(optional) — same multipoint (tcp:/unix:) restriction as the VIA, for the same reason.protocol(optional,"ascii"or"binary", default"ascii").irq(optional, default2).
Character Display (display)
A memory-mapped character/color-cell text display, structurally similar to the VIC-II in the Commodore 64 (separate character RAM and color RAM over a fixed grid), but with a full 8-bit palette index per cell rather than 4-bit, and a grid size that’s configurable rather than fixed (40×25 by default). The 8×8 glyph font and RGB24 color palette are supplied at configuration time and are not part of the device’s bus-addressable memory — only the two per-cell RAM arrays and two control registers are:
| Region | Offset | Size | Access | Notes |
|---|---|---|---|---|
| Character RAM | 0 | cells | R/W | Glyph index per cell (cells = columns * rows) |
| Color RAM | cells | cells | R/W | Palette index per cell |
| Control register | 2*cells | 1 | R/W | Bit 0: request a swap now. Bit 1: auto-swap on every vsync. Bit 3: arm a palette update. Bit 7 (read-only): a requested swap is still pending |
| Status/data register | 2*cells + 1 | 1 | R/W | Read: bit 0 vsync occurred, bit 1 a palette update was accepted (both clear on read). Write: feeds a 4-byte armed palette-update sequence (index, red, green, blue), ignored unless control bit 3 was set first |
Character/color RAM writes always target an off-screen buffer; nothing
changes on screen until a swap — either requested explicitly (control bit
0) or automatically on every vsync (control bit 1). A color RAM byte whose
value falls outside the configured palette’s length still reads back
exactly what was written — only compositing resolves it, by masking to
palette.len() - 1 when the palette length is a power of two, or by
reducing it modulo the palette length otherwise — so an out-of-range index
always renders as some defined color rather than a bus error or a panic.
Keyboard input (optional): configuring keyboard-address= maps a
second, separate 2-byte data/latch register pair — behaviorally identical
to Console’s (the same latch-and-clear-on-read
semantics, the same optional break-key handling) — anywhere else in the
address space, so a program can treat the display as a combined
screen-and-keyboard console. This is also what makes the device IRQ-capable
at all; with no keyboard range configured it never asserts IRQ. Live input
is supplied by whichever display panel is rendering the device’s output (see
below) — the debugger’s Display panel, or, for the plain emma65 CLI, the
bundled emma65-display SDL2 peripheral, which captures SDL2 keyboard
events from its own window and sends them back over the same pipe:
transport used for frame data (see the
inbound keystroke stream).
Unlike the other register-window devices, display’s output is graphical,
so the plain emma65 CLI can’t just print it to its terminal window the way
console or an ACIA does. A display panel that can actually draw it is
available two ways:
- The debugger — the Display panel renders composited frames in-process, no configuration needed, and also supplies the live keyboard input described above.
- Standalone
emma65— configure apipe:transport pointing at the bundledemma65-displaySDL2 peripheral binary (see Running the Display Peripheral below). The wire protocol is designed for high throughput — it sends one composited frame per vsync rather than streaming every individual memory write, so the peripheral stays in sync without the overhead of redrawing more often than the display actually changes — and, whenkeyboard-address=is configured,emma65-displaysupplies live keyboard input the same way the debugger’s Display panel does. See the Character Display External Protocol for details.
Configuration
[[devices]]
type = "display"
address = 0xF000
columns = 40
rows = 25
transport = "pipe:/path/to/emma65-display"
columns/rows(optional, default 40×25) — grid size; both must be positive.palette(optional, path) — a text file, oneRRGGBB(or#RRGGBB) color per line, either 16 or 256 entries; overrides the compiled-in default palette.font(optional, path) — a raw 2048-byte file (256 glyphs × 8 bytes, one byte per row, bit 0 = leftmost pixel); overrides the compiled-in default 8×8 font.double-buffered(optional bool, defaulttrue).frame-rate-hz(optional, default60) — vsync/auto-swap cadence; not the same as the external protocol’s own frame rate.transport(optional) —pipe:only, for the same atomic bulk-send reason asdisplay/matrix.keyboard-address(optional) — see above.break(optional, byte) — break-key code for the keyboard sub-range; has no effect unlesskeyboard-address=is also set.irq(optional, default7) — only meaningful (and only allocated) whenkeyboard-address=is set.
LCD Display (display/lcd)
A memory-mapped character LCD module emulating a Hitachi HD44780-compatible controller/driver, faithfully reproducing its real two-register bus interface rather than mapping display memory directly:
- A 2-byte register pair (instruction/status and data), regardless of configured geometry — exactly like a real HD44780, all display state (DDRAM, CGRAM, address counter) is reached only indirectly through these two registers
- Command execution takes simulated time, reported via a busy flag on the instruction register, matching real HD44780 timing so programs written against real hardware assumptions behave the same way here
- Supports both the 8-bit and “software enabled” 4-bit interface widths,
selected at runtime via
Function Set, including the classic 5×8/5×10 font height switch - Geometry (rows × columns) is fixed at configuration time from a set of real-world HD44780 module layouts, quirks (like 16x1’s split-segment addressing) included
- Not IRQ-capable — the HD44780 interface has no interrupt output
Like display and display/matrix, display/lcd’s output is graphical,
so the plain emma65 CLI can’t just print it to its terminal window the way
console or an ACIA does. A display panel that can actually draw it is
available two ways:
- The debugger — the LCD Display panel renders composited frames in-process, no configuration needed.
- Standalone
emma65— configure apipe:transport pointing at the bundledemma65-lcd-displaySDL2 peripheral binary (see Running the LCD Display Peripheral below). The wire protocol is designed for high throughput — it only sends a fresh frame when a register write could actually change what’s rendered, so the peripheral stays in sync without redrawing anything that hasn’t changed. See the LCD Display External Protocol for details.
Configuration
[[devices]]
type = "display/lcd"
address = 0xD000
geometry = "16x2"
transport = "pipe:/path/to/emma65-lcd-display"
geometry (optional, default 16x2) selects one of the ten supported
real-world module layouts: 8-character-5x10, 16-character-5x10, 8x2,
16x1, 16x2, 16x4, 20x2, 20x4, 40x1, 40x2. Only the first two
have the 11 physical common lines a true datasheet 5×10 glyph needs – an
HD44780 drives 16 common outputs total, and every wider common module (even
one, like 16x1, whose name refers to a 16-character row) tops out at
what 5×8 needs, so Function Set’s F=1 is a no-op (logged as a warning)
on every geometry but those two. cgrom (optional) selects the bundled character
generator ROM by name – a00 (the default, and the ROM code most HD44780
clones ship with) or a02 (the European-font variant), case-insensitive –
or overrides it with a file of the same format.
polarity (optional, default positive) and backlight (optional, default
yellow) together select one of 8 color-scheme presets modeling
commonly available real LCD modules, rather than requiring hand-picked RGB24
values: positive polarity renders dark pixels over a backlight-colored
background; negative polarity renders backlight-colored pixels over a dark
“opaque near-black” background. Not every backlight value is valid for
every polarity — only the combinations below are:
polarity | backlight |
|---|---|
positive | yellow |
positive | white |
positive | amber |
positive | blue |
negative | blue |
negative | white |
negative | amber |
negative | red |
background/foreground (optional, hex RGB24) remain available for fully
custom colors — each, if given, overrides the corresponding channel of the
polarity/backlight preset. None of these are part of the HD44780’s own
behavior, and none are bus-addressable.
RGB LED Matrix Display (display/matrix)
A memory-mapped RGB LED matrix display supporting 1, 2, 4, or 8 attached 32×32 matrices, fixed at configuration time. It occupies two separate ranges: a block of pixel memory (one byte per pixel) and a 2-byte command/data register pair elsewhere in the address space, so pixel memory can start on a convenient boundary without the registers getting in the way.
Pixel memory is a flat, row-major raster of the composed canvas —
columns * 32 pixels wide by rows * 32 tall (from arrangement, below) —
addressed exactly like a real framebuffer: byte row * width + col. Each
pixel byte indexes one of 256 shared palette entries (16-bit RGB565 color,
matching real LED matrix driver hardware); the default palette follows the
Xterm 256-color layout (16 named colors, a 6×6×6 color cube, a 24-level
grayscale ramp). Writes target an off-screen buffer per matrix — nothing
appears on screen until that matrix is swapped to its visible buffer.
Command/data registers — write the command byte, then the argument bytes it expects, one per write; a command that produces a reply is read back one byte per read of the data register:
| Command | Value | Write bytes | Read bytes | Effect |
|---|---|---|---|---|
SWAP | 0 | 1 (matrix bitmask) | — | Swaps each matrix whose bit is set to its visible buffer immediately, regardless of whether it’s actually changed |
SET_AUTOREFRESH | 1 | 1 (matrix bitmask) | — | Replaces which matrices auto-swap on every dirty vsync (all matrices, by default) |
SET_POWER | 2 | 1 (matrix bitmask; bit set = on) | — | Turns matrix drivers on/off (all on, by default) |
SET_BRIGHTNESS | 3 | 1 (0–255) | — | Sets overall brightness uniformly across every attached matrix |
PALETTE_WRITE | 4 | 4: index, red, green, blue | — | Sets palette entry index (colors are down-converted to RGB565) |
PALETTE_READ | 5 | 1: index | 3: red, green, blue | Reads back palette entry index (scaled up from its stored RGB565 value) |
The command register always reads 0; writing it discards whatever partial
command sequence was in progress and arms a new one. There’s no interrupt
capability — swaps are always synchronous, so there’s nothing to wait on.
Like display and display/lcd, this device’s output is graphical, so
the plain emma65 CLI can’t just print it to its terminal window the way
console or an ACIA does. A display panel that can actually draw it is
available two ways:
- The debugger — the LED Matrix panel renders each matrix as an independent, composited canvas in-process, no configuration needed.
- Standalone
emma65— configure apipe:transport pointing at the bundledemma65-led-matrixSDL2 peripheral binary (see Running the LED Matrix Peripheral below). The wire protocol is designed for high throughput — it only sends a matrix’s pixels when that matrix actually swaps, and a palette update only when the palette actually changes — so the peripheral stays in sync without redrawing anything that hasn’t changed. See the LED Matrix External Protocol for details.
Configuration
[[devices]]
type = "display/matrix"
address = 0x9000
register-address = 0x9400
arrangement = "2x2"
transport = "pipe:/path/to/emma65-led-matrix"
arrangement (required, COLSxROWS, e.g. 2x2) describes how the matrices
are physically daisy-chained: the matrix count (columns * rows, must be
1, 2, 4, or 8) and how bus addresses map onto them. Matrix n occupies the
32x32 sub-rectangle at ((n / columns) * 32, (n % columns) * 32) of the
composed canvas. There is no separate matrix-count attribute — a bare
count doesn’t say how the matrices are wired, and having both invited them
to silently disagree. A 1xN (single column) arrangement reproduces the
original one-matrix-per-1024-contiguous-bytes layout.
register-address (required) selects where the 2-byte command/data register
pair is mapped, separately from pixel memory. transport (optional) accepts
pipe: only — other transport kinds don’t support the atomic bulk sends
this protocol relies on.
16-bit Galois LFSR (lfsr)
A memory-mapped pseudo-random number generator based on a 16-bit Galois linear-feedback shift register. It occupies 2 bytes of address space:
| Offset | Read | Write |
|---|---|---|
| 0 (LOW) | Latches the current state and returns its low byte; in step mode, this read is also what advances the register | Buffers a low seed byte |
| 1 (HIGH) | Returns the latched high byte (no side effect) | Loads the seed (buffered low byte) | (value << 8) into the register |
- Continuous mode (the default) advances the register once per CPU clock cycle, so successive reads (without reseeding) return a fresh pseudo-random value each time.
- Step mode advances the register only when the LOW register is read, for a sequence driven entirely, and reproducibly, by the program.
- To reseed: write the low byte first (buffered, not yet applied), then the
high byte (loads both into the register together). A seed of
0x0000is clamped to0x0001, since an all-zero state would never change.
This device is not IRQ-capable.
Configuration
[[devices]]
type = "lfsr"
address = 0xFFF6
mode = "step"
taps(optional, default0xB400) — the Galois tap mask; the default gives a maximal-length, 65535-state sequence.mode(optional,"continuous"or"step", default"continuous").
Priority Interrupt Controller (pic/finch)
An optional vectored interrupt controller. Where the default bus
configuration dispatches every IRQ-capable device to the 6502’s single
0xFFFE/0xFFFF vector — leaving a handler to poll each device’s status
register to find out which one interrupted — configuring a pic/finch
device instead ranks up to 8 IRQ priority slots and routes the CPU straight
to a per-slot vector, no polling required.
It occupies a single byte of address space: its Interrupt Enable Register (IER). It does not claim the 16-byte vector table itself, which is expected to be backed by ROM:
| Address | Contents |
|---|---|
0xFFE0–0xFFE1 | Vector for slot 0 (highest priority) |
0xFFE2–0xFFE3 | Vector for slot 1 |
| … | … |
0xFFEC–0xFFED | Vector for slot 6 |
0xFFEE–0xFFEF | Vector for slot 7 (fold slot: every IRQ identifier from 7 up shares this vector, wired-OR) |
Lower IRQ identifiers are higher priority and get their own slot (0..6);
every identifier from 7 up to the emulator’s maximum of 63 shares the
lowest-priority fold slot. The RESET and NMI vectors are untouched — only
the IRQ/BRK vector is affected, and only when a pic/finch is configured.
The IER’s low 7 bits individually enable or disable slots 0..6 for vector routing; a disabled slot’s source is not recognized as pending at all, so it can’t wake the CPU or be routed anywhere until re-enabled. Sources 7..63 are always recognized and always routed to the fold slot — matching real PIC hardware, where an unprioritized/wired-OR tier has no per-source mask — and can only be inhibited by setting the CPU’s I flag.
Reading the IER returns the enable state of slots 0..6 in bits 0..6; bit 7
always reads as 1. Writing the IER treats bit 7 as a set/clear indicator and
bits 0..6 as a selection mask: bits set in the written value select which of
slots 0..6 to modify, and bit 7 determines whether the selected slots are
enabled (1) or disabled (0); unselected bits are left unchanged. This is
the same encoding used by the VIA’s IER.
Because the 6502’s 0xFFFE IRQ/BRK vector low byte is never fetched when a
pic/finch is installed (the CPU fetches the PIC-resolved vector instead),
the IER is typically mapped at 0xFFFF, the otherwise-unused high byte of
that vector.
Only one pic/finch can be configured at a time — it’s the only device type
that replaces the emulator’s vectored dispatch, and configuring a second one
fails at startup.
Configuration
[[devices]]
type = "pic/finch"
address = 0xFFFF
pic/finch accepts no device-specific attributes; it always occupies
exactly one byte at address. It is itself not IRQ-capable and has no
irq attribute — it consumes IRQ identifiers assigned to other devices
rather than asserting one of its own.
Transport Options
Devices that exchange byte streams attach to a transport. Configurable via TOML/CLI:
| Transport | Shorthand | Best for |
|---|---|---|
| Pipe | pipe:/path/to/exe,arg1,arg2 | Spawning a child process and bridging its stdin/stdout to the device |
| TCP Socket | tcp:PORT or tcp:IP:PORT | Connecting a terminal emulator or remote process over the network |
| Unix Socket | unix:PATH | Low-latency local IPC (lower overhead than TCP) |
| PTY | pty or pty:SYMLINK_PATH | Any program that expects a real TTY — screen, minicom, cu, etc. |
There’s also an internal-only transport that isn’t configured via TOML/CLI —
the emma65 binary and the debugger UI use it to wire a console device
directly to the host process’s own stdin/stdout (CLI) or terminal window
(debugger) when no transport attribute is given.
Every transport handles its actual I/O in the background, independent of the emulated CPU’s own pace. Bytes arriving from the outside world are buffered until the device is ready for them, and the device never waits on a slow or idle connection to keep running. That separation means a peripheral can sit disconnected, connect late, or send data in bursts without stalling emulation, and the CPU can run at full speed — including with clock throttling disabled entirely — without communication overhead holding it back.
Several devices go further and frame their transport traffic with a wire protocol — a defined message format layered on top of the raw byte stream, so that whatever is on the other end (real or emulated hardware, a script, another emulator) can be built independently and still understand exactly what the device is telling it, and be understood in turn. Some of these protocols offer a choice of encoding — a human-readable form that’s easy to inspect or drive by hand while developing a peripheral, and a compact binary form for efficiency — while others always use binary because they’re built for high-throughput streaming. See the Wire Protocols appendix for the full set and the byte-level details of each.
The Debugger
emma65-debugger is a native desktop application (built with
Tauri) that turns the emulator into a full interactive
development environment for 65C02 programs. Its main window is a freely
rearrangeable dock of panels — disassembly, memory, registers, stack,
breakpoints, watchpoints, symbols, a live execution trace, a log, a built-in
terminal, an assembler, and one panel per graphical display device — plus an
always-visible status bar, a native menu bar, and a set of keyboard shortcuts
that mirror the menu commands. A segmented Auto/Dark/Light control in the
toolbar switches the whole UI’s theme independent of the OS.
Profiles
The debugger organizes its configuration into profiles: a profile is a
self-contained emulator configuration (the same TOML format described under
Running the Emulator) plus its own watchpoints.
The default profile lives at ~/.emma/debugger/profiles/default/, with its
emulator configuration in emulator.toml and its watchpoints in
watchpoints.emw. The dock layout and other UI preferences — theme,
terminal settings, exit-confirmation — aren’t tied to any particular
profile, so switching profiles never rearranges the window.
Four File menu items manage profiles, and — because switching or reloading a profile tears down and rebuilds the active session — all four are available only while the CPU is stopped:
- New Profile (
Ctrl+N) opens a dialog for a new profile’s name and a starter template to seed it from, then switches to it immediately. The bundled templates cover a range of starting points, from a bare interpreter to a graphical demo:- TaliForth2 (the default) — Forth-2012 on the emulator’s standard full device set: 32 KB RAM, 32 KB ROM, a VIA, two ACIAs, an LFSR, and the console
- Microsoft BASIC — 48 KB RAM, 12 KB ROM, a VIA, and the console for input and output
- EhBASIC — Lee Davison’s EhBASIC, the same 48 KB RAM/12 KB ROM/VIA/ console arrangement as Microsoft BASIC
- Digital Rain — a “digital rain” demo driving the memory-mapped character display (also used for keyboard input) and using the LFSR for pseudo-randomness
- LCD Display — a demo exercising the HD44780-compatible LCD display, with a VIA
- Snake — the classic game, played over the console (input and output), with a VIA for timing and the LFSR for pseudo-randomness
- Open Profile (
Ctrl+O) shows a native folder picker (defaulting to the profiles directory) and switches to whatever profile directory is chosen, filling in any files it’s missing (anemulator.toml, say, but nowatchpoints.emwyet) rather than requiring a fully-formed profile. - Reload Profile (
Ctrl+Shift+R) re-reads the active profile’s files from disk without switching away from it — useful after editingemulator.tomlby hand, or after a separate assembler or IDE has produced a new ROM image or labels file for the profile to pick up. - Open Recent lists recently activated profiles for one-click switching, with a “Clear Recent…” item to empty that list.
Docking and Window Layout
Every panel described below is a dockable tab: drag its tab header to split the window and dock it in a new position, or to tab-group it with another panel; drag the sash between groups to resize them; drag a tab out to float it as its own panel within the main window. Four panels — Terminal, Display, LED Matrix, and LCD Display — go a step further and can be fully detached into independent OS-level windows, toggled from the Window menu or their own keyboard shortcut; the menu item’s label flips between “Detach X…” and “Attach X” to reflect the current state, and a detached window’s native close button reattaches it just like the shortcut does.
Closing a panel’s dock tab removes it from view entirely; the View menu — one plain item per panel — is how it comes back, even if the tab was closed outright rather than just buried behind another tab. A handful of panels also expose a small action icon directly in their tab header: Breakpoints and Watchpoints get a “+” (add) icon there, and Terminal gets a size-preset icon when docked (see Terminal below).
The entire arrangement — dock positions, sizes, tab groupings, and which of the four detachable panels are currently detached — persists across restarts. Window > Restore Layout… discards all of that, after a confirmation prompt, and rebuilds the application’s built-in default arrangement.
Panels
Registers
Shows the CPU’s registers as two groups — data (A, X, Y) and address/status (PC, S, P plus flags) — each with its own radix button that cycles the display base (hex, unsigned/signed decimal, octal, and, for the 8-bit data group, binary). The A register’s value also shows its printable ASCII character alongside the number when applicable. The status register’s flags are shown as individual letters (N V - B D I Z C); a flag that changed on the most recent step is highlighted.
While the CPU is stopped, double-clicking a register value opens an inline
edit field pre-filled with the current value — type a bare number in the
field’s current radix, or override it with an explicit $/0x, 0o/0q,
0b, or 0d/. prefix (a leading +/- selects signed decimal). Enter
commits, Escape or clicking away cancels. Double-clicking the flags display
similarly turns each flag letter into a click-to-toggle control. None of
this is editable while the CPU is running.
Disassembly
A scrolling, symbol-annotated instruction listing — breakpoint gutter, address, raw opcode bytes, mnemonic, operand, and any inline comment, with label rows interleaved above the instructions they annotate. The row at the current program counter is highlighted and scrolled into view on every halt or step, and the view auto-extends as execution approaches the bottom of the currently loaded window.
Clicking a row’s gutter (while the CPU is stopped) sets or removes a breakpoint there; right-clicking a row opens a context menu with Set/Enable/Disable/Remove Breakpoint (whichever apply) plus “Set Breakpoint at Address…” for an arbitrary address. An address field in the panel header jumps the view to a typed symbol name or hex address. Breakpoint state stays in sync with the Breakpoints panel no matter which one made the change.
Run Controls
A single fixed-height toolbar hosting Run, Stop, Step Into, Step Over, and Step Return, plus an Auto-Step toggle with a speed slider and a millisecond entry field for its interval. Every button here has an identical entry in the top-level Run menu, kept enabled and disabled in lockstep, and each enables only in the states where it makes sense — Run and the Step buttons disable while free-running, auto-stepping, or already stepping; Stop enables only while free-running. Reset and the IRQ/NMI controls live in the status bar instead, not here — and there’s no clock-speed control anywhere in the UI, since clock speed is a configured, not a live-adjustable, property of the emulated CPU.
Memory
Displays one 256-byte page at a time as 16 rows of 16 bytes, each with its
address, hex bytes, and ASCII rendering (non-printable bytes shown as
.). Typing a hex address or symbol name into the header field and
pressing Enter jumps to (and page-aligns) that location; the mouse wheel and
arrow keys scroll a row at a time, Page Up/Down a full page, wrapping around
the 64 KB address space. Hovering a byte or its ASCII character shows any
symbol defined at that address as a tooltip.
While the CPU is stopped, double-clicking a hex byte or its ASCII character opens an Edit Memory dialog pre-set to that address, in hex or text mode respectively; it also has an “Allow ROM Overwrite” option for patching otherwise write-protected regions. Three more dialogs, reached only through the top-level Memory menu (there are no in-panel buttons): Load from File… (auto-detects Binary Image, Intel Hex, or Motorola S-Record format from the extension, with an optional VICE-format symbol file to import alongside it), Save to File… (a start/end address range), and Fill… (a start/end range plus a fill byte). All Memory menu commands, like the in-panel edit, are available only while the CPU is stopped.
Stack
Shows eight rows of the current stack page as word pairs, with a marker on the most recently pushed byte and the stack pointer’s own slot rendered as a placeholder rather than a stale value. A radix button cycles the display base; an EVEN/ODD toggle shifts how bytes are paired into words, a display convenience with no effect on the underlying data. It always tracks the active stack position — there’s no manual scrolling.
Breakpoints
A flat list of every breakpoint: an enable/disable indicator (click to toggle), the address, any symbol that resolves to it, and a remove button. The tab header’s “+” icon opens an Add Breakpoint popover accepting a hex address or a symbol name. All actions require the CPU to be stopped, and the list stays in sync with breakpoints set or toggled from the Disassembly panel’s gutter or context menu.
Watchpoints
Lists each watchpoint from watchpoints.emw with an enable/disable
indicator, its expression source, and a remove button; a row is colored by
its current state — disabled, a compile/evaluation error, currently
triggered, or not triggered. Double-clicking a row’s expression opens an
Edit Watchpoint popover; the tab header’s “+” icon opens Add Watchpoint the
same way. A collapsible Variables section below the list shows every named
:= variable and its current value. All editing is available only while
the CPU is stopped and the file has no compile error; see
Watchpoint Expressions below for the expression
language itself.
Symbols
A sortable, filterable table of the program’s symbol table — Name, Address, Source, and Aliases columns. A filter field does live substring matching across all four columns; clicking a column header sorts by it, clicking again reverses direction. Column widths are user-adjustable and persist across restarts. It refreshes automatically whenever the symbol table changes, such as after assembling and loading a program or loading a memory image with an associated symbol file.
Trace
A live view of recently executed instructions, recorded via the same facility described in Execution Tracing. A toolbar starts recording to a chosen file, stops it, or pauses just the live-follow scrolling without stopping the recording itself. Below it, a windowed log shows sequence number, cycle count, every register, decoded flags, address, raw bytes, and the disassembled instruction, with the same label-row interleaving as the Disassembly panel; while recording and unpaused, the view auto-follows the newest instructions. Clicking a row populates a Bus Operations pane below the log with every bus read or write that instruction performed.
Log
A running table of emulator log messages — timestamp, cycle count, level, category, and message — that always auto-scrolls to show the newest entry. There’s no filtering or manual scroll-lock; it simply accumulates.
Terminal
A full terminal emulator wired directly to the configured, memory-mapped console device, so a running program can be interacted with directly, with no external terminal emulator or PTY setup needed. Right-clicking the terminal, or clicking the hamburger icon in its dock-tab header, opens a size-preset menu offering four fixed grid sizes (80×24, 132×24, 80×43, 132×43) that resize the panel — or, once detached, the OS window itself — to match exactly. The same menu’s Preferences… item opens a tabbed dialog covering text appearance (font, scrollback, colors, the ANSI palette), cursor style and blink, and Backspace/Delete key compatibility. Copy and paste use Ctrl+Shift+C/V specifically so they don’t collide with the terminal’s own use of Ctrl+C and Ctrl+V.
Display
Renders the memory-mapped character display device’s composited output, scaled to the largest clean integer multiple of its native resolution that fits the panel. The canvas auto-focuses itself whenever its tab or window becomes active, and — when the device is configured with a keyboard range — forwards keystrokes to the emulated keyboard as single bytes, so a program can be driven entirely from this panel with no separate input device needed.
LED Matrix
Renders the memory-mapped RGB LED matrix device
as a grid of round LEDs on a dark PCB-styled background, deliberately
modeled on a real hobbyist LED matrix panel rather than a flat pixel blit.
Multiple attached matrices are laid out edge-to-edge exactly as the
device’s own arrangement describes; there’s no independent
panel-side layout control. Display-only — it accepts no keyboard or mouse
input.
LCD Display
Renders the memory-mapped LCD display device as a dot-matrix grid behind a bezel, using the device’s configured polarity, backlight, and geometry to reproduce a real module’s look — dim “off” segments stay faintly visible against the backlight rather than going flat black. Display-only, like LED Matrix.
Assembler
A full source editor (line numbers, a lint gutter for assembly errors,
tab-to-indent, undo/redo) for writing 6502 assembly and loading it straight
into the emulator’s memory. It’s meant for quickly assembling small programs
and patches directly against a running session — sketching out a routine,
tweaking a few instructions, and reloading them without leaving the
debugger — rather than replacing a full assembler toolchain or IDE for
larger projects. File operations — New, Open…, Save, Save As… —
are reached through the top-level Assembler menu; the dock tab’s title
tracks the open file’s name, with a trailing * while there are unsaved
changes. Assemble… compiles the current buffer and, on success, opens a
confirmation dialog listing each output segment’s origin address and byte
length before anything is actually written to memory — a successful compile
alone never touches memory. A failed assemble shows errors both as gutter
markers with hover text and as a plain-text list below the editor. Assemble…
is available only while the CPU is stopped; the file operations are always
available.
Status Bar
A slim bar fixed to the bottom of the main window — not a dock panel, so
it’s visible no matter what’s docked, floated, or hidden — showing, left to
right: a running cycle count; the emulator’s current effective clock speed;
an NMI indicator that also triggers a one-shot NMI on click; an IRQ
indicator that asserts or releases IRQ as a toggle while the CPU is stopped,
or fires a one-shot pulse while it’s running; a Run/Stop indicator that also
distinguishes a CPU halted on a STP or WAI instruction from an ordinary
stop; and a Reset button. This is the only place Reset lives in the UI —
there’s no equivalent button on the Run Controls panel.
Menus and Keyboard Shortcuts
The native menu bar is File, Edit, View, Run, Memory, Assembler, Window, and Help:
- File — New/Open/Reload Profile and Open Recent (see Profiles above), plus Exit. Everything but Exit requires the CPU to be stopped, since each reloads the active session.
- Edit — Cut, Copy, Paste, enabled according to whatever panel currently has focus and a selection (the Terminal and Assembler panels both participate). Deliberately unaccelerated so nothing here steals Ctrl+C/Ctrl+V from the terminal.
- View — one item per panel (see Docking and Window Layout above) to reveal a closed or buried dock tab.
- Run — mirrors the Run Controls panel exactly.
- Memory — the sole way to reach Memory’s Load/Save/Edit/Fill dialogs.
- Assembler — New/Open/Save/Save As…/Assemble…, mirroring the Assembler panel’s own menu-only workflow.
- Window — Detach/Attach for Terminal, Display, LED Matrix, and LCD Display, plus Restore Layout….
- Help — View on GitHub, and an About dialog with build information.
| Shortcut | Action |
|---|---|
Ctrl+N / Ctrl+O / Ctrl+Shift+R | New / Open / Reload Profile |
Ctrl+Q | Exit |
F5 / Shift+F5 | Run / Stop |
F10 / F11 / Shift+F11 | Step Over / Step Into / Step Return |
Ctrl+Shift+F5 | Toggle Auto-Step |
Ctrl+L / Ctrl+S | Memory: Load from File… / Save to File… |
Ctrl+Shift+E / Ctrl+Shift+F | Memory: Edit… / Fill… |
Alt+N / Alt+O / Alt+S / F9 | Assembler: New / Open… / Save / Assemble… |
Ctrl+Shift+T / D / M / I | Detach or attach Terminal / Display / LED Matrix / LCD Display |
Ctrl+Shift+C / Ctrl+Shift+V | Copy / Paste, inside the Terminal panel specifically |
The four detach/attach shortcuts work from any window, including a detached device’s own window, so a panel can be sent back to the dock without switching back to the main window first.
Watchpoint Expressions
Watchpoints are boolean expressions evaluated against live machine state
before each instruction; each line of watchpoints.emw is one watchpoint,
and the Watchpoint panel shows whether it’s currently triggered. The
expression language covers:
- Registers —
A,X,Y,P,S,PCX > 10 PC == $8010 - CPU status flags, prefixed with a backtick —
`N,`V,`B,`D,`I,`Z,`C`C `N && `Z - Literals — decimal, or hex with a
$or0xprefix (0o/0qoctal and0bbinary are also recognized)A == 42 A == $2A - Memory operands —
B[addr],W[addr],D[addr]read a byte, word, or doubleword from memory; a leading+or-interprets the value as signed (-also negates it)B[$0200] == $FF +B[$D010] < 0 // true when bit 7 (the sign bit) of the byte at $D010 is set W[$FE] != 0 - Symbols — a bare identifier resolves to the address of a label loaded
from a VICE-format label file (the
labelsdevice attribute), so a watchpoint can reference a source-level name instead of a hardcoded addressPC == reset_vector B[cursor_x] > 79 - Arithmetic, bitwise, and comparison operators —
+ - * / %,& | ^ ~,<< >>,== != < <= > >=,&& || !(B[$D010] & $80) != 0 - The walrus operator (
:=) snapshots a value into a named variable that persists across steps, so one watchpoint can be compared against a value captured on an earlier stepA != x // triggers once A differs from the value snapshotted below x := A // snapshot this step's A for comparison on the next step
Expressions are compiled to bytecode once, at load time, and evaluated efficiently on every step, making it practical to run many watchpoints simultaneously.
Build and run the debugger from debugger/src-tauri with the
Tauri CLI (cargo tauri dev for development,
cargo tauri build for a packaged release); this drives an npm run build
of the debugger/frontend React/TypeScript UI automatically.
Running the Emulator
Default configuration
When launched with no devices configured, the emulator runs with a built-in TaliForth 2 ROM and a full set of peripherals:
- 32 KB zero-filled RAM at
0x0000–0x7FFF - TaliForth ROM at
0x8000–0xFFFF - VIA at
0xFF80on a Unix-domain socket (~/.emma/sock/via6522) - R6551 ACIA at
0xFFF0on a pseudo-terminal (~/.emma/dev/ttyS0) - MC6850 ACIA at
0xFFF4on a pseudo-terminal (~/.emma/dev/ttyS1) - LFSR at
0xFFF6in step mode - Console device at
0xFFF8–0xFFF9, withCtrl+C(0x03) configured as its break key, connected to the process’s own standard input and output - WDC 65C02 variant at 1.8432 MHz
Interact with the Forth interpreter via standard input and output.
TOML configuration file
Use --config <file> to load a TOML configuration file. Top-level keys map
directly to emulator fields — there is no [emulator] wrapper:
cpu-variant = "WDC65C02" # or "65C02" (CMOS only, default)
clock-speed-hz = 1843200 # omit for unlimited throughput
unmapped-policy = "ignore" # or "error"; how unmapped-address accesses
# are handled (default: "ignore")
[[devices]]
type = "ram"
address = 0x0000
size = 32768 # or the quoted string "32K"
[[devices]]
type = "rom"
address = 0x8000
size = 32768
image = "~/roms/my.bin" # .bin, .rom, .hex, .ihx, .ihex, .s19, .srec
[[devices]]
type = "console"
address = 0xFFF8
transport = { pty = { path = "~/.emma/dev/ttyS0" } }
CLI flags
All config values can also be set from the command line. CLI takes precedence
over TOML, which takes precedence over environment variables. CLI arguments
are the natural fit for the standalone emma65 binary — a one-off run, a
quick experiment, a shell script — where writing a TOML file is more
ceremony than the task needs; the debugger drives its own profiles instead
and has no use for these flags.
emma65 --cpu-variant WDC65C02 \
--clock-speed-hz 1843200 \
--unmapped-policy error \
--device ram@0x0000,size=32768,fill=0 \
--device rom@0x8000,size=32768,image=~/roms/my.bin \
--device console@0xFFF8,transport=pty:~/.emma/dev/ttyS0
--device accepts one spec and can be repeated (as above), or several specs
space-separated after a single --device:
emma65 --device ram@0x0000,size=32768 rom@0x8000,size=32768,image=~/roms/my.bin
Device shorthand format: type@address[,key=value,...]
- Address: decimal,
0xhex,0ooctal, or0bbinary - Size: bytes, or
K/ksuffix for kibibytes (e.g.32K) — since everykey=valuehere is already a string, no quoting is needed (contrast the TOML form above, where the suffixed form must be a quoted string) - Paths support
~/tilde expansion - Boolean attributes take
true/false(e.g.with-tdre-bug=true)
Transport arguments on the CLI
Any device with a transport attribute takes the same shorthand on the
command line as in TOML (see Transport Options
for the full description of each kind):
| Transport | CLI shorthand | Example |
|---|---|---|
| Pipe | pipe:/path/to/exe | --device display@0xF000,transport=pipe:/path/to/emma65-display |
| TCP (any interface) | tcp:PORT | --device console@0xFFF8,transport=tcp:9600 |
| TCP (specific interface) | tcp:IP:PORT | --device console@0xFFF8,transport=tcp:127.0.0.1:9600 |
| Unix domain socket | unix:PATH | --device via/6522@0xFF80,transport=unix:~/.emma/sock/via6522 |
| PTY (auto-named) | pty | --device acia/6551@0xFFF0,transport=pty |
| PTY (named symlink) | pty:SYMLINK_PATH | --device acia/6551@0xFFF0,transport=pty:~/.emma/dev/ttyS0 |
Pipe transport arguments need a TOML file. In TOML, transport = { pipe = { command = ["/path/to/exe", "arg1", "arg2"] } } can pass arguments to the
spawned process. The CLI shorthand can’t: a --device spec’s own
key=value,... attributes are comma-separated, so a comma inside a pipe:
value is parsed as the start of the next attribute rather than as an
argument separator. transport=pipe:/path/to/emma65-display (no arguments
to the peripheral) works fine on the command line; something like
transport=pipe:/path/to/exe,--some-flag does not — reach for a TOML config
file instead when the peripheral needs arguments.
Device configuration examples
Every built-in device type from I/O Devices and
Memory Devices can be configured entirely from the
command line. A few representative examples, one --device flag per device:
# RAM and ROM
--device ram@0x0000,size=32K,fill=0
--device rom@0x8000,size=32K,image=~/roms/my.bin
# Console wired to a PTY, with a break key configured
--device console@0xFFF8,transport=pty:~/.emma/dev/ttyS0,break=0x03
# 6522 VIA over a Unix socket, using the binary peer protocol
--device via/6522@0xFF80,transport=unix:~/.emma/sock/via6522,protocol=binary
# 6551 ACIA over a PTY, in bug-compatible TDRE mode
--device acia/6551@0xFFF0,transport=pty:~/.emma/dev/ttyS0,with-tdre-bug=true
# 6850 ACIA listening on a TCP port
--device acia/6850@0xFFF4,transport=tcp:9600
# MC6840 PTM over a Unix socket
--device ptm/6840@0xFF90,transport=unix:~/.emma/sock/mc6840
# Character display with keyboard input, rendered by emma65-display
--device display@0xF000,keyboard-address=0xF800,break=0x03,transport=pipe:/path/to/emma65-display
# LCD display, rendered by emma65-lcd-display
--device display/lcd@0xD000,geometry=16x2,polarity=negative,backlight=blue,transport=pipe:/path/to/emma65-lcd-display
# 2x2 RGB LED matrix, rendered by emma65-led-matrix
--device display/matrix@0x9000,register-address=0x9400,arrangement=2x2,transport=pipe:/path/to/emma65-led-matrix
# 16-bit Galois LFSR in step mode
--device lfsr@0xFFF6,mode=step,taps=0xB400
# Priority interrupt controller
--device pic/finch@0xFFFF
# Finch bank-switched MMU — address is required but unused, see Memory Devices
--device mem/finch@0,bank-registers=0xFC00,control-register=0xFFD8,image=rom.bin,labels=rom.lbl
# Phoebe bank-switched memory
--device mem/phoebe@0,control-register=0xFFF7,image=rom.bin
# Vireo bank-switched memory
--device mem/vireo@0,control-register=0xFFF4,image=rom.bin
Environment variables
Any config key can be set with the EMMA65_ prefix, using _ in place of
-:
EMMA65_CPU_VARIANT=WDC65C02
EMMA65_CLOCK_SPEED_HZ=1843200
Built-in device types
| Type | Registers | Key attributes |
|---|---|---|
ram | — | size (required, integer bytes or quoted "K"/"k"-suffixed string), fill (optional byte), image (optional path) |
rom | — | size (required, integer bytes or quoted "K"/"k"-suffixed string), image (required path), fill (optional byte) |
console | 2 | transport (optional), break (optional byte: break-key code) |
acia/6551 | 4 | transport (optional), with-tdre-bug (bool), with-overrun (bool) |
acia/6850 | 2 | transport (optional) |
via/6522 | 16 | transport (optional), protocol (ascii or binary, optional) |
ptm/6840 | 8 | transport (optional), protocol (ascii or binary, optional) |
display/matrix | variable | arrangement (required COLSxROWS; columns * rows must be 1, 2, 4, or 8), register-address (required), frame_rate_hz, transport (optional, pipe: only) |
display/lcd | 2 | geometry (optional, default 16x2), cgrom (optional, a00/a02/path), polarity, backlight (optional presets), background, foreground (optional hex overrides), transport (optional, pipe: only) |
display | variable | columns, rows (optional, default 40×25), palette, font (optional paths), double-buffered (bool), frame-rate-hz, transport (optional, pipe: only), keyboard-address (optional address: maps a second 2-byte data/latch range, debugger-only for live input), break (optional byte, requires keyboard-address), irq (optional, only allocated when keyboard-address is set) |
lfsr | 2 | taps (optional u16), mode (continuous or step, optional) |
mem/finch | 2 | bank-registers, control-register (required addresses), image (required path), write-policy, fill, offset, labels (all optional) |
mem/phoebe | 1 | control-register (required address), image (required path), write-policy, fill, ram-fill, offset, labels (all optional) |
mem/vireo | 1 | control-register (required address), image (required path), write-policy, fill, ram-fill, offset, labels (all optional) |
mem/finch, mem/phoebe, and mem/vireo each occupy the entire 64 KB
address space rather than a fixed-size register window; their register count
above is the count of dedicated MMU/bank-control registers, placed at the
configurable addresses shown, not a contiguous block.
display’s register window is 2 * columns * rows + 2 bytes (char RAM +
color RAM + a control register + a status/data register), so it grows with
the configured grid size rather than being fixed.
display/matrix’s pixel memory is columns * rows * 1024 bytes (from its
arrangement), based at address; its command and data registers are a
separate 2-byte range based at register-address rather than immediately
following pixel memory, so the two can be placed independently on the bus
(e.g. keeping pixel memory aligned to a 1 KiB/N KiB boundary).
Every transport attribute above accepts the same shorthand string in TOML
(transport = "unix:~/.emma/sock/via6522") as on the CLI — see
Transport arguments on the CLI above for
the full set of forms and a CLI-specific limitation around pipe: arguments.
Running the Tracer
emma65-tracer decodes a binary trace file — recorded via emma65 --trace-file <path> or the debugger’s Trace window — into a human-readable,
disassembled instruction listing.
emma65-tracer [--output <path>] [--symbol-file <path>]... [--verbose] [<input>]
<input>— path to the trace file; reads from stdin if omitted--output <path>— path to write decoded output; writes to stdout if omitted--symbol-file <path>— a VICE-format label file to resolve addresses to symbol names; may be repeated to load labels from multiple files--verbose— additionally print the bus reads and writes performed by each instruction
Running the Display Peripheral
emma65-display is an SDL2 window that renders a display device’s
composited output when running the plain emma65 CLI standalone (the
debugger doesn’t need it — its own Display panel renders in-process). It’s
not run directly against a live emulator process; instead, the emulator
spawns it as a child and streams frame data to it over the pipe transport’s
stdin, per the Character Display External Protocol.
Building it requires SDL2 development headers (libsdl2-dev on
Debian/Ubuntu, sdl2 on Homebrew), the same way building the debugger
requires gtk on Linux:
cargo build --release -p emma65-display
Configure a display device with a pipe: transport pointing at the
built binary:
[[devices]]
type = "display"
address = 0xF000
transport = "pipe:/path/to/target/release/emma65-display"
(or the CLI equivalent — see
Device configuration examples
in Running the Emulator for the general --device syntax).
The window opens as soon as the emulator attaches the transport (immediately
on startup for a TOML/CLI-configured device), sized to the device’s
configured grid at an initial --scale (default 3, an integer multiple of
the native columns*8 by rows*8 pixel size); it remains resizable
afterward and letterboxes/scales to fit. Closing the window ends
emma65-display; it also exits cleanly if the emulator process exits or is
killed first, since that closes its stdin.
Keyboard input
emma65-display also feeds keyboard input back to the emulated program — it
captures key presses from its own SDL2 window and sends them over the same
pipe (its stdout), the same way the debugger’s Display panel supplies live
input in-process. This only does anything if the display device is
configured with a keyboard-address= range (see
Character Display):
[[devices]]
type = "display"
address = 0xF000
transport = "pipe:/path/to/target/release/emma65-display"
keyboard-address = 0xF800
break = 0x03
With no keyboard range configured, keystrokes are simply not captured or sent — the emulator would discard them anyway.
The emma65-display window must have keyboard focus (click it, the same as
any other window) to capture key presses; SDL2 doesn’t deliver events to an
unfocused window. What’s captured: ordinary printable characters (sent as
their ASCII code); Enter, Backspace, Tab, and Escape (as the
standard ASCII control codes); and Ctrl+<letter> (as 0x01–0x1A).
Anything else — modifier keys on their own, function keys, non-ASCII input
from an IME — is not sent. See the
inbound keystroke stream
for the exact byte-level encoding.
Running the LED Matrix Peripheral
emma65-led-matrix is an SDL2 window that renders a display/matrix
device’s per-matrix composited output when running the plain emma65 CLI
standalone (the debugger doesn’t need it — its own LED Matrix panel renders
in-process). Like emma65-display, it’s spawned by the emulator as a child
process and streams data to it over the pipe transport’s stdin, per the
LED Matrix External Protocol.
Building it requires SDL2 development headers (libsdl2-dev on
Debian/Ubuntu, sdl2 on Homebrew), the same as emma65-display:
cargo build --release -p emma65-led-matrix
Configure a display/matrix device with a pipe: transport pointing at the
built binary:
[[devices]]
type = "display/matrix"
address = 0x9000
arrangement = "1x4"
register-address = 0x9400
transport = "pipe:/path/to/target/release/emma65-led-matrix"
(or the CLI equivalent — see
Device configuration examples
in Running the Emulator for the general --device syntax).
The window opens as soon as the emulator attaches the transport, showing the
configured matrices side by side as round LEDs on a PCB-colored background,
flush against each other. --arrangement COLSxROWS lays the matrices out in
a grid instead of a single row (must be a divisor pair of the configured
matrix count, e.g. 2x2 for 4 matrices); --pitch sets the initial
on-screen LED center-to-center spacing in pixels (default 12). The window
remains resizable afterward and letterboxes/scales to fit. Closing the
window ends emma65-led-matrix; it also exits cleanly if the emulator
process exits or is killed first, since that closes its stdin.
This --arrangement flag only controls on-screen layout and is independent
of the device’s own arrangement config attribute, which controls bus
addressing (see RGB LED Matrix Display
in I/O Devices) — matrix n’s content is correct either way, but the two should
normally be set to the same COLSxROWS value so the picture on screen
matches the physical layout the program was written for.
Running the LCD Display Peripheral
emma65-lcd-display is an SDL2 window that renders a display/lcd device’s
composited dot-matrix output when running the plain emma65 CLI standalone
(the debugger doesn’t need it — its own LCD Display panel renders
in-process). Like emma65-display and emma65-led-matrix, it’s spawned by
the emulator as a child process and streams data to it over the pipe
transport’s stdin, per the
LCD Display External Protocol.
Building it requires SDL2 development headers (libsdl2-dev on
Debian/Ubuntu, sdl2 on Homebrew), the same as emma65-display and
emma65-led-matrix:
cargo build --release -p emma65-lcd-display
Configure a display/lcd device with a pipe: transport pointing at the
built binary:
[[devices]]
type = "display/lcd"
address = 0xD000
geometry = "16x2"
transport = "pipe:/path/to/target/release/emma65-lcd-display"
(or the CLI equivalent — see
Device configuration examples
in Running the Emulator for the general --device syntax).
The window opens as soon as the emulator attaches the transport, showing a
blank dot-matrix grid in the device’s configured background color before
any writes occur. --pitch sets the initial on-screen dot center-to-center
spacing in pixels (default 12); the window remains resizable afterward and
letterboxes/scales to fit. Closing the window ends emma65-lcd-display; it
also exits cleanly if the emulator process exits or is killed first, since
that closes its stdin.
Whether the window can ever show true 5×10 dots is fixed by the device’s
configured geometry= — only 8-character-5x10 and 16-character-5x10
have the physical common-line count a real 5×10 glyph needs (see
LCD Display). On those two
geometries, Function Set’s F bit still switches the active font between
5×8 and 5×10 dots at runtime, and the window resizes on the fly to match,
since that changes every subsequent frame’s pixel height — no configuration
is needed for the resize itself, it follows automatically from each frame
message’s own dimensions. On every other geometry, a program setting F=1
has no visible effect: the font, and so the window’s size, stays fixed at
5×8 for the life of the device.
For Contributors
Emma65 is written in Rust (2024 edition), as a Cargo workspace. The root
crate (emma65) exposes a library plus two binaries, emma65 and
emma65-tracer; three further workspace members — debugger/src-tauri
(emma65-debugger), display (emma65-display), and led-matrix
(emma65-led-matrix) — are thin front ends built on that library. Its
central public module is emulator, which implements everything those front
ends are built on: a 65C02 CPU model, a configurable memory bus, a library of
memory-mapped I/O devices, and the pluggable transports that connect those
devices to the outside world. The sections below describe how those pieces
fit together internally, for a contributor adding a new device or working on
the emulator core itself; see The Emulator Core for a
feature-level tour of the same territory. For full type- and function-level
detail, see the generated
API documentation.
CPU
emulator::cpu::Cpu is built with Cpu::builder(variant), which takes a
CpuVariant (Cmos65C02 or Wdc65C02), a ClockSpeed, and a Bus, and
owns the Registers, the interrupt-priority logic, and the fetch/decode/
execute loop. cpu::opcodes::decode_table() builds a fixed [DecodedOp; 256]
lookup table once, keyed by opcode byte — decoding an instruction is an array
index, not a match statement, keeping Cpu::step() cheap enough to run
unthrottled at full host speed. Every effective-address computation and ALU
operation lives behind that same table entry’s AddressingMode and
Mnemonic, so adding an instruction is a matter of extending the table and
execute()’s corresponding match arm — variant gating (which opcodes exist
on CMOS vs. WDC 65C02) is a property of the table itself, checked once at
decode time rather than scattered through execution.
Cpu::step() runs a fixed sequence every instruction: service a pending
RESET, then NMI, then a recognized IRQ (in that priority order) if one is
pending; otherwise fetch, decode, and execute one instruction. After the
instruction (or the STP/WAI idle case) it calls bus.tick_devices(cycles)
with the exact cycle count that instruction took, then polls
bus.device_interrupt_states() to refresh the interrupt controller. This is
the mechanism that keeps every device’s timers and interrupt lines
synchronized with CPU time without per-cycle callbacks — see
Device Interfacing below for the device side of that
same contract. exec::run()/run_from() drive this loop on a background
thread (throttled to a target ClockSpeed by batching cycle-vs-wall-time
comparisons over ~1,000 instructions), exposing a RunHandle/RunStopper
for control and a CpuLiveSnapshot read without pausing the CPU;
step_into(), step_over_subroutine(), step_over_breakpoint(), and
step_return() build the debugger’s single-step commands on top of the same
step() primitive.
Bus
emulator::bus::BusConfig is a builder: .ram(), .rom(), and .device()
each add a named AddressRange, and .build() resolves all 65,536 possible
addresses to their most-specific owner exactly once — consulting
IoDevice::claims() on any overlapping device candidate to settle
conditional chip-select — into a flat lookup table. Every subsequent
Bus::read()/write() the CPU performs is then a single array index into
that table, with no region walk or claims() re-check at runtime, so bus
access cost stays effectively constant regardless of how many devices are
configured. This is also the seam that keeps IoDevice implementations
decoupled from addressing: a device is only ever handed the absolute bus
address it was invoked with, never told which region it lives in, so the
same device type can be remapped to a different address purely through
configuration. bus::symbol::load_vice_labels loads a VICE-format label file
into a SymbolTable, shared by the disassembler, the tracer, and the
debugger’s address-to-name resolution.
Device Interfacing
Every device — built-in or custom — implements IoDevice
(emulator::device), stored in the bus as a boxed trait object behind a
DeviceId. Three methods are required: read/write (always passed the
absolute bus address, never a device-relative offset) and peek, which
must be side-effect-free since it backs the debugger’s Memory panel,
watchpoints, and the disassembler rather than a real CPU access. Everything
else — tick(cycles), irq_active(), take_nmi(), reset(), patch(),
shutdown(), claims() — defaults to a no-op and is opted into only as a
device actually needs it; tick() and the two interrupt hooks are the ones
built-in devices rely on most, since they’re what Cpu::step() calls into
after every instruction (see CPU above). A device signals NMI by
setting its own internal pending flag on the triggering event and reporting
it once, from take_nmi(); it never calls anything on itself to raise an
interrupt directly. Device construction is a separate concern from the
IoDevice trait: a DeviceModule implementation is the piece that turns a
TOML [[devices]] entry or --device CLI flag into an IoDevice instance
and a call to BusConfig::device() — see
Adding a Custom Device Module below, which
walks through implementing both halves for a new device type.
Peripheral Transport
Devices that exchange byte streams with something outside the emulator —
the console, the VIA, the PTM, both ACIAs — hold a Transport
(emulator::transport), a small byte-stream trait with implementations for
TCP sockets, Unix sockets, PTYs, spawned child processes over a pipe
(PipeTransport), and an in-process variant (InternalPipeTransport) used
internally to wire a console straight to the host’s own stdin/stdout or
terminal window. Every transport’s actual I/O runs on its own thread or
async task; a lock-free ring buffer (ChannelRelay/TransportRelay)
decouples that from the device’s synchronous tick() call, so a device
drains whatever bytes have arrived — none, one, or a burst — without ever
blocking, and the transport side never blocks waiting for the CPU thread
either. TransportReporter surfaces connect/disconnect/error events as
DeviceEvents over a channel obtained from device_event_channel(), which
is how the debugger UI shows transport status without polling it. A device
that needs a transport is configured the same way as EchoDevice in
A Device That Uses a Transport below:
deserialize a TransportSpec from the device’s attributes and call
to_transport_with_reporter() to obtain a connected Transport and its
paired relay during DeviceModule::instantiate().
Configuration
The emma65 binary (src/bin/emulator/) uses the emulator::config module
to load configuration from all sources (TOML, environment, CLI), build an
EmulatorSession, and run the free loop. The emulator::config module is
the integration point for contributors adding new device types. The
emma65-tracer binary (src/bin/tracer/) and the emma65-debugger crate
(debugger/src-tauri/) are both thin front ends over the same library.
Other Public Modules
Beyond emulator, the crate exposes three more top-level public modules:
assembler— assembles 6502 assembly source into one or more.org-delimited output segments plus a symbol table, viaassemble(source)(src/assembler/). Directives are.org,.byte(including string-literal operands),.word,.res, and.setcpu; symbols are defined viaFOO = expr,FOO .equ expr, or a label on an instruction (my_routine: LDA #$55). Expressions support arithmetic, logical, bitwise, and shift operators over symbols and literals, plus the classic 6502-assembler</>unary LSB/MSB extractors (e.g.LDA #<label); multi-pass resolution handles forward references and picks the zero-page addressing mode once an operand’s value is known. Output segments are ready to load into emulator memory (e.g. viaBus::patch) or round-trip through the disassembler for verification.disassembler— decodes bus memory into human-readable instruction listings via side-effect-freepeekreads, sharing the same opcode decode table and variant logic as the CPU (see CPU above); itstracesubmodule reconstructs a disassembly listing from a previously recorded binary execution trace, used by theemma65-tracerbinary and the debugger’s Trace window.watch— a self-contained watchpoint expression pipeline:Scanner→Vec<Token>→Parser→ExprAST →Compiler→Vec<OpCode>→Evaluator→Operand. The scanner and parser use zero-copy techniques — token text slices borrow directly from the source string — so the pipeline produces no heap allocations until bytecode emission.WatchCompilerandWatchEvaluatorare the primary entry points;WatchEvaluatorowns variable name-to-index mappings and persistent variable storage so that watchpoint variables survive across steps.
Key Dependencies
| Crate | Purpose |
|---|---|
bitflags | Processor status register flag sets |
thiserror | Structured, typed error enums |
rand | Random fill for uninitialized RAM |
tokio | Async runtime backing TCP, Unix socket, and PTY transport tasks |
crossbeam-channel | Sync/async bridge between device tick() calls and transport tasks |
libc / nix | PTY and pipe setup on Unix |
serde | Serialization framework for configuration structs |
clap | CLI argument parsing |
figment | Multi-source configuration merging (TOML, env vars, CLI) |
tempfile | Temporary file for the embedded default ROM at startup |
The other three workspace members are thin binary crates. debugger/src-tauri
(crate emma65-debugger) adds Tauri 2, tauri-plugin-dialog/
tauri-plugin-log, and (on Linux) gtk on the Rust side, plus a
React/TypeScript/Vite frontend in debugger/frontend — see
The Debugger. display (crate emma65-display) and
led-matrix (crate emma65-led-matrix) each add the sdl2 crate (requires
SDL2 development headers to build — see
Running the Display Peripheral and
Running the LED Matrix Peripheral) and
reuse the root crate’s compositing code directly rather than duplicating it.
Adding a Custom Device Module
A custom device is two pieces: an IoDevice implementation (the device
itself) and a DeviceModule implementation (the glue that lets it be
configured from TOML/CLI). Device modules are registered with
DeviceRegistry before Config::build() is called; once registered, a
module’s name() can appear as the type field in a TOML [[devices]]
entry or in a CLI --device shorthand.
Step 1 — Implement IoDevice. read/write/peek are always passed
the absolute bus address, not an offset into the device’s own registers —
store the device’s own base address (typically via a with_address()
builder, matching every built-in device) and compute address - self.address
yourself if you have more than one register. peek() must be side-effect-free
— it backs the debugger’s Memory panel, watchpoints, and the disassembler, so
it must never change device state or interrupt lines the way a real access
might. Beyond those three required methods, override whichever optional ones
your device actually needs — tick() for cycle-accurate timing,
irq_active()/take_nmi() for interrupts, reset()/shutdown() for
lifecycle, claims() for conditional chip-select — all described in
Memory-Mapped I/O Devices. Note that both
interrupt hooks are polled by the CPU, not called by the device itself:
irq_active() is a live query of your own IRQ state, and take_nmi() is
called once per step to report and clear a pending edge that your own code
raised internally (e.g. via a signal_nmi()-style helper of your own that
sets a pending flag on the triggering write) — not something a device calls
on itself. Everything else defaults to a no-op.
#![allow(unused)]
fn main() {
use emma65::emulator::IoDevice;
struct BlinkerDevice {
address: u16,
on: bool,
}
impl BlinkerDevice {
fn new() -> Self {
Self { address: 0, on: false }
}
fn with_address(mut self, address: u16) -> Self {
self.address = address;
self
}
}
impl IoDevice for BlinkerDevice {
fn read(&mut self, _address: u16) -> u8 {
self.on as u8
}
fn write(&mut self, _address: u16, value: u8) {
self.on = value != 0;
}
fn peek(&self, _address: u16) -> u8 {
self.on as u8
}
fn name(&self) -> &str {
"myvendor/blinker"
}
}
}
Step 2 — Implement DeviceModule. The trait requires name() and an
async
instantiate() that receives a BusConfig builder, the mapped address, a
HashMap<String, figment::value::Value> of configuration attributes, an
InstantiationContext (holds the configured clock speed, an error-event
sender, and — for the console only — a pre-built transport slot), and a
shared DeviceIdAllocator for obtaining a DeviceId that won’t collide with
any other configured device. The implementing struct must also be
Clone + Send + Sync + 'static.
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use emma65::emulator::{AddressRange, BusConfig};
use emma65::emulator::bus::DeviceIdAllocator;
use emma65::emulator::config::{DeviceModule, DeviceModuleError, InstantiationContext};
#[derive(Clone)]
struct BlinkerModule;
impl DeviceModule for BlinkerModule {
fn name(&self) -> &'static str { "myvendor/blinker" }
async fn instantiate(
&self,
bus_config: BusConfig,
address: u16,
_attributes: &HashMap<String, figment::value::Value>,
_context: &InstantiationContext,
id_allocator: Arc<Mutex<DeviceIdAllocator>>,
) -> Result<BusConfig, DeviceModuleError> {
let device_id = id_allocator.lock().unwrap().next(false);
bus_config
.device(
AddressRange::new(address, address + 1),
device_id,
Box::new(BlinkerDevice::new().with_address(address)),
)
.map_err(DeviceModuleError::BusConfig)
}
}
}
Step 3 — Deserialize attributes from the HashMap. Follow the pattern
used by
RamModule in src/emulator/config/ram.rs (or RomModule in
src/emulator/config/rom.rs): define a serde
Deserialize struct, then extract it with figment::Figment:
#![allow(unused)]
fn main() {
use figment::providers::Serialized;
use figment::value::{Dict, Value};
#[derive(serde::Deserialize)]
struct BlinkerAttributes {
color: String
}
let attrs = Dict::from_iter(attributes.clone());
let config: BlinkerAttributes = figment::Figment::new()
.merge(Serialized::defaults(attrs))
.extract()
.map_err( | e| DeviceModuleError::Config(e.to_string())) ?;
}
Step 4 — Register the module and build:
#![allow(unused)]
fn main() {
let mut registry = emma65::emulator::DeviceRegistry::with_builtins();
registry.register(BlinkerModule);
let session = config.build( & registry).await?;
}
Once registered, the module is available by name in TOML and CLI configuration:
[[devices]]
type = "myvendor/blinker"
address = 0xD000
color = "red"
A Device That Uses a Transport
BlinkerDevice never talks to anything outside the emulator, so its module
never touches Transport Options. A device that does —
follow the pattern used by every built-in transport-attached device (see
src/emulator/config/mc6850.rs for the simplest real example): deserialize
an optional transport attribute, convert it to a TransportSpec, and hand
it to TransportSpec::to_transport_with_reporter() to get back a connected
Transport and its paired TransportRelay. EchoDevice below sends
whatever’s written to it out over its transport, and buffers whatever the
transport delivers for the next read — draining the relay from tick(),
never from read(), so an idle transport never blocks CPU execution (see
Transport Options for why that’s safe to rely on):
#![allow(unused)]
fn main() {
use std::collections::VecDeque;
use emma65::emulator::{IoDevice, Transport, TransportRelay};
struct EchoDevice {
address: u16,
transport: Option<Box<dyn Transport>>,
relay: Option<TransportRelay>,
rx_buffer: VecDeque<u8>,
}
impl EchoDevice {
fn new() -> Self {
Self { address: 0, transport: None, relay: None, rx_buffer: VecDeque::new() }
}
fn with_address(mut self, address: u16) -> Self {
self.address = address;
self
}
fn attach_transport(&mut self, transport: Box<dyn Transport>, relay: TransportRelay) {
self.transport = Some(transport);
self.relay = Some(relay);
}
}
impl IoDevice for EchoDevice {
fn read(&mut self, _address: u16) -> u8 {
self.rx_buffer.pop_front().unwrap_or(0)
}
fn write(&mut self, _address: u16, value: u8) {
if let Some(transport) = self.transport.as_mut() {
transport.send(value);
}
}
fn peek(&self, _address: u16) -> u8 {
self.rx_buffer.front().copied().unwrap_or(0)
}
fn tick(&mut self, _cycles: u32) {
if let Some(relay) = self.relay.as_mut() {
let rx_buffer = &mut self.rx_buffer;
relay.drain_bytes_into(|b| rx_buffer.push_back(b));
}
}
fn name(&self) -> &str {
"myvendor/echo"
}
}
}
#![allow(unused)]
fn main() {
use emma65::emulator::config::{TransportSpec, TransportSpecFormat};
#[derive(Clone)]
struct EchoModule;
#[derive(serde::Deserialize)]
struct EchoAttributes {
transport: Option<TransportSpecFormat>,
}
impl DeviceModule for EchoModule {
fn name(&self) -> &'static str { "myvendor/echo" }
async fn instantiate(
&self,
bus_config: BusConfig,
address: u16,
attributes: &HashMap<String, figment::value::Value>,
context: &InstantiationContext,
id_allocator: Arc<Mutex<DeviceIdAllocator>>,
) -> Result<BusConfig, DeviceModuleError> {
let attrs = figment::value::Dict::from_iter(attributes.clone());
let config: EchoAttributes = figment::Figment::new()
.merge(figment::providers::Serialized::defaults(attrs))
.extract()
.map_err(|e| DeviceModuleError::Config(e.to_string()))?;
let transport_spec = config.transport
.map(TransportSpec::try_from)
.transpose()
.map_err(DeviceModuleError::Config)?;
let device_id = id_allocator.lock().unwrap().next(false);
let mut device = EchoDevice::new().with_address(address);
if let Some(transport_spec) = transport_spec {
let (transport, relay) = transport_spec
.to_transport_with_reporter(context.pipe_exit_reporter(device_id))
.await
.map_err(DeviceModuleError::Transport)?;
device.attach_transport(transport, relay);
}
bus_config
.device(AddressRange::new(address, address + 1), device_id, Box::new(device))
.map_err(DeviceModuleError::BusConfig)
}
}
}
[[devices]]
type = "myvendor/echo"
address = 0xD100
transport = { pty = { path = "~/.emma/dev/ttyEcho" } }
Wire Protocols
I/O Devices describes each device’s bus-facing register
behavior — what a 6502 program sees. This appendix documents the byte-level
protocols four of those devices speak over an attached
Transport to talk to something outside
the emulator process. It’s reference material for writing a peripheral
implementation (real hardware, a script, another emulator) or a replacement
for one of the bundled SDL2 peripheral binaries — not needed for ordinary use
of the emulator or debugger.
Two families of protocol, serving different needs:
- Peer-communication protocols — the VIA and PTM protocols exchange GPIO/timer signal state bidirectionally with one or more connected peripherals, mirroring how a real VIA or PTM talks to the hardware wired to its pins. A newly connected peripheral receives a full state dump; every peripheral thereafter sees every state change, whether it originated on the device (a program writing a register) or from another connected peripheral.
- External rendering protocols — the Character Display,
LED Matrix, and
LCD Display protocols stream composited
frame data to the bundled
emma65-display,emma65-led-matrix, andemma65-lcd-displaySDL2 peripheral binaries (see Running the Display Peripheral, Running the LED Matrix Peripheral, and Running the LCD Display Peripheral). These only matter when running the plainemma65CLI standalone — the debugger renders all three devices in-process and never speaks any of these protocols.
| Protocol | Device (config type) | Direction | Encoding | Transport requirement |
|---|---|---|---|---|
| VIA Peer Protocol | VIA (via/6522) | bidirectional | ASCII or Binary, selected by protocol= | multipoint (tcp:/unix:) |
| PTM Peer Protocol | PTM (ptm/6840) | bidirectional | ASCII or Binary, selected by protocol= | multipoint (tcp:/unix:) |
| Character Display External Protocol | Character Display (display) | outbound frames + inbound keystrokes | Binary only | atomic send (pipe: only) |
| LED Matrix External Protocol | LED Matrix (display/matrix) | outbound only | Binary only | atomic send (pipe: only) |
| LCD Display External Protocol | LCD Display (display/lcd) | outbound only | Binary only | atomic send (pipe: only) |
The three rendering protocols additionally require an atomic
transport send — either the
whole outbound message is delivered or none of it is — because all three are
framed with no length prefixes or delimiters; a partial write would desync
the stream with no way to resynchronize. In practice this rules out every
transport but pipe:, and each device rejects any other transport spec at
configuration time. The peer-communication protocols have no such
requirement, since every message is short and self-delimiting.
VIA Peer Protocol
Wire protocol for a peripheral to exchange GPIO port and control-signal
state with a VIA
device (config type via/6522) over an attached
transport.
Connection semantics
The VIA supports multiple concurrent peripheral connections over a
multipoint transport (tcp: or unix:) — pty:/pipe: transports are
point-to-point and can’t tag messages per connected client, so the config
module rejects them for via/6522 at instantiation time. Just as on real
hardware, it’s up to the peripherals themselves not to interfere with each
other (e.g. two peripherals both driving the same output pin).
When a peripheral connects, the VIA immediately sends it a full state dump covering both ports and their control signals, so it starts with an accurate picture without having to wait for the next change. After that, whenever any connected peripheral changes VIA state — or a 6502 program does, by writing a port configured for output — every connected peripheral is informed of the change. Unrecognized peripheral input is silently ignored; there is no error signaling in either direction.
Message encoding — ASCII or Binary — is chosen per device at configuration
time via the protocol attribute (protocol = "ascii" or protocol = "binary"; default ascii), not negotiated per connection. Every peripheral
connected to a given VIA instance uses the same encoding.
[[devices]]
type = "via/6522"
address = 0x9000
protocol = "binary"
transport = "unix:/path/to/via.sock"
The ASCII encoding is useful for interactive sessions from a terminal or socket utility, for education or debugging, and for simple scripting. The binary encoding is compact and efficient, and is the better choice for a real peripheral implementation.
Message types
Six message types are defined:
- Port State — sent by the VIA to convey the current state of Port A or B; may also be sent by a peripheral to configure the state of all pins of the subject port.
- Ctrl State — sent by the VIA to convey the current state of the control pins for Port A or B; may also be sent by a peripheral to configure the state of both control pins for the subject port.
- Reset Port — sent by a peripheral to reset any combination of bits in the specified port. The VIA sends this to convey bit-level state changes as needed. The message includes a mask byte identifying, with ones in the corresponding bit positions, which bits to reset.
- Set Port — sent by a peripheral to set any combination of bits in the specified port. The VIA sends this to convey bit-level state changes as needed. The message includes a mask byte identifying, with ones in the corresponding bit positions, which bits to set.
- Reset Ctrl — sent by a peripheral to reset either control pin (
Cx1orCx2) for the specified port. The VIA sends this to convey changes in individual control signals. The ASCII message specifies the pin to reset; the binary message specifies a mask identifying which control bits to reset. - Set Ctrl — sent by a peripheral to set either control pin (
Cx1orCx2) for the specified port. The VIA sends this to convey changes in individual control signals. The ASCII message specifies the pin to set; the binary message specifies a mask identifying which control bits to set.
ASCII protocol
Short strings of printable ASCII characters. A receiver must discard
non-printable ASCII control characters (0x00–0x1F, 0x7F), spaces
(0x20), and any byte with the high-order bit set, and must not distinguish
upper case from lower case letters.
As an aid to human readability, the VIA separates distinct messages with a
single space, and emits a canonical CR (0x0D) LF (0x0A) after every 72
characters of messages and spaces.
| Message Type | Format | Example | Description |
|---|---|---|---|
| Port State | pxx | A55 | Port A state is 0x55 |
| Ctrl State | Cpuv | CB10 | Port B CB1 is high and CB2 is low |
| Reset Port | Rpxx | RBF0 | Reset port B bits 4 through 7 |
| Set Port | Spxx | SA03 | Set port A bits 0 and 1 |
| Reset Ctrl | RCpu | RCA2 | Reset ctrl CA2 (low) |
| Set Ctrl | SCpu | SCB1 | Set ctrl CB1 (high) |
- p is a port identifier
AorB - u is a control pin identifier
1or2 - v is a bit state
0or1 - x is an ASCII hexadecimal digit
0..F
Binary protocol
Each message starts with a byte whose high-order bit is set. The upper four bits distinguish the message type; the lower four carry parameters. Port State, Reset Port, and Set Port messages are followed by one additional data byte.
| Message Type | Format | Example | Description |
|---|---|---|---|
| Port State | 10000p00 bbbbbbbb | 10000110 01010101 | Port B state: PB=0x55 |
| Ctrl State | 10010puv | 10010001 | Port A control state: CA1=0 CA2=1 |
| Reset Port | 10100pyz bbbbbbbb | 10100000 00000011 | Reset PA bits 0 and 1 |
| Set Port | 10110pyz bbbbbbbb | 10110110 11110000 | Set CB1 and PB bits 4 through 7 |
| Reset Ctrl | 11000pyz | 11000101 | Reset CB2 |
| Set Ctrl | 11010pyz | 11010011 | Set CA1 and CA2 |
- b is an arbitrary bit,
0or1 - p identifies the port;
0for port A,1for port B - u and v are the states of control pins
Cp1andCp2respectively;0or1 - y and z are mask bits identifying whether control pins
Cp1andCp2respectively are affected by a reset or set operation;0or1
PTM Peer Protocol
Wire protocol for a peripheral to exchange clock, gate, and timer-output
state with a PTM
device (config type ptm/6840) over an attached
transport.
Connection semantics
Like the VIA Peer Protocol, the PTM supports
multiple concurrent peripheral connections over a multipoint transport
(tcp: or unix:); pty:/pipe: transports are rejected for ptm/6840
at instantiation time because they can’t tag messages per connected client.
When a peripheral connects, the PTM immediately sends it a full state dump covering all three timers’ clock, gate, and output state. After that, whenever any connected peripheral changes PTM state — or a 6502 program does — every connected peripheral is informed of the change.
Unlike the VIA, the PTM’s message set is asymmetric: the PTM only accepts messages designated as sent by the peripheral (clock and gate edge transitions), and a peripheral only receives messages designated as sent by the PTM (clock, gate, and output state updates). Any other received message is silently ignored; there is no error signaling in either direction.
Message encoding — ASCII or Binary — is chosen per device at configuration
time via the protocol attribute (protocol = "ascii" or protocol = "binary"; default ascii), not negotiated per connection, exactly as for
the VIA.
[[devices]]
type = "ptm/6840"
address = 0xA000
protocol = "binary"
transport = "unix:/path/to/ptm.sock"
The ASCII encoding is useful for interactive sessions from a terminal or socket utility, for education or debugging. The binary encoding is compact and efficient.
ASCII protocol
Short strings of printable ASCII characters. A receiver must ignore
non-printable ASCII control characters (0x00–0x1F, 0x7F), spaces
(0x20), and any byte with the high-order bit set, and must not distinguish
upper case from lower case letters.
As an aid to human readability, the PTM separates distinct messages with a
single space, and emits a canonical CR (0x0D) LF (0x0A) after every 72
characters of messages and spaces.
| Message Type | Sent By | Format | Example | Description |
|---|---|---|---|---|
| Clock Edge | Peripheral | Cnp | C21 | Change the state of an input clock signal; n is the subject timer (1..3); p is the polarity (0=negative, 1=positive) |
| Gate Edge | Peripheral | Gnp | G30 | Change the state of an input gate signal; n is the subject timer (1..3); p is the polarity (0=negative, 1=positive) |
| Clock State | MC6840 | Txyz | T010 | Clock input state; x, y, z are the state (0 or 1) of timers 1, 2, 3 respectively |
| Gate State | MC6840 | Uxyz | U101 | Gate input state; x, y, z are the state (0 or 1) of timers 1, 2, 3 respectively |
| Output State | MC6840 | Vxyz | V001 | Timer output state; x, y, z are the state (0 or 1) of timers 1, 2, 3 respectively |
Binary protocol
Each message is a single bit-mapped byte with the high-order bit set; subsequent bits determine the message type and parameters. A receiver (peripheral or MC6840) must ignore any received byte whose upper nibble (bits 4–7) doesn’t match one of the recognized patterns below.
| Message Type | Sent By | b7 | b6 | b5 | b4 | b3 | b2 | b1 | b0 | Description |
|---|---|---|---|---|---|---|---|---|---|---|
| Clock Edge | Peripheral | 1 | 0 | 0 | 0 | P | C3 | C2 | C1 | P is the polarity (0=negative, 1=positive); each Cx set to 1 signals a transition of clock input Cx |
| Gate Edge | Peripheral | 1 | 0 | 0 | 1 | P | G3 | G2 | G1 | P is the polarity (0=negative, 1=positive); each Gx set to 1 signals a transition of gate input Gx |
| Clock State | MC6840 | 1 | 0 | 1 | 0 | 0 | C3 | C2 | C1 | Each Cx is the current state of clock input Cx |
| Gate State | MC6840 | 1 | 0 | 1 | 1 | 0 | G3 | G2 | G1 | Each Gx is the current state of gate input Gx |
| Output State | MC6840 | 1 | 1 | 0 | 0 | 0 | O3 | O2 | O1 | Each Ox is the current state of timer output Ox |
x ranges over 1..3, identifying one of the PTM’s three timers, in all
rows above.
Character Display External Protocol
Wire protocol the Character Display
device (config type display) uses to stream its composited frame data to an
external peripheral process — the bundled emma65-display binary, or a
replacement for it — over an attached transport,
when running the plain emma65 CLI standalone. It’s unrelated to how the
debugger renders the same device in-process, which needs no wire protocol at
all (same address space, same process), and unrelated to the
LED Matrix External Protocol — a different
device with different needs. See
Running the Display Peripheral for how
to configure and launch emma65-display itself, and
Character Display for the
device’s bus-facing register behavior — this page covers only what crosses
the transport.
Transport requirements
Exactly one connection, used in both directions: outbound (device →
peripheral) for the header and frames below, and inbound (peripheral →
device) for keystrokes. The outbound direction must be sent atomically —
either the whole buffer is written or none of it is — which in practice
means only pipe: is supported; any other transport spec is rejected for a
display device at configuration time. The inbound direction carries no such
requirement, since each of its messages is a single byte.
Message framing
No length prefixes or delimiters anywhere. The header is sent exactly once,
immediately when the transport is attached. Every subsequent outbound
message is a frame, sent once per vsync, always exactly 2 * cells + 3 * palette_len bytes — a size fully determined by the header, which a receiver
reads exactly once at the start of the stream. This is only safe because of
the outbound direction’s atomicity requirement above: a transport that could
deliver a partial frame would desync the stream permanently, with no way to
resynchronize.
Header (sent once, on attach)
| Field | Type | Size (bytes) | Notes |
|---|---|---|---|
magic | ASCII | 4 | "E65D" — distinct from the trace format’s "E65T" |
version | u8 | 1 | 1 |
columns | u32 LE | 4 | grid width in cells |
rows | u32 LE | 4 | grid height in cells |
frame_rate_hz | u32 LE | 4 | vsync cadence; informational only — a peripheral is not required to sync its own redraw to it |
palette_len | u16 LE | 2 | fixed for the connection’s lifetime (see Runtime palette updates) |
font | raw bytes | 2048 | 256 glyphs × 8 bytes/row, one byte per row, bit 0 = leftmost pixel |
Total header size: 2067 bytes.
Frame (sent once per vsync)
| Field | Size (bytes) | Notes |
|---|---|---|
| char RAM | cells | one glyph index per cell, row-major, top row first |
| color RAM | cells | one palette index per cell, row-major, top row first |
| palette | palette_len * 3 | RGB24 triples (r, g, b), in palette order |
cells = columns * rows, from the header. Total frame size, constant for
the life of the connection: 2 * cells + 3 * palette_len bytes.
The char/color RAM sent is always the buffer currently on screen — the scanout buffers in double-buffered mode, the CPU-addressable buffers directly otherwise — i.e. exactly the same data the debugger’s in-process compositing reads on the same vsync.
Inbound keystroke stream (peripheral → device)
Unlike the outbound stream above, this direction has no length prefix or
framing at all: one byte per keystroke, sent whenever the peripheral
captures a key press, with no relationship to vsync cadence or frame
boundaries. The device forwards each byte into its keyboard sub-range’s
input buffer when a keyboard-address= range is configured for the device,
and silently discards it otherwise.
Encoding mirrors the same keystroke encoding used by the debugger’s Display
panel: ordinary printable characters send their ASCII character code;
Enter, Backspace, Tab, and Escape send the standard ASCII control
codes (0x0D, 0x08, 0x09, 0x1B); Ctrl+<letter> sends
charCode(letter) - 64. Non-ASCII input (e.g. from an IME) is silently
dropped rather than encoded — there is no multi-byte encoding in this
stream.
Runtime palette updates
The device supports writing individual palette entries at runtime (see
the memory-mapped display device spec’s control/status register section).
Rather than a separate update message, the entire palette is resent as
part of every frame: simpler for both sides, and cheap — even the maximum
256-entry palette is 768 bytes, small next to a default 40×25 grid’s 2000
bytes of char+color RAM. No special-casing is needed on the sending side for
an update to take effect: each frame just reflects the palette’s current
in-memory state, whatever it happens to be. palette_len itself, unlike the
entries, cannot change after the header is sent — palette length is fixed
at configuration time and is never the subject of a runtime update.
Non-goals
No reconnection support — the design assumes a single spawned child process
tied to the device’s lifetime — and no protocol negotiation. A version
mismatch has no defined recovery: a peripheral that doesn’t recognize a
header’s version should refuse to proceed rather than guess at a
compatible framing.
LED Matrix External Protocol
Wire protocol the
RGB LED Matrix Display
device (config type display/matrix) uses to stream its per-matrix pixel and
palette data to an external peripheral process — the bundled
emma65-led-matrix binary, or a replacement for it — over an attached
transport, when running the plain
emma65 CLI standalone. It’s unrelated to how the debugger renders the same
device in-process, which needs no wire protocol at all (same address space,
same process), and unrelated to the
Character Display External Protocol — a
different device with different needs, most notably that this device’s
matrices swap per-matrix rather than in lockstep across the whole device on a
single vsync. See
Running the LED Matrix Peripheral
for how to configure and launch emma65-led-matrix itself, and
RGB LED Matrix Display
for the device’s bus-facing register behavior — this page covers only what
crosses the transport.
Transport requirements
Exactly one connection, outbound only (device → peripheral) — unlike the
Character Display, this device has no input capability, so there is no
inbound direction. The transport send must be atomic — either the whole
buffer is written or none of it is — which in practice means only pipe: is
supported; any other transport spec is rejected for a display/matrix
device at configuration time.
Message framing
Unlike the Character Display protocol, messages here are tagged rather than shaped as one fixed-size frame — swaps happen per-matrix, at unpredictable intervals relative to each other, so there is no single per-tick “frame” to send as a unit. The header is sent exactly once, immediately when the transport is attached. Every subsequent message begins with a one-byte tag that determines its fixed total length; there are no length prefixes anywhere in this protocol. This is only safe because of the transport atomicity requirement above: a transport that could deliver a partial message would desync the stream permanently, with no way to resynchronize.
Header (sent once, on attach)
| Field | Type | Size (bytes) | Notes |
|---|---|---|---|
magic | ASCII | 4 | "E65M" — distinct from the trace format’s "E65T" and the display protocol’s "E65D" |
version | u8 | 1 | 2 |
matrix_count | u8 | 1 | number of matrices configured, 1..=8 |
columns | u8 | 1 | the device’s configured arrangement’s column count; the peripheral derives row count as matrix_count / columns, which always divides evenly |
frame_rate_hz | u32 LE | 4 | auto-refresh cadence; informational only — a peripheral is not required to sync its own redraw to it |
Total header size: 11 bytes. Unlike the Character Display protocol’s header, there is no palette or per-matrix dimension field: matrix dimensions are a fixed 32×32 constant that both sides already know, and the palette is never transferred at connection time (see Runtime palette updates).
columns was added in version 2, replacing the peripheral’s own
--arrangement command-line flag: the peripheral’s on-screen layout now
always mirrors the device’s actual bus-addressing arrangement rather than an
independently chosen value that could disagree with it.
Messages (sent as they occur)
Every message after the header begins with a one-byte tag identifying its type and fixed length.
Block (MSG_BLOCK = 1, sent once per matrix swap)
| Field | Type | Size (bytes) | Notes |
|---|---|---|---|
tag | u8 | 1 | 1 |
matrix_index | u8 | 1 | which matrix this block belongs to, 0..matrix_count |
pixels | raw | 1024 | one palette-index byte per pixel, row-major, top row first |
Total message size: 1026 bytes. Sent whenever a matrix is swapped to its
visible buffer — whether triggered by the SWAP command or by
auto-refresh — carrying that matrix’s contents exactly as swapped. The
peripheral is expected to composite these raw indices against its own copy
of the current palette (see below), the same way the debugger’s in-process
rendering does.
Palette (MSG_PALETTE = 2, sent only on an actual PALETTE_WRITE)
| Field | Type | Size (bytes) | Notes |
|---|---|---|---|
tag | u8 | 1 | 2 |
index | u8 | 1 | palette entry updated, 0..256 |
color | u16 LE | 2 | packed RGB565 (rrrrrggggggbbbbb), the entry’s new value |
Total message size: 4 bytes. Sent whenever a PALETTE_WRITE command is
applied, carrying the already-quantized RGB565 value stored in the device’s
palette table — the same value a subsequent PALETTE_READ of that entry
would report (scaled back up to 8-bit components), not the original
pre-quantization write bytes.
Power (MSG_POWER = 3, sent only on an actual SET_POWER)
| Field | Type | Size (bytes) | Notes |
|---|---|---|---|
tag | u8 | 1 | 3 |
mask | u8 | 1 | new power-state bitmask, one bit per matrix |
Total message size: 2 bytes. Sent whenever a SET_POWER command is applied.
The peripheral must retain this mask and reapply it to every future
composite of each affected matrix, the same way it already retains the
palette — a powered-off matrix composites to fully black regardless of
palette content.
Brightness (MSG_BRIGHTNESS = 4, sent only on an actual SET_BRIGHTNESS)
| Field | Type | Size (bytes) | Notes |
|---|---|---|---|
tag | u8 | 1 | 4 |
level | u8 | 1 | new global brightness level, 0..=255 |
Total message size: 2 bytes. Sent whenever a SET_BRIGHTNESS command is
applied. The peripheral must retain this value and reapply it to every
future composite of every matrix, the same way it already retains the
palette.
Power and brightness messages are a pure addition to this tagged scheme, requiring no change to any existing message’s framing.
Startup state (device → peripheral)
The device never re-sends the full contents of every matrix or the whole
palette at connection time. A peripheral that attaches after the device has
already been running sees only messages for matrices swapped, palette
entries written, and power/brightness changes made from that point forward;
anything unset renders using the peripheral’s own reconstruction of the
built-in default palette, all-zero (index 0) pixel data, and full
power/brightness (power_mask = 0xFF, brightness = 0xFF), matching the
device’s own defaults at startup.
Runtime palette updates
Unlike the Character Display, which resends its entire palette with every frame, this device’s palette is comparatively large (256 entries, RGB565) and changes independently of any single matrix’s swap cadence, so each write is sent as its own small message (see Palette above) instead. A peripheral must therefore retain every matrix’s most recently received raw pixel indices as well as its own copy of the palette, and recomposite every matrix’s stored pixels whenever a palette message arrives — palette changes are not re-sent per matrix.
LCD Display External Protocol
Wire protocol the LCD Display
device (config type display/lcd) uses to stream its composited frame data
to an external peripheral process — the bundled emma65-lcd-display binary,
or a replacement for it — over an attached
transport, when running the plain
emma65 CLI standalone. It’s unrelated to how the debugger renders the same
device in-process, which needs no wire protocol at all (same address space,
same process), and unrelated to the
Character Display External Protocol
and LED Matrix External Protocol — different
devices with different needs. See
Running the LCD Display Peripheral
for how to configure and launch emma65-lcd-display itself, and
LCD Display for the device’s
bus-facing register behavior — this page covers only what crosses the
transport.
Transport requirements
Exactly one connection, outbound only (device → peripheral) — like the LED
Matrix, and unlike the Character Display, this device has no input
capability at all. The transport send must be atomic — either the whole
buffer is written or none of it is — which in practice means only pipe: is
supported; any other transport spec is rejected for a display/lcd device
at configuration time.
Message framing
The header is sent exactly once, immediately when the transport is attached. Every subsequent message is a frame, sent whenever the device pushes a frame — i.e. after every register write that could change what’s rendered, with no periodic cadence at all, unlike the Character Display’s per-vsync or LED Matrix’s per-swap sends.
Unlike both of those protocols, a frame here is not a fixed size for the
life of the connection: Function Set’s F bit can switch the active font
between 5×8 and 5×10 dots at any time, changing every subsequent frame’s
pixel height. So each frame message carries its own width_px/height_px
fields rather than relying on the header alone to fix a size. There are
still no separate length prefixes or delimiters beyond those two fields: a
frame’s total size is always exactly 4 + width_px * height_px * 4 bytes,
which the receiver can compute as soon as it has read those two fields. This
is only safe because of the transport atomicity requirement above: a
transport that could deliver a partial frame would desync the stream
permanently, with no way to resynchronize.
A frame the transport can’t accept immediately (its outbound buffer still full of an earlier, not-yet-drained message) is not lost: the device keeps it and retries on every subsequent CPU cycle until it goes through. A later write that composites a newer frame before the retry succeeds replaces the pending one outright rather than queuing behind it, since only the current state is ever worth delivering. This guarantees the peripheral eventually catches up to whatever the device last rendered, even after a burst of writes outruns the peripheral’s read/render rate — unlike the Character Display’s or LED Matrix’s periodic cadence, which corrects itself on the next tick regardless, a permanently dropped frame here would otherwise leave the peripheral showing stale content indefinitely.
Header (sent once, on attach)
| Field | Type | Size (bytes) | Notes |
|---|---|---|---|
magic | ASCII | 4 | "E65L" — distinct from "E65D" (display) and "E65M" (LED matrix) |
version | u8 | 1 | 1 |
columns | u8 | 1 | configured character grid width |
rows | u8 | 1 | configured character grid height |
background | RGB24 | 3 | r, g, b — configuration-time-fixed |
foreground | RGB24 | 3 | r, g, b — configuration-time-fixed |
Total header size: 13 bytes. columns/rows are the character grid
dimensions, not pixel dimensions — a peripheral wanting pixel dimensions
ahead of the first frame can compute an upper bound (columns * 5 by
rows * 10) but must still read each frame’s own width_px/height_px to
render it, since the actual font in use isn’t known until the first frame
arrives. background/foreground are carried here, not derived from frame
pixel data, so a peripheral can replicate the debugger panel’s dot-matrix
cosmetics (see below) without having to reverse-engineer which composited
pixels are “on” vs. “off”.
Frame (sent whenever the device pushes a frame)
| Field | Type | Size (bytes) | Notes |
|---|---|---|---|
width_px | u16 LE | 2 | columns * 5 (fixed glyph cell width) |
height_px | u16 LE | 2 | rows * 8 or rows * 10, depending on the active font |
pixels | raw | width_px * height_px * 4 | RGBA, row-major, top row first, 4 bytes per pixel |
Total message size: 4 + width_px * height_px * 4 bytes. The pixel data
sent is always exactly what the device’s compositing produces for its
current DDRAM/CGRAM/CGROM/cursor/mode state — already fully composited
(background/foreground baked into each pixel, cursor drawn, blank when
display_on is false). There is no palette to separately transmit or
retain, unlike the Character Display or LED Matrix: this device’s only two
colors are the header’s fixed background/foreground.
Rendering cosmetics are the peripheral’s responsibility
The device-side compositing produces a flat one-RGBA-pixel-per-dot buffer
with no visual polish — no gaps, no rounded corners, no dim “off” state.
That cosmetic dot-matrix rendering (rounded dots, inter-dot and inter-cell
gaps, a dimly-visible off state rather than flat background) deliberately
lives independently in each renderer rather than in a shared library — this
protocol carries the same undecorated raw buffer the debugger panel receives
in-process, and a companion peripheral is expected to apply its own
equivalent cosmetic treatment using its own native drawing primitives (the
same split the LED Matrix’s protocol and emma65-led-matrix already use for
round-LED rendering). A peripheral can distinguish an “on”
dot from an “off” one the same way the debugger panel does: a pixel exactly
equal to the header’s background triple is “off”; anything else is “on”
(in practice, always exactly the header’s foreground triple).
Startup state and reconnection
There is no reconnection support — the design assumes a single spawned
child process tied to the device’s lifetime, mirroring the Character
Display’s and LED Matrix’s companion processes. A peripheral that attaches sees no frame
at all until the device’s next render-affecting register write — unlike the
debugger panel, which can fetch a cached last-delivered frame on mount,
there is no equivalent “replay the last frame” mechanism over this
protocol, so a freshly attached peripheral should render a blank grid (in
background) until its first frame arrives.
Non-goals
No protocol negotiation — a peripheral that doesn’t recognize a header’s
version should refuse to proceed rather than guess at a compatible
framing. No inbound direction (this device has no input capability at
all). No power/brightness/contrast messages (the HD44780 has no such
registers, and unlike the LED Matrix’s power/brightness messages, nothing in
this device’s spec calls for them).
Trace File Format
The binary format the emulator writes, and the debugger and tracer read, when
execution tracing is active. It’s
reference material for writing an independent trace file reader (e.g. a
script or alternate viewer) — the bundled emma65-tracer binary already
decodes it into disassembly listings (see Running the Tracer),
and the debugger’s Trace window reads it live without needing to touch this
format directly.
A trace file is a fixed 8-byte header followed by a sequence of fixed-width 16-byte records, all little-endian, with no trailer.
Header
| Offset | Length | Field | Description |
|---|---|---|---|
| 0 | 4 | magic | ASCII E65T |
| 4 | 1 | format version | Currently 2. A reader must reject any other value. |
| 5 | 1 | CPU variant | 0 = Cmos65C02, 1 = Wdc65C02 |
| 6 | 2 | reserved | Always zero |
The CPU variant identifies which 65C02 variant produced the trace — relevant
to a decoder because Wdc65C02 adds 34 opcodes (STP, WAI, BBR0–7,
BBS0–7, RMB0–7, SMB0–7) beyond the base Cmos65C02 set.
Records
Each record is exactly 16 bytes:
| Offset | Length | Field | Description |
|---|---|---|---|
| 0 | 8 | instr_id | u64. See Instruction correlation below. |
| 8 | 1 | tag | 0 = Registers, 1 = Read, 2 = Write, 3 = Cycles |
| 9 | 7 | payload | Tag-dependent, zero-padded |
A reader determines a record’s meaning from the tag byte alone; the payload layout is fixed per tag, not length-prefixed.
Registers (tag 0)
A snapshot of all CPU registers taken immediately before the instruction
identified by instr_id began executing. Emitted once per instruction, as
the first record for that instr_id.
| Payload offset | Length | Field |
|---|---|---|
| 0 | 1 | A |
| 1 | 1 | X |
| 2 | 1 | Y |
| 3 | 1 | S |
| 4 | 2 | PC (u16) |
| 6 | 1 | P (status register, see below) |
The P byte is the eight processor status flags packed as
N V - B D I Z C (bit 7 down to bit 0), where - is the always-set UNUSED
bit — the same encoding pushed to the stack on real hardware:
| Bit | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|
| Flag | N | V | UNUSED | B | D | I | Z | C |
Read (tag 1) / Write (tag 2)
A single bus access performed while executing the instruction identified by
instr_id. A record is emitted for every bus read and write the CPU
performs — operand fetches, opcode fetches, stack pushes/pops, and each
device access — never for a device’s side-effect-free peek.
| Payload offset | Length | Field |
|---|---|---|
| 0 | 2 | addr (u16) |
| 2 | 1 | value |
| 3 | 4 | (padding) |
Cycles (tag 3)
The total clock cycle count for the instruction identified by instr_id
(base cycles plus any addressing-mode or branch-taken extra cycles), known
only once the instruction has finished executing. Emitted once per
instruction, as the last record for that instr_id — after every
Registers, Read, and Write record sharing that id.
| Payload offset | Length | Field |
|---|---|---|
| 0 | 1 | cycle count |
| 1 | 6 | (padding) |
Instruction correlation
instr_id is a monotonically increasing counter (wrapping on overflow,
which in practice never happens) assigned once per instruction executed. Every
record belonging to the same instruction — its Registers snapshot, each
bus Read/Write it performs, and its final Cycles total — shares the
same instr_id, in that relative order, though other instructions’ records
never interleave with them since the CPU executes one instruction to
completion before the next begins. A reader reconstructing a
per-instruction view (as emma65-tracer does) can therefore group records
by run of equal instr_id.
Seeking
Because every record is exactly 16 bytes, the byte offset of record n
(0-based, counting from the first record after the header) is
8 + n * 16. A reader with a seekable source can jump directly to any
record without scanning from the start — this is how the debugger’s Trace
window serves windowed reads over a large live trace file.
API Documentation
Redirecting to the generated rustdoc API documentation…