The mental model
An emulator consists of a struct holding the machine's state and a loop that advances it. For CHIP-8, that state fits in a dozen lines:
pc: u16 // program counter, starts at 0x200
i: u16 // index register (addresses, not values)
v: [u8; 16] // V0..VF — VF doubles as the flag register
ram: [u8; 4096] // font at 0x50, program from 0x200
stack: [u16; 16] // return addresses only, no frames
sp: u8
delay: u8 // both tick down at exactly 60 Hz
sound: u8 // beeps while nonzero
fb: [bool; 64*32]// naive form; §04 packs this to [u64; 32]
keys: [bool; 16] // hex keypad, not a keyboard
The loop executes three steps: fetch, decode, and execute. Read two bytes at pc (every instruction is 2 bytes, stored high byte first), advance pc by 2, split the 16 bits into four nibbles, and match. Most bugs in this project come down to incorrect decoding or wrong timing.
The font, verbatim
The font contains sixteen glyphs of five bytes each, totaling 80 bytes. It is conventionally loaded at 0x50, below the program area. Only the high nibble of each byte is used, so each glyph is 4 pixels wide and each byte ends in 0. In binary, the bytes 0xF0, 0x90, 0x90, 0x90, 0xF0 form the digit 0:
0xF0 -> 1111 .... ####
0x90 -> 1001 .... #..#
0x90 -> 1001 .... #..#
0x90 -> 1001 .... #..#
0xF0 -> 1111 .... ####
Instruction FX29 sets the index register I to a glyph's address, which DXYN then reads. The address is FONT_START + (VX & 0x0F) * 5. The mask is required because VX is an 8-bit register. Without masking, VX * 5 can point I to address 1355, nearly 1200 bytes past the 80-byte table and inside program memory. Games use this instruction to draw scores. The table:
0xF0,0x90,0x90,0x90,0xF0, // 0
0x20,0x60,0x20,0x20,0x70, // 1
0xF0,0x10,0xF0,0x80,0xF0, // 2
0xF0,0x10,0xF0,0x10,0xF0, // 3
0x90,0x90,0xF0,0x10,0x10, // 4
0xF0,0x80,0xF0,0x10,0xF0, // 5
0xF0,0x80,0xF0,0x90,0xF0, // 6
0xF0,0x10,0x20,0x40,0x40, // 7
0xF0,0x90,0xF0,0x90,0xF0, // 8
0xF0,0x90,0xF0,0x10,0xF0, // 9
0xF0,0x90,0xF0,0x90,0x90, // A
0xE0,0x90,0xE0,0x90,0xE0, // B
0xF0,0x80,0x80,0x80,0xF0, // C
0xE0,0x90,0x90,0x90,0xE0, // D
0xF0,0x80,0xF0,0x80,0xF0, // E
0xF0,0x80,0xF0,0x80,0x80, // F
Transcribing this table manually makes the sprite format clear: each set bit represents a lit pixel, which is the mechanism DXYN relies on.
Three independent clocks
CHIP-8 has three separate clock rates that must not be coupled:
- CPU execution runs at roughly 500–700 instructions per second. This rate was never standardised, so it should be configurable.
- The delay and sound timers decrement at 60 Hz regardless of CPU speed. Games rely on the delay timer for wall-clock timing, so coupling it to instruction cycles will cause games to run at the wrong speed across platforms.
- Display refresh occurs at 60 Hz. On original hardware,
DXYNblocked until vblank, which is handled as a quirk (see §02).
A standard loop structure uses a 60 Hz timer tick that executes N CPU cycles, decrements both timers by one, and presents the frame. On desktop, N ≈ 10 produces 600 Hz. On the RP2350, a hardware timer alarm drives that outer tick.
Isolating the core
Keep windowing, printing, and std dependencies out of the emulator core. The core should receive inputs through methods and expose its framebuffer through a borrowed slice. This separation allows the same crate to compile for desktop, WebAssembly, and the RP2350 without changes. Adding no_std compatibility from the beginning avoids major refactoring later.
Suggested reading before you start
This is optional if the model above is familiar. Readers who want a concrete grounding in registers, buses, and instruction decoding will find it in Petzold's text.
Reading through the CPU construction chapters provides helpful context for M1, where binary data is first decoded into mnemonics.
CHIP-8 is a bytecode virtual machine created by Joseph Weisbecker in 1977 for the COSMAC VIP microcomputer, implemented as an interpreter on the RCA 1802. Writing a CHIP-8 emulator means writing an interpreter for a virtual machine rather than modeling hardware components. It lacks hardware interrupts, memory mapping, a bus cycle model, and dedicated video processors. Later projects like the Intel 8080 or Game Boy introduce those subsystems.
Core reading
Six references and two community tools. Read Langhoff before writing code, and consult Cowgod and Mikolay during implementation.
8XY6/8XYE) and register store/load (FX55/FX65) describe SUPER-CHIP behavior rather than the original COSMAC VIP.DXYN behavior. Helpful background once your core passes the initial test ROMs.The quirks and test suites
CHIP-8 implementations diverged between 1977 and 1990 as new interpreters altered instruction behaviors. Software written for one platform frequently fails on another, meaning an emulator that decodes every opcode correctly can still fail to run specific games due to behavioural differences.
Six behaviours account for most compatibility issues. Store each as a bool field in a configuration struct early in development to avoid refactoring later.
| Quirk | Original COSMAC VIP | SUPER-CHIP / modern | Symptom if wrong |
|---|---|---|---|
vf_reset8XY1/2/3 |
OR/AND/XOR also reset VF to 0 | VF left untouched | Subtle logic corruption; some games hang |
memoryFX55/FX65 |
I is incremented by X+1 | I unchanged (or +X on CHIP-48) | Save/load of register blocks reads garbage |
display_waitDXYN |
Blocks until vblank — caps drawing at 60 sprites/sec | Draws immediately | Games run absurdly fast; heavy flicker |
clippingDXYN |
Starting coords wrap (x%64, y%32), then the sprite clips at the right and bottom edges |
XO-CHIP and many modern emulators wrap the overhanging pixels to the opposite edge instead | Sprites tear at the edges, or smear across to the far side |
shifting8XY6/8XYE |
VX = VY shifted | VX shifted in place, VY ignored | Very common; breaks lots of games silently |
jumpingBNNN |
Jump to NNN + V0 | Read as BXNN: jump to XNN + VX | Wild jumps into data; instant crash |
github.com/Timendus/chip8-test-suite provides ROMs that render visual pass/fail output directly to the screen once DXYN is functional. The suite contains eight ROMs, numbered in execution order: 1-chip8-logo, 2-ibm-logo, 3-corax+, 4-flags, 5-quirks, 6-keypad, 7-beep, and 8-scrolling (the last requires SUPER-CHIP or XO-CHIP support). Running 1-chip8-logo first is useful because it omits 7XNN and requires only byte-aligned sprite drawing. Running these ROMs headlessly in cargo test by stepping a fixed number of cycles and comparing a hash of the framebuffer provides reliable regression testing.
Implementation details
- The COSMAC VIP used a 4×4 hexadecimal keypad laid out
1 2 3 C / 4 5 6 D / 7 8 9 E / A 0 B F. Standard emulator implementations map these rows to1234 / QWER / ASDF / ZXCVon modern keyboards. Games assume the physical key positions, so mapping key codes purely by digit label produces unintuitive controls. - The wait-for-key instruction
FX0Ahalts execution until an input event occurs. The standard approach decrementspcby 2 so the opcode repeats until satisfied. On original hardware, execution resumed on key release rather than initial keypress. - Drawing with
DXYNsets VF to 1 if any active pixel is cleared by the XOR operation, and 0 otherwise. Games use this mechanism as their primary collision detection. - Sprites have a fixed width of 8 pixels and a variable height from 1 to 15 rows. Each row is defined by one byte, with the most significant bit mapped to the leftmost pixel.
- The stack stores only return addresses, with no concept of stack frames or arguments. Modern implementations generally support 16 levels, whereas the original VIP interpreter reserved space for 12. Opcode
2NNNpushes the return address, and00EEpops it.
Build order
This sequence introduces debugging checkpoints at each milestone. Building verification tools before implementing the full opcode set helps isolate decode and timing errors early.
Load and disassemble
Load ROM bytes into ram[0x200..], copy the font table into 0x50, and write a disassembler that prints addresses, raw opcodes, and mnemonics without executing instructions.
Done when the disassembler produces readable assembly for 2-ibm-logo.
Splash screen rendering
Implement five instructions to run 1-chip8-logo: 00E0, 1NNN, 6XNN, ANNN, and DXYN. Next add 7XNN and horizontal bit-shifting for unaligned sprites in 2-ibm-logo. Output the framebuffer to the terminal using # characters. A simple [bool; 2048] array works at this stage; §04 covers packing to [u64; 32].
Done when both 1-chip8-logo and 2-ibm-logo display correctly in terminal output.
Remaining instructions
Implement the rest of the 35 standard opcodes, including subroutine handling (2NNN, 00EE), binary-coded decimal conversion (FX33), and font indexing (FX29). Verify execution against 3-corax+ and 4-flags.
Done when 3-corax+ reports all passes and 4-flags confirms correct VF handling across arithmetic and logic opcodes.
Quirk configuration
Add configuration toggles for the six behaviours detailed in §02, defaulting to original COSMAC VIP behavior. Verify both VIP and modern profiles with 5-quirks.
Done when 5-quirks passes under both COSMAC VIP and SUPER-CHIP configurations.
Input, timers, and audio
Implement the hex keypad mapping, decouple timer decrements to a stable 60 Hz tick, gate a square-wave audio tone on the sound timer, and connect a desktop display window.
Done when playable games like Brix or Pong run at stable speed with responsive input and audio feedback.
Platform extensions or embedded port
Choose an extension path: add SUPER-CHIP and XO-CHIP instructions, or port the core to the RP2350 microcontroller described in §05.
Done when the selected extension passes its test suite or runs standalone on the target hardware.
Diagnostic tools
A ring buffer recording recent (pc, opcode) pairs helps diagnose crashes by dumping trace history on panic or error states. Adding single-step execution and register inspection to the desktop frontend during M3 simplifies tracking bugs before running on microcontroller targets.
Rust workspace architecture
A three-crate Cargo workspace isolates platform-independent emulation logic from platform-specific I/O:
chip8/
├── chip8-core/ #![no_std], zero I/O, all the logic
├── chip8-desktop/ winit + pixels (or minifb)
└── chip8-pico/ #![no_std] + #![no_main], rp235x-hal or embassy-rp
The public API for the core crate requires only a minimal surface:
impl Chip8 {
pub fn new(cfg: Quirks) -> Self;
pub fn load_rom(&mut self, bytes: &[u8]);
pub fn step(&mut self); // one instruction
pub fn tick_timers(&mut self) -> bool; // true = beep
pub fn set_key(&mut self, k: u8, down: bool);
pub fn framebuffer(&self) -> &[u64; 32]; // 1 bit per pixel
}
Implementation choices
- Instruction decoding using
match (a, b, c, d)on the four nibbles is clearer than nestedifbranches, and the compiler flags unreachable patterns automatically. - Representing the display as
[u64; 32]stores oneu64per row, matching the 64-pixel width. XOR drawing for an 8-pixel sprite row becomes a bit shift and XOR operation. Collision detection evaluates asold & shifted_sprite != 0before modifying the row. This layout occupies 256 bytes instead of 2048 bytes for a boolean array. - Because the core is
no_std, opcodeCXNNcannot call standardrandfunctions that depend on an operating system entropy source. Instead, provide a deterministic generator inside theno_stdcore, such as a lightweight xorshift seeded by the caller. Avoidingrandor depending on an older release likerand 0.7prevents pulling in unexpected dependencies. Microcontrollers like the RP2350 have a hardware TRNG, but abstracting RNG generation behind the core's API keeps the crate portable. - Arithmetic operations require explicit overflow handling. Opcode
7XNNwraps without modifying VF, while8XY4sets VF on carry. Usingwrapping_addandoverflowing_addprevents debug-build overflow panics and ensures consistent behavior. - Testing the core does not require a window or an event loop. Unit tests can load raw
u16opcode sequences into RAM, instantiate aChip8struct, and callstep(), asserting on register and flag states directly.
Desktop frontends
The aquova guide uses sdl2, which requires linking external SDL2 development libraries. Alternatives include minifb, which manages a window and framebuffer without external C dependencies on macOS or Windows (though Linux requires libx11-dev), or pixels combined with winit for hardware-accelerated presentation.
The Pico 2 track
The RP2350 is a genuinely nice target for this: dual Cortex-M33 at 150 MHz with 520 KB of SRAM, against a machine that needs 4 KB of RAM and 256 bytes of framebuffer. You are not resource-constrained — you have roughly 100× the headroom the original hardware had. What the port actually teaches you is whether your core/frontend boundary was real.
Pick a HAL
| Option | Style | Take |
|---|---|---|
rp235x-hal 0.4rp-rs/rp-hal |
Blocking, bare-metal, embedded-hal traits |
You see the peripherals. Best for learning what the chip actually does. Now on crates.io — earlier write-ups telling you to vendor the repo are out of date. |
embassy-rp 0.9feature rp235xa |
async executor, batteries included |
More ergonomic, great timer and USB story, and the 60 Hz tick becomes a one-line Ticker. Slightly more magic between you and the silicon. |
Either works. If the goal is understanding, start with rp235x-hal; if the goal is a working handheld, embassy will get you there faster.
The hardware decisions
- Display: an SSD1306 128×64 I²C OLED is the natural fit — exactly 2× CHIP-8's resolution in both axes, so every CHIP-8 pixel is a 2×2 block and there is no scaling arithmetic at all. Drive it with the ssd1306 crate over embedded-graphics. If I²C's refresh rate annoys you, an ST7789 SPI LCD is much faster and gives you colour for a HUD.
- Input: 16 keys. A 4×4 tactile matrix scanned across 8 GPIOs is the honest version and matches the VIP's real layout. A capacitive TTP229 or MPR121 board is the low-effort version. A PCF8574 I²C expander saves pins if you're short.
- Sound: PWM to a piezo. Set a square wave on a PWM slice and gate the output on
sound_timer > 0. Ten lines, and it's the first time the emulator feels like a device. - ROMs:
include_bytes!a handful into flash for the first version. A menu reading from SD via embedded-sdmmc is the natural second version.
Timing on bare metal
Do not build your loop out of delay_ms. Use the hardware timer to fire a 60 Hz alarm; on each fire, run your N CPU cycles, decrement the timers once, and push the framebuffer to the display. With two cores available you can put the display flush on core 1 and keep the interpreter on core 0, though for CHIP-8 that's a luxury rather than a necessity.
Embedded Rust prerequisites
no_std basics and the embassy setup, written against the actual board you're considering. The fastest route from "cargo new" to a blinking LED on an RP2350.no_std, cross-compilation, memory-mapped peripherals, the PAC/HAL/BSP layering. Read the first four chapters; skim the rest as needed.Get the desktop version passing the full Timendus suite before you touch the microcontroller. Debugging an interpreter bug and a HAL bug at the same time, through a 128×64 OLED with no logging, is a bad afternoon. The port should be a frontend swap, nothing more — and if it isn't, that's useful information about your core.
The aquova book
An Introduction to Chip-8 Emulation using the Rust Programming Language by aquova contains nine chapters, a three-crate workspace (core, desktop, wasm), and bundled test ROMs. The local repository is at commit e0b3888 from 13 August 2023.
What it gets right
- The crate workspace structure cleanly separates core emulation logic from frontend presentation.
- The material targets readers who know Rust syntax but have not written an emulator, with full implementations for each opcode.
- The WebAssembly chapter provides a functional browser build using the same core crate.
What's stale or missing
| Issue | What to do |
|---|---|
| Rust edition 2018 throughout | Bump to 2021 or 2024; nothing in the code should break. |
rand ^0.7.3 in the core crate | Replace with your own xorshift. Required for no_std; this is the blocker for the Pico port. |
sdl2 ^0.34.3 for the desktop frontend | Bump it, or swap for minifb. Expect to install SDL2 natively (brew install sdl2) and possibly fight linker paths on macOS. |
wasm-bindgen ^0.2.69 / web-sys ^0.3.46 | Years old. Bump both; the APIs used are stable. |
| No quirks discussion at all | The real gap. Read §02 here and Langhoff's guide alongside chapter 5. |
| No test ROMs / no test harness | Add the Timendus suite yourself at M3. Don't skip this. |
Core is std, not no_std | Add #![no_std] on day one. Shorter than you'd think, but see the BCD row — it is not zero. |
Font loaded at 0x000, and FX29 is a bare c * 5 (lib.rs:60, lib.rs:390) | The book does not use the conventional 0x50 base. If you follow this guide's layout but the book's FX29, I points at uninitialised memory and scores render as garbage. Pick one convention. Either way add the & 0x0F mask, which the book also omits. |
BCD (FX33) uses f32 and .floor() (lib.rs:395-402) | Float methods don't exist in core, so this is the one thing that will break the moment you add #![no_std]. Rewrite with integers: vx / 100, (vx / 10) % 10, vx % 10. |
Use chapters 1–6 as a structural guide while referencing Langhoff for opcode semantics and the Timendus test ROMs for validation. Implementing the core crate as #![no_std] and removing external rand dependencies early will avoid refactoring during the microcontroller port.
More angles
Three additional resources provide useful context outside standard CHIP-8 specifications:
After CHIP-8
This guide covers simple interpretive execution, where an emulator decodes and executes instructions sequentially. While standard for CHIP-8 and introductory projects, other CPU emulation techniques exist along a performance spectrum, including direct-threaded dispatch, dynamic recompilation, and just-in-time (JIT) compilation. High-level emulation (HLE) forms another approach, replacing lower-level hardware execution with native reimplementations of system API calls.
Common subsequent hardware projects, ordered by complexity:
- Intel 8080 arcade hardware (such as Space Invaders, documented at emulator101.com) introduces real hardware features: paired 8-bit registers, condition flags, dedicated I/O ports via
INandOUT, memory-mapped video RAM, and display interrupts. Instruction cycle timing becomes necessary for correct game speed. - The Game Boy (DMG) is a common second project. Documentation is centralized at Pan Docs with community tools at gbdev.io and comprehensive test ROMs by Blargg and Mooneye. Implementing the Sharp SM83 CPU core is straightforward, while accurately modeling the Pixel Processing Unit (PPU) scanline timing requires significantly more effort.
- The Nintendo Entertainment System (NES) introduces cartridge memory mappers and strict Picture Processing Unit (PPU) timing requirements. Technical reference material is maintained on the NESdev Wiki alongside the javidx9 video walkthrough.
The r/EmuDev subreddit and its Discord community maintain the emudev.org documentation and provide active technical discussion for verifying edge-case hardware behaviors and test results.