potatis: a MOS-6502 core in Rust with five ways to run it
🥔 MOS-6502 and NES emulator in Rust (SDL/WebAssembly/Android/Embedded/Cloud)
At a glance
- What is it?
- potatis is a Rust workspace whose centre is a MOS-6502 emulator that passes the functional tests including illegal opcodes, with a deliberately incomplete NES emulator built on top and front ends for SDL, WebAssembly, Android, a Raspberry Pi Pico and a netcat cloud terminal. The CPU crate is the finished part, and the honest way to read the project is as an emulator study with a debugger rather than a playable NES.
- Who is it for?
- potatis fits a developer who wants to read a 6502 emulator, build a debugger on top of one, or run a 6502 on a microcontroller with 264kB of RAM, and it does not fit someone who wants to play NES games, since the machine is incomplete with no APU and only five mappers.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 136 days ago.
- What is it written in?
- Mainly Rust, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
A finished CPU core under a deliberately unfinished NES
Potatis is a MOS-6502 and NES emulator in Rust, and the README splits it into crates that make the unevenness obvious.
The /mos6502 crate is described as a generic CPU emulator that passes all the tests, including illegal opcodes, with the single exception of BCD mode. The /nes crate is described as a very incomplete NES emulator. That contrast is the project in one sentence: the processor is done, the machine around it is not.
The NES part supports five mappers, NROM as mapper 0, MMC1 as mapper 1, UxROM as mapper 2, CNROM as mapper 3 and MMC3 as mapper 4, and the TODO section at the end of the README lists more mappers and the APU as the remaining work. An NES without an APU is a NES with no sound and no music, which is a real limitation rather than a missing extra.
The target platforms are the reason the project is interesting to look at. There is a native SDL target, a browser target compiled to WebAssembly, a cloud target, an embedded target for the RP-2040 Raspberry Pi Pico, and an Android target built through JNI. One CPU core, five ways to present it, and the constraint on each is different.
The licence is MIT and the primary language is Rust, with the workspace organised so that the core is a plain library with no dependency on any of the front ends.
The CPU crate comes with a debugger attached
The mos6502 crate is used directly, as a library, in three steps. You load a program into memory at a base address, wrap it in a Cpu, and hand that to a Mos6502 machine that you tick.
let load_base = 0x2000;
let mem = Memory::load(&program[..], load_base);
let cpu = Cpu::new(mem);
let mut machine = Mos6502::new(cpu);
loop {
machine.tick()
println!("{}", machine); // Prints nestest-like output
}The formatting of the machine is the nestest style, the standard functional test output format for the 6502, which is why the test credits list includes the amb5l functional tests, the TTL6502 binary test and the nesdev forum threads.
The debugger is the part that goes beyond an emulator core, and it is what makes this crate reusable.
let mut debugger = machine.debugger();
debugger.verbose(true); // Trace, dumps disassembled instructions to stdout
debugger.add_breakpoint(Breakpoint::Address(0x0666));
debugger.add_breakpoint(Breakpoint::Opcode("RTI"));
debugger.watch_memory_range(0x6004..=0x6104, |mem| {
// Invoked when memory in range changes
});Breakpoints can be set on a memory address or on an opcode by name, so you can stop on a return from interrupt wherever it appears, and a memory range can be watched with a callback that fires when the contents change. For anyone writing a debugger, a test harness or a custom machine on top of the 6502, that is the interesting surface of the project, far more than the NES part.
The NES crate is a library with a host interface, not a game
The nes crate does not draw anything or read a keyboard. It defines a host interface that you implement, which is what lets the same emulator sit behind an SDL window, a browser canvas, a phone or a microcontroller.
impl nes::HostPlatform for MyHost {
fn render(&mut self, frame: &RenderFrame) {
// frame.pixels() == 256 * 240 * 3 RGB array
}
fn poll_events(&mut self, joypad: &mut Joypad) {
// pump events and forward to joypad
}
}The frame is the native NES resolution of 256 by 240 pixels with three channels of RGB, and events are pushed into a joypad that the emulated machine reads. The host therefore owns timing as well as display, which is the right split when the front end is a browser that wants to render on request rather than at 60 frames a second.
A cartridge is loaded from a ROM file and inserted into a machine with a host.
let cart = Cartridge::blow_dust("path/to/rom.nes")?;
let mut nes = Nes::insert(cart, MyHost::new());
loop {
nes.tick();
println!("{:?}", nes); // Complete nestest formatted output
}The default key bindings are WASD for the direction pad, K for B, L for A, space for select, enter for start and R for reset, which is a keyboard layout rather than a gamepad layout.
So the crate gives you a machine, a mapper set and a debuggable CPU, and leaves rendering, input and sound to you. Since the APU is on the TODO list, the sound is yours to write or to leave out.
Six front ends, each with its own constraint
The value of the workspace is that the same core is driven in five very different environments, and each one exposes a different limit.
The SDL target is the plain one. You run it with cargo run --release path/to/rom.nes, and cargo run -- --help lists the options.
The browser target is built with wasm-pack, with the build run in release mode against the web target, followed by an npm install and an npm run dev to serve it.
A built version is hosted at henrikpersson.github.io/nes, so the browser path can be looked at without building anything.
The Android target needs the NDK and a rustup target, and a linker configured per target in the cargo config.
[target.aarch64-linux-android]
linker = "$NDK_PATH/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android33-clang"The install script is invoked with cd nes-android &&./install.sh release, and the README notes that it only targets the arm64-v8a ABI.
The embedded target is the one with the most interesting numbers. It runs on an RP2040 with only 264kB of RAM available, and the README reports that without optimisation it started at about 0.5 frames per second, and that after some overclocking and offloading the display rendering to the second CPU core it reaches a steady 5 FPS. Heap usage is 135kB single core, and 243kB multi core with two frame buffers, on a display driven by an ST7789, with a second Pico wired up as the SWD debugger and flasher.
cd nes-embedded
ROM=/path/to/rom.nes cargo run --releaseFive frames per second is not a playable console, and the README is not pretending otherwise. It is a demonstration of how little a 6502 needs, and the numbers given are the interesting part.
The cloud target streams a frame over netcat
The nes-cloud target is described as NES-as-a-service, clientless cloud gaming with netcat and terminal rendering, and the README is upfront that this is the next big thing.
The client is a single line.
stty -icanon && nc play-nes.org 4444The stty call disables terminal input buffering so keystrokes go straight to netcat, and the README explains that you can connect without it but then have to press enter after each key.
The same transport can carry a ROM from your own machine.
stty -icanon && cat zelda.nes - | nc play-nes.org 4444Three rendering modes are offered on separate ports. Sixel on port 6666 is the recommended one if the terminal supports it, and iTerm2 is named as an example. Unicode colour on port 5555 draws the screen with the upper half block character, and since the lower half of the character is transparent, ANSI colour codes can set the foreground and background to draw two lines at once, with the caveat that the frame is still too large for a normal terminal window unless the font size is reduced a lot. ASCII on port 7777 drops the colour and the unicode and computes luminance per pixel, with the same font size caveat.
This is the part of the project that is most fun to try and the part with the most external dependency, since it needs a server, a network round trip per frame and a terminal that speaks Sixel. A cloud client that renders through a remote host is also a different security and latency story from running the same core locally.
The workspace keeps the awkward crates out of the build
The Cargo.toml shows a deliberate split in what is built together. The workspace members are common, mos6502, nes, nes-sdl, nes-wasm and nes-android, and the excludes list nes-embedded, nes-cloud and profile. So the two targets with unusual requirements, the microcontroller build and the cloud server, are not part of the ordinary workspace build, presumably because they need their own toolchain or dependencies.
The profiles are also set for a reason. The test profile uses opt-level 3 with a comment explaining that otherwise the integration tests take too long, which is an optimisation of the test run rather than of the library. There is a release-with-debug profile that inherits release and turns on debug information, which is the profile you want when you are profiling or stepping through a machine in a debugger.
The rest of the tree supports the same two concerns. There is a test-roms directory and a .gitmodules file, so the test ROMs that the emulator is verified against arrive as a submodule rather than being committed, and the profile directory with profile_mem.sh and profile_perf.sh holds the memory and performance measurement scripts that explain the heap figures in the embedded section. A common directory holds code shared by the front ends, and screenshots holds the images used in the README.
Running the whole test suite is a single cargo test invocation from the workspace root.
And the README's thanks list is a good map of the reference material the implementation was checked against, covering nesdev, the masswerk 6502 instruction set, the 6502_65C02 functional tests, the TTL6502 binary test, the nesdev header format, the nes test ROMs, the loopy PPU document and a set of forum threads.
MIT terms, no releases, and an emulator that is openly unfinished
The licence is MIT, with the LICENSE file at the top of the repository, and there are no GitHub releases for the project at all. The last push was on 2026-06-10 and the repository is not archived, so the work continues without being cut into tagged versions.
That is the right shape for a project whose components have different lifecycles. The CPU core changes rarely and is validated by the functional tests, the NES crate changes when a mapper or an APU appears, and the front ends change when a platform's toolchain moves. Tagging the whole workspace would say less than the individual crates do.
The honest limitation is the one the README states itself. The NES emulator is incomplete, with no APU and five mappers, and the TODO list is short rather than aspirational. If you want to play NES games, this is the wrong project, and a mature emulator with a full mapper set, sound and save states is the right tool. If you want to study how a 6502 and a PPU work, or you want to run a 6502 on a microcontroller with 264kB of RAM, or you want to read a debugger implementation, then the incompleteness is the point, because the crate boundaries are legible and the code is small enough to read.
The concrete way to evaluate it is to build the CPU crate and run the functional test, then look at the debugger and the host interface. Those are the parts that are finished, and they are the parts a serious user would depend on.
Editorial conclusion
potatis fits a developer who wants to read a 6502 emulator, build a debugger on top of one, or run a 6502 on a microcontroller with 264kB of RAM, and it does not fit someone who wants to play NES games, since the machine is incomplete with no APU and only five mappers. Before adopting it, build the mos6502 crate, run cargo test to confirm the functional tests pass, and read the nes crate's host interface to see whether you are willing to supply rendering, input and sound yourself.
Frequently asked questions
How complete is the 6502 emulator in potatis?
The README describes the mos6502 crate as a generic CPU emulator that passes all the tests including illegal ops, with the exception of BCD mode. The NES crate on top of it is described as a very incomplete NES emulator with five mappers and no APU, which is on the TODO list.
Which NES mappers does potatis support?
The listed mappers are NROM as mapper 0, MMC1 as mapper 1, UxROM as mapper 2, CNROM as mapper 3 and MMC3 as mapper 4. The README's TODO section lists more mappers and the APU as the remaining work.
How do I build the browser version of potatis?
Change into the nes-wasm directory, run wasm-pack build --release --target web, then npm install and npm run dev. A built version is hosted at henrikpersson.github.io/nes so the browser target can be tried without a local build.
Can potatis run on a Raspberry Pi Pico?
Yes, the nes-embedded target targets the RP-2040 with only 264kB of RAM available. The README reports about 0.5 FPS without optimisation and a steady 5 FPS after overclocking and moving the display rendering to the second core, with heap usage of 135kB single core and 243kB multi core.
How does the potatis cloud target work?
You connect with stty -icanon && nc play-nes.org 4444, and the stty call disables terminal input buffering so keys are sent directly. Rendering is offered as Sixel on port 6666, Unicode colour on port 5555 and ASCII on port 7777, and you can pipe your own ROM into the same port.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/henrikpersson-potatis)Was this useful?