RP6502-EMU¶
RP6502 - Emulator
Note
This page still needs a major review to bring it up to the standard of the manual.
Introduction¶
The RP6502-EMU is the machine on the device you use every day, a
Picocomputer 6502 hosted on a desktop or laptop running Windows, macOS or
Linux. It plays .rp6502 games and applications in a window, with the
keyboard, mouse and gamepads of that computer. The same emulator is a
RetroArch core, which reaches Android phones, tablets and TVs, and
iPhone, iPad and Apple TV, and adds save states, rewind and netplay.
The emulator is also the development machine. A project made from the
SDK template runs in it from VS Code, with breakpoints, stepping
and variables in the C or assembly source. --debug opens a debugger
for the whole machine over the emulated screen. A script works the
keyboard, gamepads and pointer and checks the results, and a headless run
puts a 6502 program in a shell pipeline.
The debug adapter, a script read from - and a headless run all use
standard input and output. With the SDK, they give an AI assistant a full
path down to the 6502: the assistant can build a program, run it, stop it
at a breakpoint, read its memory and check the screen.
Install¶
Pre-built emulators are on the releases page. A project made from
the SDK template will fetch the right one into tools/, so
you may have it already.
Windows —
rp6502-emu.exeis the program itself, not an installer. Requires a GPU with Direct3D 11. It isn’t code signed, so SmartScreen warns on first launch; choose “More info” then “Run anyway”.macOS — drag
rp6502-emu.appto Applications. It isn’t signed or notarized, so Gatekeeper blocks the first launch. Allow it under System Settings > Privacy & Security > “Open Anyway”, orxattr -dr com.apple.quarantine rp6502-emu.app.Linux — built on Ubuntu 22.04, so it needs glibc 2.35 or later plus the GL, X11, and ALSA runtime libraries. The tarball preserves the execute bit; if something along the way stripped it,
chmod +x rp6502-emu.RetroArch — the core is in the Online Updater, under “Picocomputer 6502”; see RetroArch below.
Running Software¶
6502 software is distributed as files ending in .rp6502. Find them on
Discord, which has a forum for ROMs, or on itch.io under the RP6502 tag:
Start the emulator with a ROM, or drag one onto the window.
rp6502-emu game.rp6502
RetroArch¶
The RP6502-EMU is also a libretro core. RetroArch reaches devices that a
dedicated emulator would make no sense for: Android phones, tablets and
TVs, iPhone, iPad and Apple TV, and Linux on ARM boards. Install the core
from Online Updater > Core Downloader, under “Picocomputer 6502”, then
load a .rp6502 ROM the way you would a cartridge. Save states are deterministic, so rewind,
run-ahead and netplay all work.
The core runs the ROM by its full path, so argv[0] is the absolute path
of the file. A ROM whose path no program could name runs from the null
drive as :name instead, as described in Installed ROMs. SAVE: files go in an rp6502 folder
inside the RetroArch save folder, or in the working directory when the
ROM starts if RetroArch has no save folder. The core never changes the
working directory, so a program starts in the working directory of
RetroArch.
Each of the four ports is a Gamepad by default. Gamepad (Analog) adds the analog sticks. Lightgun is the Picocomputer’s tablet, because a lightgun is libretro’s only absolute pointing device.
libretro was built for consoles with gamepads, and a core cannot work around these limits:
Keyboard — RetroArch maps the keys to its virtual RetroPad and its own hotkeys, so typing reaches the program only with Game Focus on. Game Focus is the Scroll Lock key by default. The core cannot turn Game Focus on or ask whether it is on, so it shows “Enable Game Focus for Keyboard and Mouse.” the first time a program asks for the keyboard. Settings > Input > Auto Enable ‘Game Focus’ Mode > Detect turns Game Focus on whenever the core is loaded.
Mouse — libretro has no way to share the host’s cursor, so the core reports no host cursor and the program draws its own. With Game Focus off, RetroArch shows its own cursor as well. With Game Focus on, RetroArch captures the mouse, and the edges of the screen are hard to reach.
In a Toolchain¶
With --headless, a ROM runs as a command-line program on the host, so
a 6502 program can be one step of a build, a script or a test. The
program reads the host’s stdin and writes the host’s stdout and stderr,
and its exit code becomes the exit code of the emulator. Errors from the
host’s filesystem are mapped to the errno values of the program’s C
library, cc65 or llvm-mos, so the program’s error handling needs no
change. --phi2 0 removes the speed limit.
rp6502-emu --headless --phi2 0 tool.rp6502 < input.txt > output.txt
rp6502-emu --headless adventure.rp6502
rp6502-emu --stdin game.rp6502 # a window, and the terminal too
With a window, stdout and stderr go to the host’s streams and
also show on the VGA terminal, so an error is on the screen even when the
streams are redirected. --stdin makes the host’s stdin the console
input while the window stays open. Once the input is gone, a read of
stdin returns 0 bytes.
Arguments¶
Note
Arguments are beta and may change.
There are no short options. Both --opt value and --opt=value
work.
Option |
Value |
Description |
|---|---|---|
|
- |
Print the options and the script commands, then exit. |
|
|
Run headlessly, render the frames to PNG, and exit. |
|
- |
Run headlessly, render the frames, print the canvas as a CRC-32 on stdout, and exit. |
|
number |
Frames to run before the screenshot or the CRC. Default 120. |
|
number |
Window scale, fractional allowed. Default 1.5. |
|
|
How pixels are scaled to the window. Default |
|
|
A fixed shape for the window. The console canvas has the same shape, at 640x480, 640x360, or 640x400, with 30, 22, or 25 rows, and a canvas of another shape is letterboxed or pillarboxed. Without it, the console canvas is 640x480, and the window takes the shape of each canvas. |
|
|
See Scripting. |
|
- |
No window and no picture. The program reads and writes the host’s
stdin, stdout and stderr, and its exit code becomes the emulator’s.
Implies |
|
- |
The host’s stdin becomes the machine’s console input. A terminal
there becomes the console itself. Implied by |
|
|
Install a file on the null drive, reached as |
|
|
The folder that holds |
|
|
Letterbox and pillarbox fill, with or without |
|
kHz |
6502 clock, 100 to 8000. Default 8000. |
|
number |
OEM code page. 437, 720, 737, 771, 775, 850, 852, 855, 857, 860-866, or 869. Default 437. |
|
number |
The seed of the random fill of RAM and XRAM, 0 to 4294967295. Without it, the seed is new on every run. |
|
byte |
Fill RAM and XRAM with one byte, such as |
|
- |
No synthesis and no audio device opened at all. |
|
- |
The on-screen machine debugger. It also holds the window open after the program exits, so you can examine where it stopped. |
|
- |
Act as a DAP debug adapter on stdio. Implies |
|
|
Where the debugger keeps its window layout. |
|
- |
Print third-party credits and licenses, then exit. |
|
- |
Print the version and exit. |
|
words |
Pass everything after this to the ROM as |
Debugging¶
The emulator is a DAP debug adapter, so any editor that supports the Debug Adapter Protocol can do source-level debugging of 6502 code. The SDK covers the VS Code side, which the project template already configures.
Both compilers support breakpoints on a source line, conditional and hit-count breakpoints, logpoints, breakpoints on a function or an instruction, watchpoints on data, stepping in and over and out, a call stack with file and line, locals and globals and registers, watch and hover expressions, assignment to a variable, reading and writing memory, and disassembly.
The limitations depend on which compiler you chose.
cc65 emits no DWARF, so the adapter reads the debug file ld65 writes instead. That file carries no C type information, so widths are inferred from how the symbols sit in memory, and it describes no call frames, so the call stack is walked by inspection rather than read. A parameter passed in a register does not appear at all, and locals are trustworthy where you stopped rather than part-way through an expression.
llvm-mos emits ELF with DWARF, so variables are typed, arrays and structs and pointers expand, and the call stack is read rather than guessed.
Neither offers XRAM or XSTACK as variable scopes. Use the memory views.
Fuller DWARF for llvm-mos is being worked on upstream. The DWARF
overview on the llvm-mos
wiki describes the work, and the code is on the numbered
feature/debug branches of the fork it’s developed in.
Nothing is released, so trying it means building LLVM from source.
The on-screen debugger¶
--debug opens the machine debugger over the emulated screen: the CPU
and VIA with their pins, the RIA’s registers, an audio scope,
disassembly, execution history, breakpoints, a stopwatch, memory editors
for RAM and XRAM and XSTACK, a memory heatmap, and the linker’s segments.
The Options menu sets window and UI scale and the theme, and it shows the
loaded ROM’s own help.
The Debug Adapter Protocol¶
--dap speaks DAP on stdio. There is no port to connect to; the editor
launches the emulator and talks to it over the pipe.
The launch request takes program, args, and optionally elf or
dbg to name the debug information. stopOnEntry breaks
before the first instruction. stopOnExit is on by default and keeps
the session alive after the program ends, so the final screen remains on
display. The program’s stdout and stderr reach the Debug Console
as output events of those two categories, so VS Code shows stderr in
red.
Scripting¶
Note
Scripting is beta and may change.
--script drives the machine with no one at the keyboard. It types,
works the gamepads and the pointer, waits for what the program writes to
stdout and stderr and for what reaches the screen, and checks what the
program produced, which is enough to turn a ROM into a test that passes or
fails.
rp6502-emu --script adventure.txt adventure.rp6502
Given - instead of a filename it reads stdin a line at a time, so a
driver written in any language can work the machine. The machine waits
for each line, so the driver sets the pace. See Driving it from a
program.
A script always runs headless, and nothing paces it against the host’s
clock. Frames elapse only when the script asks for them, so run 600 is
six hundred frames and six hundred VSYNCs every time.
A command that waits, such as wait, runs frames until what it waits for
happens. Its budget is the largest number of frames that it runs. When the
budget runs out, the line fails, so a script that waits for something that
never happens ends instead of hanging. The default budget is 600 frames, ten
seconds of the machine’s time. run and wav run the number of frames in
their argument. Every other command runs no frames, so expect, peek,
and the other checks read the machine as it is at that line.
One command per line. # at the start of a word, outside quotes, starts a
comment. Text is always in double quotes and takes the C escapes, so \n is
a newline, \\ and \" are themselves, and \x03 or \3 is a
control byte. Hex takes up to two digits and octal up to three. A program can
write a zero byte, so wait and expect take \0, and no other string
can hold one. A number is an expression, such as 255, $FF, or
XRAM_PSG+5. Spaces separate a command’s arguments, so an expression with
spaces must be in parentheses, as in xram:(XRAM_PSG + 5). See Symbols and
expressions.
Text is UTF-8, and so is what wait and expect search, converted
from the machine’s code page as it is written. stdout and stderr
are the bytes the program wrote, before the console puts a carriage return
in front of each newline, so a line the program prints ends in \n.
console is what reaches the screen: those bytes with the carriage
returns, the line editor’s echo of what is typed, and the system’s own
messages. An escape from \x80 to \xFF is a byte of UTF-8, so
"caf\xC3\xA9" is "café".
wait "Colossal Cave Adventure"
wait "Would you like instructions?"
type "no\r"
wait "standing at the end of a road"
type "take lamp\r"
wait "I see no lamp here"
Command |
Description |
|---|---|
|
Let exactly that many frames elapse, one VSYNC each. Default 1. |
|
Run until it appears on that stream, stdout if none is named. |
|
Run until that byte reads that value. The byte is read once a frame, at the boundary. |
|
Run until the program exits. |
|
Type it. |
|
End the program’s stdin, the way a piped |
|
Send the bytes a terminal sends for that key. See Key Names. |
|
Set and clear bits in the HID bitmap a program reads. Each key is a name or a keycode. See Key Names. |
|
Toggle a lock LED. |
|
Attach or detach one of four gamepads, optionally saying how its buttons are labeled. |
|
|
|
Each -128 to 127. |
|
Each 0 to 255. |
|
Work the mouse. The mask is one bit per button, 0 left, 1 right, 2 middle, 3 back, 4 forward. |
|
Work the absolute pointer, including multi-touch up to eight
contacts. The buttons are the same bits as the mouse. A pointer
placed with |
|
Check the stream since its last match, stdout if none is named. A match consumes up to and including it. Each stream is checked separately. The line editor’s echo of typed text is only on the console, but a program that echoes its own input writes that echo to stdout. |
|
Check the exit code of the program. The check fails while the
program runs, so put |
|
Drop what the program printed on that stream after the last match,
or on all three streams without a stream name. |
|
Compare memory. |
|
Write memory. The program reads what you wrote, so a test can skip ahead to the state it needs to exercise. Each byte goes to the next address. |
|
Print memory as hex. |
|
Print the status of the machine on one line. |
|
Print the screen as a CRC-32. |
|
Remember the screen, then check it against what you remembered. |
|
Write the screen. |
|
Run that many frames, default 1, and record their sound as 16-bit
stereo at 48 kHz. With |
|
Save the whole machine to a file, and load it back. |
|
Install a file on the null drive as |
|
Boot a program. The machine must be stopped, since loading writes
the memory a running program is using. The new program’s |
|
Set the seed of the random numbers that the program draws, 0 to
4294967295. The program gets the new stream at its next draw, and a
program can draw in its first frame. To give a program the stream from
its start, set the seed before the first command that runs frames: on
the first line, or just before or after |
|
Start the machine, stop it, or interrupt it the way a break at the console would. |
|
Answer every command on stdout. See Driving it from a program. |
|
Set a variable that later lines can use in numbers. See Symbols and expressions. |
An address is in the 6502’s address space, RAM at $0000-$FEFF, the VIA
at $FFD0-$FFDF and the RIA at $FFE0-$FFFF, unless xram: puts it in
XRAM. ram: is the same as no prefix. The part after the prefix can be
an expression, as in xram:XRAM_PSG+5. Nothing answers at
$FF00-$FFCF, so an address there is an error. peek, dump and
wait find a register as a read would, without what the read does,
so RX keeps its byte, XSTACK keeps its top and IRQ keeps its flags.
poke sets what a read finds and does nothing else. With bus:,
poke stores the byte the way the program does, so a register does
what a store to it does: poke bus:$FFE1 $41 sends an A to stdout.
xbus: writes XRAM the way RW0 and RW1 write it, so the PSG or OPL
hears a byte written to its block, which poke xram: changes without
telling it.
status prints these fields in this order, each as NAME=value, with
a space between fields. status leaves out a field that does not apply,
so check for a field before you read it.
Field |
Value |
|---|---|
|
The 6502’s registers. A frame can end partway through an instruction, so PC may be past its first byte. |
|
The RIA’s OP and BUSY registers. |
|
1 while the 6502 runs, and 0 while the RIA holds it in reset. |
|
The exit code of the program. It appears after the program exits,
but not after |
|
The seed of the random numbers that the program draws, from the
last |
|
|
|
The path of the ROM that runs, or that ran last. It is always the last field, and its value runs to the end of the line, so a path with spaces needs no quotes. |
When a check fails, the emulator prints two lines on stderr: the script, the
line, and the reason, then the fields of status. To repeat the failed run,
put seed <n> with the SEED value first in a script that sets no seed.
Then run the script with --memseed and the MEMSEED value, or with
--memfill and the MEMFILL value. A script that runs frames before its
first seed cannot repeat the draws before that seed. Then the emulator
prints to stdout what the program printed after the last match, in UTF-8, and
exits 1. Once that output reaches 64 KB, the emulator keeps only the newest
part, so it can start partway through a line. The program’s stderr goes to
stderr as the program writes it, all through the run.
A program must treat RAM and XRAM as uninitialized, because they hold unknown
values until the program writes them. To help find a program that reads memory
before writing it, the emulator fills RAM and XRAM from a new random seed on
every run, so a program that gets lucky with an uninitialized value on one run
is not lucky on the next. For a test, --memseed repeats the fill, and
--memfill fills memory with a known byte, such as --memfill 00. No seed
controls the clock, the files on SAVE:, or a program that seeds its own
random numbers.
Key Names¶
A <key> is one name out of one list, whichever command reads it.
Letters are a to z, digits are 0 to 9, function keys are
f1 to f12, and keypad digits are kp0 to kp9. The rest have
a name of their own, because only letters and digits are written as
themselves. Case does not matter.
Group |
Names |
|---|---|
Typing |
|
Navigation |
|
Punctuation |
|
Locks and system |
|
Keypad |
|
Modifiers |
|
press and release take any key, because they set and clear bits
in the HID bitmap. They also take a keycode from 4 to 255 in place of a
name, written as 0x2C, $2C or decimal. These are the keyboard
usage codes from the USB HID specification, not PS/2 scancodes, and bit N
of the bitmap is the key with keycode N. A bare single digit is the digit
key rather than a keycode, so press 4 is the 4 key and press $04
is the a key.
key sends what a terminal sends, so it takes the keys that type a
character and the keys that have an escape sequence. +shift types the
shifted character, +alt prefixes ESC, and +ctrl sends the control
byte, which makes key c+ctrl Ctrl-C and key leftbracket+ctrl an
ESC. Ctrl with a digit from 2 to 8, with Space or with / sends what xterm
sends, so key 2+ctrl is a zero byte. Those characters are a US keyboard’s, whatever layout the machine is
set to, so that a script sends the same bytes under every layout.
A key that types nothing is an error, which
covers capslock, numlock, scrolllock, printscreen,
pause, menu and the modifiers. So is a +ctrl on a key that has
no control byte, such as key 1+ctrl.
Symbols and expressions¶
A .sym file beside the ROM, with the same name, lists the program’s
addresses and constants by name. Every build of a project made with the
SDK writes one. Each line is a name, one space and a whole number:
XRAM_DATA_TAB 0x1100
XRAM_PSG 0x1200
A script can use these symbols wherever a command takes a number. The
emulator reads them from the .sym of the ROM that boots, and load
replaces them with the new program’s. Without a .sym there are none.
A name on two lines is ambiguous, and a script fails only when it uses
that name.
Spaces separate a command’s arguments, so an expression with spaces must
be in parentheses, as in poke xram:(XRAM_PSG + 5) ($80 | 3). Without
spaces, as in poke xram:XRAM_PSG+5 $83, no parentheses are needed.
Where an argument could be a keyword, the emulator reads it as the
keyword, so a name in that place needs parentheses. C programs have a
global named stdout, so wait stdout "done" waits for text and
wait (stdout) 1 waits until the byte at stdout is 1. In
press and release the emulator reads key names and single digits
as keys, so press 4 is the 4 key and press (4) is keycode 4.
let name = expr sets a variable, and later lines can use the variable
in numbers. The rest of the line after = is the expression, so it
needs no parentheses even with spaces. A symbol can’t be set. load
keeps the variables, and a variable whose name is also in the new
.sym becomes ambiguous.
Expressions use whole numbers only. Expressions follow Python 3’s rules for whole numbers, so an expression has the same value as in Python, or it is an error. The list below shows where these rules differ from C.
Numbers are written as in Python, or with
$for hex:255,0xFF,$FF,0b1111_1111and0o377. Digits can be separated with_. A number can’t start with 0 unless it is 0, so010is an error, not octal as in C. Write0o10for octal. A float such as1.5or2e3is an error.The operators, from lowest to highest precedence, are
or,and,not, the comparisons< <= > >= == !=,|,^,&,<< >>,+ -,* // %, the unary- + ~, and**.**raises to a power, so2 ** 8is 256, and-2 ** 2is -4. Unlike C,| ^ &have higher precedence than the comparisons, sox & 1 == 0means(x & 1) == 0./is not allowed, because in Python it makes a float, even for6 / 2. Divide with//, which is integer division. With a negative number it differs from C:-7 // 2is -4 where C gives -3, and-7 % 2is 1 where C gives -1. A negative power such as2 ** -1makes a float, so it is not allowed either.A comparison is 1 or 0.
1 < x < 5means1 < x and x < 5, not(1 < x) < 5as in C.notis C’s!with a lower precedence, sonot x + 1means!(x + 1).a or bis C’sa ? a : b, so0 or 3is 3.a and bis C’sa ? b : a, so2 and 3is 3. In C,0 || 3and2 && 3are both 1.Whole numbers are 64-bit and signed. A result that doesn’t fit in 64 bits is an error. In C, signed overflow is undefined.
A name is a letter or
_, then letters, digits,_and., so an llvm-mos static such asdraw.imageis one name. Names are case-sensitive.There are no strings, function calls, casts,
?:,&&,||or!. Writeandfor&&,orfor||,notfor!, and 1 and 0 for true and false.
A failure message shows the expression as it is written in the script,
without the xram:, xbus:, bus: or ram: in front and
without parentheses around all of it. For let it is the expression
after =.
Failure |
Why |
|---|---|
|
No symbol or variable has that name. |
|
The |
|
A symbol can’t be set. |
|
The expression has a |
|
The expression has a number with a point or an exponent, or a
negative power such as |
|
A |
|
A number or a result doesn’t fit in 64 bits. |
|
A |
|
The expression has a syntax error, or something that expressions
don’t have, such as |
|
A |
With reply on, as in Driving it from a program, a driver that
starts rp6502-emu --memfill 0 --script - on a program whose .sym has
XRAM_DATA_TAB writes:
reply
let base = XRAM_DATA_TAB
peek xram:base+4 $00
poke xram:(base + 6 // 2) $01
peek xram:(base + 6 / 2) $01
and reads:
ok
ok
ok
ok
fail base + 6 / 2: floats are not allowed, use //
PC=$0000 A=$00 X=$00 Y=$00 SP=$00 P=$02 OP=$00 BUSY=$00 RESB=1 SEED=1556577197 MEMFILL=$00 ROM=FS:/home/user/game/build/game.rp6502
Driving it from a program¶
reply turns on one line of answer per command — ok, ok <values>
for dump, crc, and status, or fail <why>. It is off
until asked, so a driver writes its whole preamble without waiting for
anything and reads the ok for reply itself as the moment the
machine starts answering.
In a project made with the SDK, rp6502.Emu in
tools/rp6502.py is such a driver. See Testing.
An answer comes when the command finishes, not when it parses. The
ok for run 600 arrives six hundred frames later, and the one for
wait $0200 $07 arrives when that byte reads 7.
reply -> ok
run 60 -> ok (sixty frames later)
pad 0 connect -> ok
pad 0 press start -> ok
run 10 -> ok
dump xram:$FF00 4 -> ok 80 00 00 08
peek xram:$FF00 $99 -> fail $FF00+0 is $80, expected $99
PC=$0307 A=$30 X=$01 Y=$00 SP=$F7 P=$86 OP=$01 BUSY=$00 RESB=1 SEED=513216670 MEMSEED=3088541612 ROM=FS:/home/user/game/build/game.rp6502
A fail ends the run, so there is no asking status after one.
Instead, the line after the fail line is the status line. Everything
after it, up to the end of stdout, is what the program printed after the
last match, in UTF-8. The program’s stderr is on stderr.
This driver never answers the question. It writes:
reply
wait "Would you like instructions?"
run 60
status
wait "end of a road" 60
and reads:
ok
ok
ok
ok PC=$FFF1 A=$16 X=$00 Y=$00 SP=$F1 P=$25 OP=$16 BUSY=$FE RESB=1 SEED=1215796064 MEMSEED=2788390211 ROM=FS:/home/user/adventure.rp6502
fail timed out waiting for "end of a road"
PC=$FFF3 A=$16 X=$00 Y=$00 SP=$F1 P=$25 OP=$16 BUSY=$FE RESB=1 SEED=1215796064 MEMSEED=2788390211 ROM=FS:/home/user/adventure.rp6502
>
PC is in the RIA’s spin loop, BUSY says an API call is running, and OP
$16 is READ_XSTACK, the call under read(). The last three lines are
the program’s stdout after the question: two empty lines and the >
prompt where it waits for the answer.
Arithmetic in a script gives the same answer as Python, so a driver can do the math either way. Loops, decisions and anything beyond arithmetic belong in your driver program.