Building a CHIP-8 Emulator in Rust
Some projects teach you a programming language. Others teach you how computers work at a low level. This one does both at the same time.
We are going to build a CHIP-8 emulator from scratch. CHIP-8 is a small virtual machine designed in the 1970s for programming games on early microcomputers. It is the classic first emulator project — small enough to finish, complex enough to be interesting, and well-documented enough that you can focus on learning rather than archaeology.
By the end, you will have a program that runs real CHIP-8 games. Along the way, you will learn Rust from the ground up, understand how a CPU executes instructions, and see exactly what happens between "program starts" and "pixels appear on screen."
What is CHIP-8?
CHIP-8 is not a real chip — it is a specification for a virtual machine. Programs written for CHIP-8 do not run on hardware directly; they run on an interpreter. In the 1970s, that interpreter ran on real machines like the COSMAC VIP. Today, we write the interpreter ourselves.
The specification is intentionally minimal:
- 4 KB of memory — the entire address space fits in what modern computers would call a rounding error
- 16 registers — each holding one byte (0–255)
- 35 opcodes — the complete instruction set fits on one page
- 64×32 pixel display — monochrome, no color, no gradients
- 16-key hexadecimal keypad — the input device
This minimalism is what makes CHIP-8 perfect for learning. There are no device drivers, no memory management, no privilege levels. Just a loop: fetch instruction, decode it, execute it, repeat.
Why Rust?
Rust is a systems programming language that gives you the control of C with the safety guarantees of a modern language. It will not let you read uninitialized memory. It will not let you have two mutable references to the same data at the same time. These constraints feel restrictive at first — then you start to appreciate that the compiler is catching entire categories of bugs before your program ever runs.
Emulators are a natural fit for Rust. They deal with raw bytes, bit manipulation, and fixed-size data structures — exactly the kind of low-level work where Rust shines. And because the emulator is small, you will encounter Rust's most important concepts without being overwhelmed by framework complexity.
How this book is structured
Each chapter introduces a new concept exactly when the implementation needs it. We do not front-load theory. When we need to understand bitwise operations, it is because we are about to decode an opcode. When we need to understand closures, it is because we ran into a compiler error while building the render loop.
The book is divided into eight parts:
- Getting Started — setting up the project with Cargo
- The CPU — modeling the processor as a Rust struct
- Memory — the 4KB address space and how to organize code
- Fetch, Decode, Execute — the CPU loop and our first opcodes
- Data and Arithmetic Opcodes — loading values, doing math
- Control Flow Opcodes — jumps, calls, conditions, and randomness
- The Display — the framebuffer and drawing sprites
- The Main Loop — opening a window and making it run
Every chapter builds on the one before it. The source code for each stage is tested — when something works, we know it works.
Prerequisites
You need Rust installed. The easiest way is via rustup:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
No prior Rust knowledge is required. Basic familiarity with any programming language (variables, loops, functions) is enough to follow along.
Setting Up with Cargo
Before writing any emulator code, we need a project. In Rust, that means using Cargo.
What is Cargo?
Cargo is Rust's build system and package manager — it compiles your code, runs your tests, downloads external libraries, and manages everything in between. Unlike ecosystems where these are separate tools, Cargo does it all.
Creating the project
cargo init chip8-rust
cd chip8-rust
Cargo creates exactly two files:
Cargo.toml — the project manifest
src/main.rs — the entry point
That is the entire initial structure.
Cargo.toml
[package]
name = "chip8-rust"
version = "0.1.0"
edition = "2024"
[dependencies]
- edition — which Rust language standard to use. We use 2024, the latest.
- [dependencies] — empty for now. When we need a graphics library (
minifb) and a random number generator (rand), they go here. See External Crates and Cargo.toml.
src/main.rs
fn main() { println!("Hello, world!"); }
Every Rust binary needs a main function — the compiler looks for it explicitly.
The four commands you will use constantly
cargo build # compile
cargo run # compile and run
cargo test # run all tests
cargo test <name> # run tests matching a name
cargo clippy # lint
cargo fmt # auto-format
Run cargo run now to verify everything works.
In the emulator: We will use
cargo testafter every opcode implementation to verify correctness, andcargo runto open the window and see pixels on screen. The modules section (Modules) covers how we split the project intocpu.rs,memory.rs, anddisplay.rsas it grows.
Structs and impl Blocks
Rust has no classes. The equivalent is a struct for data and an impl block for methods — kept deliberately separate.
The struct
A struct is a named group of fields:
#![allow(unused)] fn main() { pub struct Cpu { pub v: [u8; 16], // general-purpose registers V0–VF pub i: u16, // index register pub pc: u16, // program counter pub sp: u8, // stack pointer pub stack: [u16; 16], // return address stack } }
Each field has a name and a type. pub makes a field visible outside the module — without it, the field is private by default.
The impl block
Methods live in a separate impl block:
#![allow(unused)] fn main() { impl Cpu { pub fn new() -> Self { Cpu { v: [0; 16], i: 0, pc: 0x200, // programs start at 0x200 sp: 0, stack: [0; 16], } } pub fn step(&mut self) { // fetch, decode, execute one instruction } } }
new() is a static constructor — it takes no self. step() takes &mut self because it modifies the CPU state.
&self vs &mut self
This distinction is enforced at compile time:
&self— read-only access to the struct's fields&mut self— read and write access
If a method with &self tries to modify a field, the compiler rejects it. This prevents accidental mutations and is part of Rust's core ownership system.
OOP comparison
| OOP | Rust |
|---|---|
| class | struct + impl |
| constructor | new() (convention, not a keyword) |
| method | function in impl |
this / self | &self or &mut self |
| inheritance | doesn't exist — use Traits |
| private/public | pub per item |
Why separate data from behavior?
It makes each concern independently visible. Looking at a struct, you see exactly what data it holds. Looking at an impl, you see exactly what it can do. You can have multiple impl blocks — useful for putting tests in a separate file — and you can implement traits in their own blocks.
In the emulator:
Cpu,Memory, andDisplayare all structs with their ownimplblocks.Cpuowns aMemoryand aDisplayas fields. The CHIP-8 Registers chapter describes what each field ofCpurepresents in hardware terms. The Constructors chapter explains whynew()is needed forCpuspecifically.
Primitive Types and Arrays
Rust requires you to be explicit about the size of every integer. There is no generic int. This is intentional — it mirrors the actual hardware and prevents silent overflow surprises.
Unsigned integers: u8 and u16
| Type | Bits | Range | Use in the CPU |
|---|---|---|---|
u8 | 8 | 0–255 | V registers, SP |
u16 | 16 | 0–65535 | PC, I, stack entries |
u8 is right for V0–VF because CHIP-8 is an 8-bit machine — each register is literally one byte. If a value overflows 8 bits, it wraps. That is not a bug; that is the hardware.
u16 is required for pc and i because they point into memory, which goes up to address 0xFFF (4095) — beyond what 8 bits can hold.
Signed types
Signed equivalents exist: i8, i16, i32, etc. For hardware emulation, unsigned types are almost always correct — addresses and register values are never negative.
Fixed-size arrays: [T; N]
#![allow(unused)] fn main() { pub v: [u8; 16] // 16 registers, each 1 byte pub stack: [u16; 16] // 16 stack slots, each 2 bytes }
The syntax is [type; count]. The size is part of the type — [u8; 16] and [u8; 32] are different types as far as the compiler is concerned.
Arrays live on the stack (not the heap), their size is fixed at compile time, and they are accessed by index:
#![allow(unused)] fn main() { self.v[0] // V0 self.v[15] // VF }
The shorthand for initialization:
#![allow(unused)] fn main() { [0; 16] // array of 16 zeros [0u8; 16] // same, with explicit type annotation }
Self as a return type
#![allow(unused)] fn main() { pub fn new() -> Self { }
Self is an alias for the implementing type (Cpu here). It is equivalent to writing -> Cpu, but Self is preferred because it stays correct if the struct is ever renamed.
Why not use Vec or HashMap?
CHIP-8's hardware is fixed: always 16 registers, always a 16-level stack. Vec is for collections whose size changes at runtime. Using a fixed array faithfully represents the hardware and is simpler and faster.
For collections whose size is only known at runtime — like a sprite loaded from memory — we do use
Vec. See Vec<T> — Dynamic Arrays and Slices and Ranges.
In the emulator: Every register, stack slot, and memory cell in the CPU is either a
u8oru16. The CHIP-8 Registers chapter explains the hardware reason for each choice. The Type Casting chapter covers what happens when you need to convert between these types — which happens constantly when decoding opcodes.
Traits
Traits are Rust's answer to interfaces. They define behavior that types can share — without inheritance.
Defining and implementing a trait
#![allow(unused)] fn main() { trait Resettable { fn reset(&mut self); } impl Resettable for Cpu { fn reset(&mut self) { self.pc = 0x200; self.v = [0; 16]; } } impl Resettable for Display { fn reset(&mut self) { self.framebuffer = [[false; 64]; 32]; } } }
Any function that accepts T: Resettable works with both — no class hierarchy needed.
Default implementations
A trait can provide a default implementation that types use unless they override it:
#![allow(unused)] fn main() { trait Resettable { fn reset(&mut self); fn reset_and_log(&mut self) { // default — types get this for free self.reset(); println!("reset complete"); } } }
This is similar to abstract classes in OOP, but without shared state.
Standard library traits you will encounter
| Trait | What it does |
|---|---|
Default | provides a "zero" value, enables Cpu::default() |
Debug | enables {:?} formatting, essential for tests and logging |
Clone | enables explicit .clone() to duplicate a value |
Many can be derived automatically — the compiler generates the implementation from the struct definition:
#![allow(unused)] fn main() { #[derive(Debug, Clone)] pub struct Cpu { ... } }
The key difference from inheritance
Traits share behavior, not state. When Cpu and Display both implement Resettable, they share the contract but not any data. There is no base class with shared fields, no diamond problem, no fragile hierarchy.
In the emulator: We use
#[derive(Debug)]on most structs so they can be printed in test output. TheDefaulttrait comes up when discussing Constructors. The trait system is also what makes external crates likerandwork — we bring theRngtrait into scope to access its methods (see External Crates and RND opcode).
Constructors and Initialization
Rust has no constructor keyword. What it has is a stricter rule: every field must be initialized before the struct can be used. The new() function is simply the conventional way to centralize that initialization.
Uninitialized fields are a compile error
#![allow(unused)] fn main() { // ERROR: missing field `sp` in initializer of `Cpu` let cpu = Cpu { v: [0; 16], i: 0, pc: 0x200, stack: [0; 16], // sp forgotten — does not compile }; }
Unlike C, there is no garbage memory waiting to cause a crash at runtime. The compiler ensures every value is set before it is read.
Why new() exists
Without it, every caller has to know all the initial values:
#![allow(unused)] fn main() { // without new() — caller must know everything let cpu = Cpu { v: [0;16], i: 0, pc: 0x200, sp: 0, stack: [0;16], memory: ..., display: ... }; // with new() — clean and encapsulated let cpu = Cpu::new(); }
new() centralizes the default values in one place. If initialization logic changes — say, fonts need to be loaded into memory at startup — you change new() and nothing else.
The Default trait
Rust provides a Default trait for types with a natural "zero" value. It can be derived:
#![allow(unused)] fn main() { #[derive(Default)] pub struct Cpu { ... } let cpu = Cpu::default(); }
The limitation: #[derive(Default)] sets every field to its type's zero (0 for integers, false for booleans). For Cpu, this is wrong — pc must start at 0x200, not 0. So we write new() manually.
You can bridge both approaches by delegating:
#![allow(unused)] fn main() { impl Default for Cpu { fn default() -> Self { Self::new() } } }
This makes Cpu work with parts of the ecosystem that expect Default, while keeping the initialization logic in new().
In the emulator:
Cpu::new()initializes PC to0x200, zeroes all registers, and delegates toMemory::new()andDisplay::new().Memory::new()also loads the 16 font sprites into RAM at address0x000— somethingDefaultcould not express. See Font Sprites for why fonts must be in memory from the start.
Modules
As the emulator grows, keeping everything in main.rs becomes unmanageable. Modules let you split code into files while controlling what is visible from the outside.
Declaring a module
In main.rs:
#![allow(unused)] fn main() { mod cpu; mod memory; mod display; }
mod cpu; tells the compiler: "find the module named cpu in the filesystem." The compiler looks in two places:
| Declaration | Compiler looks for |
|---|---|
mod cpu; | src/cpu.rs or src/cpu/mod.rs |
mod memory; | src/memory.rs or src/memory/mod.rs |
Both forms are equivalent. The folder form (cpu/mod.rs) is preferred when the module has submodules — like when we add a tests.rs file inside the cpu/ directory.
Submodules and test files
When src/cpu/mod.rs contains:
#![allow(unused)] fn main() { #[cfg(test)] mod tests; }
The compiler looks for src/cpu/tests.rs. The full path of this module is cpu::tests. This is how we keep test code out of the main module without losing access to the internals being tested.
Bringing items into scope
#![allow(unused)] fn main() { // full path — always works let cpu = cpu::Cpu::new(); // use — brings an item into the local scope use cpu::Cpu; let cpu = Cpu::new(); }
super in test files
Inside src/cpu/tests.rs:
#![allow(unused)] fn main() { use super::*; // import everything from cpu/mod.rs }
super refers to the parent module. This gives the test file access to Cpu, execute(), and anything else in cpu/mod.rs — without needing to know the full path crate::cpu::Cpu.
Visibility rules
Items are private by default:
#![allow(unused)] fn main() { pub struct Cpu { ... } // visible everywhere pub fn step(&mut self) { } // visible everywhere fn execute(&mut self) { } // private — only inside cpu/mod.rs }
A child module can always see its parent's private items. The reverse is not true.
The module tree for this project
crate (main.rs)
├── cpu (src/cpu/mod.rs)
│ └── tests (src/cpu/tests.rs)
├── memory (src/memory/mod.rs)
│ └── tests (src/memory/tests.rs)
└── display (src/display/mod.rs)
Related Rust concepts: Module visibility uses
pubwhich is introduced in Slices and Ranges. The#[cfg(test)]attribute that gates the test module is covered in Attributes and cfg.
Attributes and cfg
Attributes are annotations that give the compiler extra instructions about a piece of code. They appear above the item they apply to, using the #[...] syntax.
The three you will use most
#[cfg(test)] — conditional compilation
#![allow(unused)] fn main() { #[cfg(test)] mod tests; }
This tells the compiler: include this module only when running cargo test. When you build normally (cargo build, cargo run), the test module does not exist. Test code stays completely out of production binaries.
Common conditions:
#![allow(unused)] fn main() { #[cfg(test)] // only during cargo test #[cfg(target_os = "macos")] // only on macOS #[cfg(debug_assertions)] // only in debug builds }
#[test] — marking a test function
#![allow(unused)] fn main() { #[test] fn test_jp() { let mut cpu = Cpu::new(); // ... assert_eq!(cpu.pc, 0x300); } }
#[test] marks a function as a test case. The test runner (cargo test) finds every function with this attribute and runs them. Without it, the function is just a regular function that never gets called automatically.
#[derive(...)] — auto-implementing traits
#![allow(unused)] fn main() { #[derive(Debug, Clone)] pub struct Cpu { ... } }
derive asks the compiler to generate a trait implementation automatically. It reads the struct definition and writes the code for you. Without derive, you would have to write:
#![allow(unused)] fn main() { impl std::fmt::Debug for Cpu { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { // ... 20+ lines of boilerplate } } }
Applying to the whole file
An attribute with ! applies to the entire enclosing module instead of the next item:
#![allow(unused)] #![allow(dead_code)] // suppress dead_code warnings for this whole file fn main() { }
Summary
| Attribute | Effect |
|---|---|
#[cfg(test)] | compile only during cargo test |
#[test] | mark as a test function |
#[derive(...)] | auto-generate trait implementations |
#[allow(...)] | suppress a compiler warning |
In the emulator: Every test file uses
#[cfg(test)]on the module declaration and#[test]on each test function.#[derive(Debug)]is on most structs so test assertion failures print readable output. See Modules for how test files are structured.
External Crates and Cargo.toml
Rust's standard library is deliberately small. It gives you the core language and data structures, but it does not include things like random number generation, graphics, or HTTP clients. For everything else, you reach for crates — the name Rust uses for packages.
The ecosystem is large and well-organized. There is a crate for almost anything you need, and adding one takes a single line in your project file.
Adding a dependency
Open Cargo.toml and add to the [dependencies] section:
[dependencies]
rand = "0.8"
minifb = "0.27"
That is the entire process. The next time you run cargo build or cargo test, Cargo fetches both crates from the internet, compiles them, and links them into your project. No separate install step, no environment variables, no build scripts.
Where crates come from
The official registry is crates.io. When you add a dependency, Cargo looks it up there by default. You can search for crates, read their documentation, and check how actively they are maintained — all on the same site.
Version numbers
Rust uses semantic versioning: major.minor.patch. The version string in Cargo.toml is a constraint, not a pin:
| Specifier | What Cargo accepts |
|---|---|
"0.8" | anything ≥ 0.8.0 and < 0.9.0 |
"0.8.5" | exactly 0.8.5, nothing else |
"^0.8" | same as "0.8" — caret is the default |
The common form "0.8" says: "give me any compatible update within this minor version." Patch updates (bug fixes) are included; a new minor version that might break things is not.
Cargo.lock
The first time you build, Cargo resolves every dependency — including all the transitive ones that your direct dependencies pull in — and records the exact versions in Cargo.lock. From that point on, every build uses those exact versions.
For a binary project like this emulator, Cargo.lock should be committed to version control. It ensures that every developer, every CI run, and every deployment uses identical code — even months later when newer versions of crates have been released.
Using a crate in code
Two steps: declare it in Cargo.toml, then bring the right items into scope:
#![allow(unused)] fn main() { use rand::Rng; // the Rng trait must be in scope for .gen() to work let value: u8 = rand::thread_rng().r#gen(); }
Just having a crate as a dependency is not enough. Traits must be explicitly imported for their methods to be available — that is a deliberate design choice in Rust's module system. Without use rand::Rng, the .r#gen() call does not compile, even if the crate is in Cargo.toml.
The
r#gen()syntax is a workaround for a keyword conflict introduced in Rust 2024. The full story is in Raw Identifiers.
In the emulator: Two crates are used.
randpowers theCXNNopcode that generates random numbers — see RND — Random Numbers.minifbopens the window and renders the framebuffer — see The Main Loop.
Type Casting
Rust never converts types implicitly. Every conversion must be written explicitly with the as keyword. This makes intent visible and prevents silent bugs from unexpected coercion.
Basic syntax
#![allow(unused)] fn main() { let x: u16 = 0x0A; let as_usize = x as usize; let as_u8 = x as u8; }
Why array indexing needs usize
Array indices must be usize — the platform-native integer for memory sizes. The compiler rejects any other type:
#![allow(unused)] fn main() { let x: u16 = 5; self.v[x] // ERROR: expected usize, found u16 self.v[x as usize] // OK }
Casting to a smaller type: truncation
When casting to a smaller type, the upper bits are discarded — only the lower bits remain:
#![allow(unused)] fn main() { let value: u16 = 0x6A42; let low = value as u8; // keeps only the lower 8 bits → 0x42 }
0x6A42 = 0110 1010 | 0100 0010
─────────
as u8 → 0x42
This is exactly what happens when extracting the NN byte from an opcode:
#![allow(unused)] fn main() { let nn = (opcode & 0x00FF) as u8; }
The mask isolates the lower byte; the cast keeps it as u8.
Casting to a larger type: zero extension
Zeros are added to the left:
#![allow(unused)] fn main() { let value: u8 = 0x42; let extended = value as u16; // 0x0042 }
This is what happens during fetch when combining two bytes into a 16-bit opcode:
#![allow(unused)] fn main() { let high = ram[pc] as u16; // 0xAB → 0x00AB let low = ram[pc + 1] as u16; // 0xCD → 0x00CD let opcode = (high << 8) | low; // 0xABCD }
Without casting to u16 before shifting, the shift would overflow the 8-bit boundary.
Summary
| Conversion | What happens |
|---|---|
| small → large | zeros added on the left |
| large → small | upper bits discarded |
| any → usize | required for array indexing |
In the emulator: Type casting appears in nearly every opcode. The register index extracted from an opcode (
u16) must be cast tousizebefore indexing intoself.v[x]. Values loaded into registers must be cast tou8. See the Bitwise Operations chapter for the masking operations that precede most casts, and The First Opcode: JP for the full fetch cycle where both casting and shifting are needed together.
Integer Overflow and Wrapping
Overflow happens when an arithmetic result exceeds the range of its type. For u8, the maximum is 255 — adding 1 to it would produce 256, which does not fit.
What Rust does by default
#![allow(unused)] fn main() { let x: u8 = 255; let y = x + 1; }
| Build mode | Command | Behavior |
|---|---|---|
| Debug | cargo build | panic at runtime |
| Release | cargo build --release | wraps silently to 0 |
This inconsistency is intentional. Debug mode surfaces bugs. Release mode prioritizes performance. The problem is that relying on either behavior makes your code unpredictable.
Explicit overflow methods
Rust provides methods that make the behavior unambiguous in both modes:
#![allow(unused)] fn main() { let x: u8 = 255; x.wrapping_add(1) // → 0 always wraps, never panics x.saturating_add(1) // → 255 clamps to max, never panics x.checked_add(1) // → None returns Option, None on overflow x.overflowing_add(1) // → (0, true) result + whether it overflowed }
Which one to use
wrapping_add / wrapping_sub — when overflow is correct and expected. Hardware emulation is the textbook use case: CHIP-8 registers are 8-bit, so arithmetic wraps exactly as the original hardware did.
#![allow(unused)] fn main() { self.v[x] = self.v[x].wrapping_add(nn); // ADD Vx, byte }
overflowing_add — when you need the result and a flag indicating overflow. Used for opcodes that write the carry or borrow to VF:
#![allow(unused)] fn main() { let (result, carry) = self.v[x].overflowing_add(self.v[y]); self.v[x] = result; self.v[0xF] = carry as u8; // 1 if overflow, 0 if not }
checked_add — when overflow is an error. Not used in the CPU core, but useful in bounds checking and validation.
Why not just use +?
Using + in debug mode panics when overflow happens in a test — exactly the case that should pass. wrapping_add makes the intent explicit and consistent.
In the emulator:
wrapping_addis used for7XNN(ADD Vx, byte) which wraps silently.overflowing_addandoverflowing_subare used for8XY4and8XY5which set VF to the carry/borrow. See ADD Vx, byte and 8XY* — Arithmetic and Logic.
Bitwise Operations
An opcode is a 16-bit number, but it is not really a number — it is four fields packed into two bytes. The first four bits identify the instruction category. The next four bits identify a register. The last eight bits carry an immediate value. Before the CPU can do anything with an opcode, it has to unpack those fields.
That unpacking is done entirely with bitwise operations. AND isolates the bits you want. Shift moves them to the right position. OR combines separate pieces into one value. These three operations appear in literally every opcode implementation in this emulator, so understanding them is not optional.
Binary: the foundation
Every integer is stored as a sequence of bits. A u8 has 8 bits, a u16 has 16:
decimal 42 = binary 0010 1010
Each bit position represents a power of 2, from right to left: 128, 64, 32, 16, 8, 4, 2, 1. With 8 bits, the maximum value is 255 (all bits set). With 16 bits, it is 65535.
AND: & — isolate bits
AND compares each bit pair. The result is 1 only where both input bits are 1. Everything else becomes 0.
0x6A42 = 0110 1010 0100 0010
& 0x0F00 = 0000 1111 0000 0000
= 0x0A00 = 0000 1010 0000 0000
This is called masking: the second operand (the mask) zeroes out every bit position we do not want, leaving only the bits we care about.
In opcode decoding, masking extracts fields:
#![allow(unused)] fn main() { let x = (opcode & 0x0F00) >> 8; // second nibble: register index let nn = (opcode & 0x00FF) as u8; // lower byte: immediate value }
Right shift: >> — move bits down
Moves all bits to the right by N positions. Zeroes fill in from the left. Each step divides the value by 2.
0x0A00 >> 8 = 0x000A = 10
After masking a field into its original bit position, shift it down to zero-based so you can use it as a number or array index:
#![allow(unused)] fn main() { let x = ((opcode & 0x0F00) >> 8) as usize; // register index 0–15 }
Left shift: << — move bits up
Moves all bits to the left. Used during fetch to combine two separate bytes into a single 16-bit opcode:
#![allow(unused)] fn main() { let high: u8 = 0xAB; let low: u8 = 0xCD; let opcode: u16 = (high as u16) << 8 | (low as u16); // = 0xABCD }
The cast to u16 before shifting is required — without it, the shift would push bits beyond the 8-bit boundary and overflow.
OR: | — combine bits
Result is 1 where at least one input bit is 1. This is how the two bytes above are merged into one value: high occupies the upper byte, low occupies the lower byte, and OR merges them without conflict.
XOR: ^ — toggle bits
Result is 1 where the bits differ. XOR with 1 flips a bit; XOR with 0 leaves it alone. XOR a value with itself and you always get zero.
#![allow(unused)] fn main() { let a: u8 = 0b10101010; println!("{}", a ^ a); // 0 — identical bits cancel out }
XOR is how CHIP-8 draws pixels. Each sprite bit is XORed against the current screen state. If a lit pixel meets another lit pixel, it turns off — which is how the hardware detects sprite collisions.
The three patterns you will see everywhere
#![allow(unused)] fn main() { // 1. Combine two memory bytes into a 16-bit opcode let opcode: u16 = (high as u16) << 8 | low as u16; // 2. Extract a nibble — mask then shift let x = ((opcode & 0x0F00) >> 8) as usize; // 3. Extract the lower byte — mask only, no shift needed let nn = (opcode & 0x00FF) as u8; }
Once these patterns are familiar, reading opcode implementations becomes straightforward — each line is just one of these three in a different form.
In the emulator: Pattern 1 appears in
step()during fetch. Patterns 2 and 3 appear in every opcode implementation. See The First Opcode: JP for the first time all three are used together. Theascasts are explained in Type Casting, and thematchthat follows extraction is in Pattern Matching.
Compound Assignment Operators
When we implement the register operation opcodes, the code almost writes itself. The bitwise opcodes — OR, AND, XOR, shift — map so directly to Rust's compound assignment operators that reading the implementation feels like reading the opcode specification.
The syntax
Compound assignment combines an operation with assignment in one step. The two forms are exactly equivalent:
#![allow(unused)] fn main() { x += y // x = x + y x -= y // x = x - y x *= y // x = x * y x /= y // x = x / y x |= y // x = x | y (bitwise OR) x &= y // x = x & y (bitwise AND) x ^= y // x = x ^ y (bitwise XOR) x >>= n // x = x >> n (right shift) x <<= n // x = x << n (left shift) }
In the register opcodes
The five bitwise operations in the 8XY* group become one line each:
#![allow(unused)] fn main() { self.v[x] |= self.v[y]; // OR Vx, Vy self.v[x] &= self.v[y]; // AND Vx, Vy self.v[x] ^= self.v[y]; // XOR Vx, Vy self.v[x] >>= 1; // SHR Vx (shift right by 1) self.v[x] <<= 1; // SHL Vx (shift left by 1) }
Each line reads like the opcode mnemonic itself. The implementation is almost a transliteration of the specification.
A subtle ordering trap
The shift opcodes — SHR and SHL — require setting VF before performing the shift, because the shift destroys the bit that VF is supposed to capture.
For SHR, VF should receive the bit that gets shifted out — the current least-significant bit:
#![allow(unused)] fn main() { // correct: save the bit first, then shift self.v[0xF] = self.v[x] & 0x1; self.v[x] >>= 1; // wrong: the bit is gone by the time we try to read it self.v[x] >>= 1; self.v[0xF] = self.v[x] & 0x1; // reads the wrong value }
Both lines compile. Both look plausible at a glance. But the second version stores the bit that came in at the LSB position after the shift — not the bit that was shifted out. It is the kind of bug that only shows up when you run a test ROM that checks VF after a shift, and the result is subtly wrong.
The rule is always the same: any time an operation consumes information that must be saved first, save it before you run the operation.
In the emulator: All five compound bitwise assignments appear in 8XY* — Arithmetic and Logic. Note that the ADD opcodes (
7XNN,8XY4) do not use+=— they usewrapping_addandoverflowing_addinstead, because plain arithmetic can panic in debug mode. See Integer Overflow and Wrapping for why.
Slices, Ranges, and Visibility
When we implement the memory module, three Rust features come up together: constants for the fixed addresses, slices for passing variable-length data, and visibility rules to protect the internal state. They are small on their own, but together they form the backbone of how the memory API is written.
Constants
The memory layout has two addresses that need names — using raw numbers would make the code unreadable:
#![allow(unused)] fn main() { pub const MEMORY_SIZE: usize = 4096; pub const PROGRAM_START: u16 = 0x200; }
Constants are evaluated at compile time and inlined wherever they appear. Unlike let, they require an explicit type annotation and can be declared at module level, outside any function. The type usize is the right choice for anything used as an array length or index — it is the platform-native integer for memory sizes.
Slices: working with variable-length data
Here is the signature for loading a ROM:
#![allow(unused)] fn main() { pub fn load_rom(&mut self, data: &[u8]) { ... } }
The parameter type &[u8] is a slice — a reference to a contiguous sequence of bytes whose length is not known at compile time. It can point to 10 bytes or 10,000 bytes — the function does not care.
This is different from a fixed-size array. A fixed array's size is part of its type:
| Type | Size at compile time? | Use case |
|---|---|---|
[u8; 16] | Yes | Fixed hardware (registers, stack) |
&[u8] | No | ROM data, variable-length input |
Internally, a slice is a "fat pointer" — it carries both a pointer to the first element and the total length. You never need to pass the length separately; it is always available through .len().
Range slicing: [start..end]
To load the ROM into memory at address 0x200, we create a slice of the destination range and copy into it:
#![allow(unused)] fn main() { let start = PROGRAM_START as usize; self.ram[start..start + data.len()].copy_from_slice(data); }
[start..end] creates a slice that references elements from start up to (but not including) end. It is a view — nothing is copied yet. Then copy_from_slice reads from data and writes into that portion of ram.
Other range forms:
#![allow(unused)] fn main() { &arr[2..5] // indices 2, 3, 4 &arr[2..] // index 2 to the end &arr[..5] // index 0 to 4 &arr[..] // the entire array as a slice }
Visibility: controlling access
The memory struct has a public interface but a private internal state:
#![allow(unused)] fn main() { pub struct Memory { ram: [u8; MEMORY_SIZE], // private — not accessible from outside this file } impl Memory { pub fn read(&self, address: u16) -> u8 { ... } pub fn write(&mut self, address: u16, value: u8) { ... } } }
In Rust, everything is private by default. ram cannot be touched from outside memory.rs — only read and write can. This matters because it gives us one place to add bounds checking, memory-mapped I/O, or logging later, without changing any of the code that calls these methods. Encapsulation is not just style — it is how you keep a growing codebase manageable.
In the emulator:
load_romuses&[u8]so it works equally well with an inline test array like&[0x13, 0x00]or a file read from disk. Thecopy_from_slicepattern places the ROM at exactly0x200. See Memory Layout for the complete picture of what lives where in RAM.
Vec<T> — Dynamic Arrays
Vec<T> is a growable array that lives on the heap. Unlike [T; N], its size is not known at compile time — it can grow as elements are added.
Creating a Vec
#![allow(unused)] fn main() { // pre-filled with a value let buffer = vec![0u32; 64 * 32]; // 2048 zeros // from an iterator let sprite: Vec<u8> = (0..n) .map(|row| self.memory.read(self.i + row as u16)) .collect(); }
.collect() consumes an iterator and gathers its values into a collection. The type annotation Vec<u8> tells the compiler which collection to build.
Vec vs array vs slice
| Type | Size | Location | Use case |
|---|---|---|---|
[T; N] | fixed, compile-time | stack | registers, framebuffer |
&[T] | unknown, compile-time | anywhere | function parameters, views |
Vec<T> | dynamic, runtime | heap | collections that grow or shrink |
In the emulator, Vec<u8> is used for sprites because their size (n rows) comes from the opcode — it is only known at runtime. A fixed array cannot represent this.
Dereferencing to a slice
Vec<T> automatically coerces to &[T], so a Vec can be passed anywhere a slice is expected:
#![allow(unused)] fn main() { fn draw_sprite(sprite: &[u8]) { ... } let sprite: Vec<u8> = vec![0xF0, 0x90]; draw_sprite(&sprite); // Vec coerces to &[u8] }
Accessing elements
#![allow(unused)] fn main() { let v = vec![10u8, 20, 30]; v[0] // 10 — panics if out of bounds v.get(0) // Some(10) — safe, returns Option v.len() // 3 }
In the emulator:
Vec<u8>is used in theDXYNopcode to build the sprite byte array before passing it todraw_sprite.Vec<u32>is used inmain.rsas the pixel buffer forminifb. See DRW — Drawing Sprites and The Main Loop. For fixed-size collections, see Primitive Types and Arrays. For passing variable-length data to functions, see Slices and Ranges.
Iterators
An iterator produces a sequence of values one at a time. Rust's standard library wraps this in a rich set of composable methods.
.iter() and .enumerate()
.iter() produces an iterator over references to a collection's elements. .enumerate() wraps any iterator to also produce the current index, yielding (index, value) tuples:
#![allow(unused)] fn main() { let bytes = [0xFF, 0x81, 0xFF]; for (i, byte) in bytes.iter().enumerate() { // i: usize, byte: &u8 println!("row {}: {:08b}", i, byte); } }
Without .enumerate(), you would need a manual counter:
#![allow(unused)] fn main() { let mut i = 0; for byte in bytes.iter() { // ... i += 1; } }
Common iterator methods
#![allow(unused)] fn main() { let numbers = [1, 2, 3, 4, 5]; numbers.iter().map(|x| x * 2) // transform each element numbers.iter().filter(|&&x| x > 2) // keep matching elements numbers.iter().sum::<i32>() // fold into one value: 15 numbers.iter().any(|&x| x > 4) // true if any match numbers.iter().all(|&x| x > 0) // true if all match }
Iterators are lazy
Chaining methods does not execute anything:
#![allow(unused)] fn main() { // nothing runs here let chain = numbers.iter() .filter(|&&x| x > 2) .map(|x| x * 2); // execution happens when consumed let result: Vec<i32> = chain.collect(); }
Building a Vec from a range
#![allow(unused)] fn main() { let sprite: Vec<u8> = (0..n) .map(|row| self.memory.read(self.i + row as u16)) .collect(); }
(0..n) is a range iterator. .map() transforms each index into a memory read. .collect() gathers the results into a Vec<u8>.
In the emulator:
.enumerate()is used indraw_spriteto iterate over sprite rows with their index. The(0..n).map(...).collect()pattern builds the sprite byte array in theDXYNopcode. See DRW — Drawing Sprites and The Display. Thecollect()step requires understanding Vec<T>.
Pattern Matching
After the fetch step extracts the first nibble from an opcode, the CPU needs to decide what to do with it. The nibble is a number from 0 to 15 — each value represents a different instruction category. In most languages you would reach for a switch statement. In Rust, you use match, which is both more powerful and safer.
Basic syntax
#![allow(unused)] fn main() { let nibble: u8 = 6; match nibble { 0x1 => { /* JP */ }, 0x6 => { /* LD Vx, byte */ }, 0xA => { /* LD I, addr */ }, _ => println!("unknown: {}", nibble), } }
_ is the catch-all arm — it matches anything not covered above. In Rust, match must be exhaustive: every possible value of the matched type must be handled. If you leave out the catch-all and there are values you have not covered, the compiler refuses to compile. This is not a warning — it is an error.
match returns a value
Unlike switch, match is an expression. It produces a value, which you can assign directly:
#![allow(unused)] fn main() { let label = match nibble { 0x1 => "JP", 0x6 => "LD Vx", _ => "unknown", }; }
Every arm must produce the same type. The compiler checks this.
Multiple patterns and ranges
#![allow(unused)] fn main() { match x { 0x0 | 0x8 | 0xF => println!("multi-opcode group"), 1..=9 => println!("single digit"), _ => println!("other"), } }
| lets one arm handle multiple values. ..= matches an inclusive range.
Guards
A guard is an extra condition after the pattern:
#![allow(unused)] fn main() { match x { n if n % 2 == 0 => println!("even: {}", n), n => println!("odd: {}", n), } }
The pattern matches first, then the guard is evaluated. If the guard is false, the arm is skipped and matching continues.
Nested match for opcodes
Some opcode categories contain multiple distinct instructions that share the same first nibble. The 0x0 category has both 00E0 (CLS) and 00EE (RET). A single level of matching on the first nibble is not enough — we need a second level on the full opcode value:
#![allow(unused)] fn main() { let nibble = (opcode & 0xF000) >> 12; match nibble { 0x0 => match opcode { 0x00E0 => self.display.clear(), 0x00EE => { /* RET */ } _ => println!("unknown: {:#06X}", opcode), }, 0x1 => { /* JP addr */ }, 0x6 => { /* LD Vx, byte */ }, 0xD => { /* DRW */ }, _ => println!("unknown: {:#06X}", opcode), } }
The outer match dispatches on the category. The inner match refines within that category. This pattern repeats for the 0x8 group (nine register operations) and the 0xF group (timer, memory, and font opcodes).
In the emulator: The complete opcode dispatch is a
matchon the first nibble, with nested matches for the0x0,0x8, and0xFgroups. See The First Opcode: JP for where this structure first appears, 8XY* — Arithmetic and Logic for the inner match on the0x8group, and Bitwise Operations for how the nibble is extracted before being matched.
Tuples
When implementing the arithmetic opcodes, we run into a situation where a single operation produces two pieces of information that are equally important: the result of the calculation, and whether it overflowed. In most languages you would use an output parameter or a struct. In Rust, the idiomatic answer is a tuple.
What is a tuple?
A tuple groups a fixed number of values that can have different types:
#![allow(unused)] fn main() { let pair: (u8, bool) = (255, true); let triple: (u8, u16, bool) = (10, 300, false); }
Unlike arrays, each position can hold a different type. The type of the tuple is part of the type signature — (u8, bool) and (u16, bool) are different types.
You can access elements by index:
#![allow(unused)] fn main() { let pair = (0x42u8, true); let value = pair.0; // 0x42 let flag = pair.1; // true }
But in practice, nobody writes .0 and .1. The idiomatic approach is to unpack immediately.
Destructuring: the way you actually use tuples
#![allow(unused)] fn main() { let (result, carry) = self.v[x].overflowing_add(self.v[y]); }
overflowing_add returns (u8, bool). Destructuring pulls both values out in a single line and gives each a clear name. Compare this to the alternative:
#![allow(unused)] fn main() { let pair = self.v[x].overflowing_add(self.v[y]); let result = pair.0; let carry = pair.1; }
Same information, three times more noise. Destructuring is not just shorter — it makes the intent clearer at the point where the variable is introduced.
Returning multiple values from a function
Tuples are the standard way to return more than one value:
#![allow(unused)] fn main() { fn divide(a: u8, b: u8) -> (u8, u8) { (a / b, a % b) } let (quotient, remainder) = divide(10, 3); }
There is no "out parameter" in Rust. If a function needs to communicate multiple results, it packs them into a tuple (or a struct, if the values deserve names at the definition site).
The unit type: ()
An empty tuple () is called the unit type. It represents the absence of a meaningful value — the equivalent of void in other languages. Functions that do not explicitly return anything implicitly return ().
In the emulator: Tuples appear in the
8XY4(ADD with carry) and8XY5(SUB with borrow) opcodes, whereoverflowing_addandoverflowing_subreturn(value, bool). The bool is then stored in VF. See 8XY* — Arithmetic and Logic. The destructuring syntax used with tuples is explored further in Destructuring.
Destructuring
When implementing draw_sprite, we need to iterate over each row of a sprite and know two things simultaneously: the row index (to calculate the y position on screen) and the byte value (to extract the pixels). In many languages, you would get one and calculate the other. In Rust, you get both at once — directly in the for loop header — through destructuring.
Destructuring is the ability to unpack a value into its component parts in a single step. It works wherever a value is bound to a name: let statements, for loops, function parameters, and match arms.
Destructuring tuples in let
The most common form — pulling apart a tuple returned by a function:
#![allow(unused)] fn main() { let (result, carry) = self.v[x].overflowing_add(self.v[y]); }
overflowing_add returns (u8, bool). Without destructuring, you would write:
#![allow(unused)] fn main() { let pair = self.v[x].overflowing_add(self.v[y]); let result = pair.0; let carry = pair.1; }
Three lines instead of one, and .0 / .1 tell you nothing about what the values mean. Destructuring names them at the point of creation.
Destructuring in for loops
#![allow(unused)] fn main() { for (row, &byte) in sprite.iter().enumerate() { // row: usize — the row index (0, 1, 2, ...) // byte: u8 — the sprite byte for this row } }
Two things are happening at once here:
Tuple destructuring — .enumerate() yields (usize, &u8) tuples. The (row, &byte) pattern unpacks that tuple directly in the loop header.
Reference pattern — .iter() yields references (&u8). Writing &byte in the pattern matches the reference and binds byte as a plain u8 — the dereference happens in the pattern, not in the loop body. Without it:
#![allow(unused)] fn main() { for (row, byte) in sprite.iter().enumerate() { let b = *byte; // byte is &u8 — manual dereference needed every time } }
The &byte pattern is an idiom worth getting comfortable with — it appears in any loop over a borrowed collection where you want the value rather than the reference.
Destructuring in match
Match arms can destructure the matched value:
#![allow(unused)] fn main() { match point { (0, 0) => println!("origin"), (x, 0) => println!("on x-axis at {}", x), (0, y) => println!("on y-axis at {}", y), (x, y) => println!("at ({}, {})", x, y), } }
The variables x and y in the arms are bound from the matched tuple — no separate extraction step needed.
Ignoring parts with _
#![allow(unused)] fn main() { let (value, _) = some_tuple; // we only need the first element }
_ discards a part without binding it. The compiler also suppresses unused variable warnings for anything prefixed with _.
In the emulator: The
for (row, &byte)pattern is used indraw_spriteto iterate over sprite rows. See The Display for the full implementation and DRW — Drawing Sprites for the opcode that calls it. Thelet (result, carry) = overflowing_add(...)pattern is used in 8XY* — Arithmetic and Logic.
Result and unwrap
Rust does not use exceptions. Functions that can fail communicate that failure through their return type.
Result<T, E>
#![allow(unused)] fn main() { enum Result<T, E> { Ok(T), // success — contains the value Err(E), // failure — contains the error } }
A function returning Result forces the caller to handle both cases. Window::new() from the minifb crate is a typical example — opening a window can fail if the display server is not available.
Handling a Result
#![allow(unused)] fn main() { // explicit match match Window::new("CHIP-8", width, height, options) { Ok(w) => { /* use w */ }, Err(e) => eprintln!("error: {}", e), } // if let — only handle the success case if let Ok(w) = Window::new("CHIP-8", width, height, options) { // use w } }
unwrap()
unwrap() extracts the Ok value — or panics if the result is Err:
#![allow(unused)] fn main() { let window = Window::new("CHIP-8", WIDTH * SCALE, HEIGHT * SCALE, options) .unwrap(); }
This is acceptable when:
- Failure is truly unexpected (a programming error, not user input)
- You are prototyping and will add proper error handling later
It is not acceptable in production code where the error is recoverable.
Option<T>
The same idea for values that may or may not exist:
#![allow(unused)] fn main() { enum Option<T> { Some(T), // value is present None, // value is absent } }
#![allow(unused)] fn main() { let v = vec![1, 2, 3]; v.get(0) // Some(&1) v.get(9) // None — no panic, unlike v[9] }
The ? operator
In functions that return Result, ? propagates errors upward instead of unwrapping:
#![allow(unused)] fn main() { fn setup() -> Result<Window, minifb::Error> { let window = Window::new("CHIP-8", WIDTH, HEIGHT, options)?; // if Err → returns early with the error // if Ok → window is the unwrapped value Ok(window) } }
? is the idiomatic alternative to unwrap() in production code.
In the emulator:
unwrap()is used inmain.rswhen creating the window and updating the pixel buffer — failures there mean the window cannot be opened, and panicking is an acceptable response for this project. See The Main Loop.
Ownership in Closures
Closures can capture variables from their surrounding scope. How they capture — by reference or by value — follows Rust's ownership rules, and the distinction matters when a closure is called multiple times.
The error we hit
When building the pixel buffer, the first attempt used an iterator chain:
#![allow(unused)] fn main() { // FAILS — cpu.display is moved on the first iteration, gone on the second let buffer: Vec<u32> = (0..HEIGHT) .flat_map(|y| { (0..WIDTH).map(move |x| { if cpu.display.pixel(x, y) { 0xFFFFFFFF } else { 0xFF000000 } }) }) .collect(); }
The compiler error: "cannot move out of cpu.display, a captured variable in an FnMut closure."
flat_map calls its closure once per row — 32 times. The inner move closure attempts to take ownership of cpu.display on every call. But ownership can only transfer once. After the first call, cpu.display is gone.
The fix: a plain loop borrows by reference
#![allow(unused)] fn main() { let mut buffer = vec![0u32; WIDTH * HEIGHT]; for y in 0..HEIGHT { for x in 0..WIDTH { buffer[y * WIDTH + x] = if cpu.display.pixel(x, y) { 0xFFFFFFFF } else { 0xFF000000 }; } } }
A for loop borrows cpu.display by reference on each iteration — no ownership transfer, no problem.
Why move exists
move closures take ownership of captured variables. They are necessary when the closure outlives the scope where it was created — for example, spawning a thread:
#![allow(unused)] fn main() { let data = vec![1, 2, 3]; std::thread::spawn(move || { println!("{:?}", data); // data is moved into the thread }); }
Without move, the thread could outlive data — Rust rejects this at compile time.
Three ways closures capture
| Mode | Syntax | When used |
|---|---|---|
| Immutable reference | default | closure only reads |
| Mutable reference | default when needed | closure modifies |
| By value (move) | move || { } | closure must own the value |
Rust chooses the least restrictive mode automatically. move overrides this.
The rule
A value can only be moved once. If a closure is called multiple times (like inside flat_map), it cannot move the same value on each call — the value would be gone after the first. References are Copy and can be used any number of times; owned values cannot.
In the emulator: This error was encountered when building the render loop in
main.rs. The plainforloop solution is what lives in the final code. See The Main Loop for the full context.
Raw Identifiers
Raw identifiers allow you to use reserved keywords as names by prefixing them with r#. This is an escape hatch for a very specific situation.
The problem
When implementing the CXNN (RND) opcode, we use the rand crate to generate random numbers:
#![allow(unused)] fn main() { let rand: u8 = rand::thread_rng().r#gen(); }
Without r#, the compiler fails:
error: expected identifier, found reserved keyword `gen`
help: escape `gen` to use it as an identifier
|
rand::thread_rng().r#gen::<u8>();
++
Why this happened
Rust introduced gen as a reserved keyword in the 2024 edition (for generator syntax). The rand crate published a .gen() method long before that edition existed.
When you use Rust 2024 and call .gen() from an older crate, there is a keyword conflict. The compiler tells you exactly what to do: prefix with r#.
Rust editions
Rust uses an edition system to introduce breaking language changes without breaking existing code:
| Edition | Year | Notable changes |
|---|---|---|
| 2015 | original | |
| 2018 | async/await, module improvements | |
| 2021 | closure capture improvements | |
| 2024 | latest | gen keyword, among others |
Each crate declares its edition in Cargo.toml. Crates with different editions coexist in the same project — Cargo compiles each with its own rules. That is why rand 0.8 (edition 2018, where gen is not a keyword) and our emulator (edition 2024, where it is) work together.
When to use raw identifiers
Almost never. If you are writing new code, choose names that are not keywords. Raw identifiers exist to avoid breaking code when Rust adds new keywords — a situation you encounter when calling a library method, not when naming your own things.
In the emulator:
r#gen()appears once, in theCXNNopcode implementation. See RND — Random Numbers for the full implementation, and External Crates for how therandcrate is added to the project.
Registers and the CPU
A register is a tiny storage location inside the CPU — faster than memory, but very limited in number. Before writing a single line of code, we need to know exactly what hardware we are modeling.
General-purpose registers: V0–VF
#![allow(unused)] fn main() { pub v: [u8; 16] }
Sixteen registers, each holding one byte (0–255). Programs use them to store temporary values: the current score, a position, a loop counter. In code, v[0] is V0, v[15] is VF.
VF is special. Several opcodes write to it automatically as a side effect:
| Opcode | What VF receives |
|---|---|
ADD Vx, Vy | 1 if result overflowed (carry), 0 otherwise |
SUB Vx, Vy | 1 if Vx ≥ Vy (no borrow), 0 otherwise |
SHR Vx | the bit shifted out |
DRW Vx, Vy, n | 1 if any pixel was erased (collision), 0 otherwise |
Programs should not store data in VF — any arithmetic or draw instruction will overwrite it.
Index register: I
#![allow(unused)] fn main() { pub i: u16 }
A 16-bit pointer to a memory address. Before drawing a sprite, you point I at the sprite data. Before reading or writing memory, you point I at the location. It is the CPU's one shared memory pointer.
It is 16-bit because addresses go up to 0xFFF (4095), which does not fit in 8 bits.
Program counter: PC
#![allow(unused)] fn main() { pub pc: u16 }
Holds the address of the next instruction to execute. Each cycle:
- Read the 2-byte opcode at
memory[pc]andmemory[pc + 1] - Advance
pcby 2 - Decode and execute
PC starts at 0x200. The first 512 bytes of memory were reserved for the original firmware, so all CHIP-8 programs begin there.
Stack and stack pointer
#![allow(unused)] fn main() { pub stack: [u16; 16] pub sp: u8 }
The stack stores return addresses for subroutine calls. When a CALL instruction runs, the current PC is pushed onto the stack so RET can come back to it. CHIP-8 supports up to 16 levels of nesting.
CALL addr → stack[sp] = pc; sp++; pc = addr
RET → sp--; pc = stack[sp]
Summary
| Register | Size | Role |
|---|---|---|
| V0–VE | 8-bit | General-purpose data |
| VF | 8-bit | Status flag (carry, borrow, collision) |
| I | 16-bit | Memory address pointer |
| PC | 16-bit | Next instruction address |
| SP | 8-bit | Stack depth counter |
| stack | 16 × 16-bit | Return address storage |
Rust concepts used here: The CPU is modeled as a
struct— see Structs and impl Blocks. The choice ofu8andu16for each field is explained in Primitive Types and Arrays. Thenew()function that initializes PC to0x200is covered in Constructors and Initialization.
Memory Layout
CHIP-8 has 4 kilobytes of memory — 4096 bytes, addressable from 0x000 to 0xFFF. That is the entire address space.
The map
0x000 ┌─────────────────────┐
│ Reserved │ interpreter, font sprites (0x000–0x04F)
0x1FF ├─────────────────────┤
0x200 │ Program (ROM) │ ← all programs load here
│ │
0xFFF └─────────────────────┘
The first 512 bytes (0x000–0x1FF) were reserved for the original CHIP-8 interpreter that ran on the host machine. ROMs always start at 0x200 — this is a hard convention that every CHIP-8 program follows without exception. PC is initialized to 0x200 for this reason.
The range 0x000–0x04F is used by us for the built-in font sprites (16 digits × 5 bytes). See Font Sprites.
The Memory struct
#![allow(unused)] fn main() { pub const MEMORY_SIZE: usize = 4096; pub const PROGRAM_START: u16 = 0x200; pub struct Memory { ram: [u8; MEMORY_SIZE], // private — access only through read/write } }
ram is private. Nothing outside memory.rs can touch it directly. All reads and writes go through explicit methods:
#![allow(unused)] fn main() { impl Memory { pub fn read(&self, address: u16) -> u8 { self.ram[address as usize] } pub fn write(&mut self, address: u16, value: u8) { self.ram[address as usize] = value; } } }
This encapsulation means we can add bounds checking or memory-mapped I/O later without changing any other code.
Loading a ROM
#![allow(unused)] fn main() { pub fn load_rom(&mut self, data: &[u8]) { let start = PROGRAM_START as usize; self.ram[start..start + data.len()].copy_from_slice(data); } }
data is a byte slice — a reference to a sequence of bytes of any length. The ROM is placed starting at 0x200. copy_from_slice copies every byte from data into the correct position in RAM.
Memory is owned by the CPU
Memory is a field of Cpu, not a global. This mirrors how the hardware works — the CPU has direct access to its memory. The CPU accesses memory through self.memory.read(addr) and self.memory.write(addr, value).
Rust concepts used here: The
&[u8]parameter type forload_romis explained in Slices and Ranges. The privateramfield andpubmethods are covered in the same chapter. File and module organization is in Modules.
The Display
The CHIP-8 display is a 64×32 monochrome screen. Each pixel is either on or off — no color, no grayscale, just two states.
(0,0) (63,0)
┌──────────────────────────────────────────────┐
│ │ 32 rows
│ │
└──────────────────────────────────────────────┘
(0,31) (63,31)
The framebuffer
#![allow(unused)] fn main() { pub const WIDTH: usize = 64; pub const HEIGHT: usize = 32; pub struct Display { framebuffer: [[bool; WIDTH]; HEIGHT], // [[bool; 64]; 32] } }
A 2D array of booleans. Indexed as framebuffer[y][x] — row first, then column. The array is private; the outside world reads pixels via pixel(x, y) and draws via draw_sprite.
clear()
#![allow(unused)] fn main() { pub fn clear(&mut self) { self.framebuffer = [[false; WIDTH]; HEIGHT]; } }
Sets every pixel to off. Called by the 00E0 CLS opcode.
draw_sprite()
A sprite is a sequence of bytes where each byte represents one 8-pixel row. Bit 7 (MSB) is the leftmost pixel:
byte 0xF0 = 0b11110000:
████░░░░
For each bit in the sprite that is 1, the corresponding pixel on screen is XORed:
screen OFF + sprite 1 = pixel ON (no collision)
screen ON + sprite 1 = pixel OFF (collision!)
screen ON + sprite 0 = pixel ON (unchanged)
XOR means drawing the same sprite twice returns the screen to its original state. If any pixel goes from ON to OFF during a draw, there was a collision — draw_sprite returns true, and the CPU stores that in VF.
Wrapping
Sprites that extend beyond screen edges wrap around:
#![allow(unused)] fn main() { let px = (x + col) % WIDTH; // horizontal wrap let py = (y + row) % HEIGHT; // vertical wrap }
A sprite drawn at x=62 will have its rightmost pixels appear at x=0 and x=1.
Display is owned by the CPU
#![allow(unused)] fn main() { pub struct Cpu { // ... pub display: Display, } }
The DRW opcode calls self.display.draw_sprite(...). The CLS opcode calls self.display.clear(). The main loop reads pixels via cpu.display.pixel(x, y) to build the render buffer.
Rust concepts used here: The 2D boolean array (
[[bool; WIDTH]; HEIGHT]) uses the fixed-size array syntax from Primitive Types and Arrays. Iterating over sprite bytes uses Iterators and Destructuring. See CLS and DRW for the opcodes that drive this module.
Font Sprites
CHIP-8 defines 16 built-in sprites for the hexadecimal digits 0–F. Every interpreter must provide them — programs rely on these sprites to draw numbers on screen without embedding their own.
Sprite format
Each digit is 5 bytes tall and 4 pixels wide. The pixels occupy the upper 4 bits of each byte (the lower 4 are always zero):
digit 0: digit A:
0xF0 ████░░░░ 0xF0 ████░░░░
0x90 █░░█░░░░ 0x90 █░░█░░░░
0x90 █░░█░░░░ 0xF0 ████░░░░
0x90 █░░█░░░░ 0x90 █░░█░░░░
0xF0 ████░░░░ 0x90 █░░█░░░░
Memory layout
Fonts are loaded into 0x000 at startup — before any ROM. The address of digit N is simply N × 5:
0x000 digit 0 (5 bytes)
0x005 digit 1
0x00A digit 2
...
0x04B digit F
Loading in Memory::new()
#![allow(unused)] fn main() { const FONTS: [u8; 80] = [ 0xF0, 0x90, 0x90, 0x90, 0xF0, // 0 0x20, 0x60, 0x20, 0x20, 0x70, // 1 0xF0, 0x10, 0xF0, 0x80, 0xF0, // 2 // ... 13 more digits ]; mem.ram[FONT_START..FONT_START + FONTS.len()].copy_from_slice(&FONTS); }
Every Memory instance has fonts loaded from the very first instruction — they are part of initialization, not an optional setup step.
Opcode FX29 — LD F, Vx
Sets I to the address of the font sprite for digit Vx:
#![allow(unused)] fn main() { 0x29 => { self.i = (self.v[x] as usize * FONT_SPRITE_SIZE) as u16; } }
After this, DRW with N=5 draws that digit on screen.
Typical usage in a program
LD V0, 0xA ; digit to draw = A (10)
LD F, V0 ; I = address of 'A' sprite = 0x032
LD V1, 10 ; x position
LD V2, 5 ; y position
DRW V1, V2, 5 ; draw 5-row sprite at (10, 5)
The test program in main.rs uses exactly this pattern to draw all 16 digits across the screen.
Rust concepts used here:
copy_from_slicewith a range is covered in Slices and Ranges. The constant arrayFONTSuses the array syntax from Primitive Types and Arrays. See DRW — Drawing Sprites for how sprites are read from memory and drawn usingI.
The First Opcode: JP
With the CPU structure defined and memory in place, we can implement the fetch-decode-execute cycle. The first opcode to implement is JP — it is simple enough to understand the full cycle, and it exercises fetch, bitwise decoding, and the program counter.
The cycle
Every CPU works by repeating three steps:
1. Fetch — read the next instruction from memory
2. Decode — figure out what it says
3. Execute — do it
In CHIP-8, every instruction is exactly 2 bytes. The fetch step reads both bytes and combines them into a single 16-bit value.
Fetch
#![allow(unused)] fn main() { pub fn step(&mut self) { // read two bytes from memory at the current PC let high = self.memory.read(self.pc) as u16; let low = self.memory.read(self.pc + 1) as u16; let opcode = (high << 8) | low; // advance PC before executing self.pc += 2; self.execute(opcode); } }
high is shifted 8 bits left to occupy the upper byte, then OR'd with low to fill the lower byte. The result is the full 16-bit opcode.
PC advances by 2 before execute. This means when execute runs, PC already points to the next instruction. Jump opcodes simply overwrite PC with a new address.
Opcode 1NNN — JP addr
JP jumps to address NNN. The next instruction executed will be whatever lives at that address.
1NNN
│└──┘
│ └─ NNN: 12-bit target address
└──── opcode category 1
Example: 0x1300 means "jump to address 0x300".
Decode and execute
#![allow(unused)] fn main() { fn execute(&mut self, opcode: u16) { let nibble = (opcode & 0xF000) >> 12; // first nibble: category match nibble { 0x1 => { let nnn = opcode & 0x0FFF; // lower 12 bits: address self.pc = nnn; } _ => println!("unknown opcode: {:#06X}", opcode), } } }
Mask 0xF000 isolates the first nibble. The shift moves it down from bits 15–12 to bits 3–0, producing a value from 0 to 15. That value drives the match.
Mask 0x0FFF isolates the lower 12 bits — the address. No shift needed because the address goes directly into pc.
Test
#![allow(unused)] fn main() { #[test] fn test_jp() { let mut cpu = Cpu::new(); // 0x1300 = JP 0x300 cpu.memory.load_rom(&[0x13, 0x00]); cpu.step(); assert_eq!(cpu.pc, 0x300); } }
Rust concepts used here: Bitwise masking and shifting are covered in Bitwise Operations. The
matchon the first nibble is the pattern used for every opcode — see Pattern Matching. Theas u16casts in fetch are explained in Type Casting. The{:#06X}format specifier prints hex values with0xprefix, padded to 6 characters.
LD Vx, byte — Loading Immediate Values
6XNN loads the value NN directly into register Vx. It is one of the most used opcodes — nearly every program uses it to set up initial values.
Encoding
6XNN
│││└─ NN: 8-bit value to load
││└── X: register index (0–F)
└──── opcode category 6
Example: 0x6A42 means "load the value 0x42 into register VA (register 10)".
Decode and execute
#![allow(unused)] fn main() { 0x6 => { let x = ((opcode & 0x0F00) >> 8) as usize; let nn = (opcode & 0x00FF) as u8; self.v[x] = nn; } }
Two extractions:
Register index X — lives in bits 11–8 (the second nibble):
0x6A42 → mask 0x0F00 → 0x0A00 → shift right 8 → 0x0A = 10
Cast to usize because it will index into self.v[...].
Value NN — lives in bits 7–0 (the lower byte):
0x6A42 → mask 0x00FF → 0x0042 → as u8 → 0x42
Cast to u8 because registers are 8-bit.
Test
#![allow(unused)] fn main() { #[test] fn test_ld_vx_byte() { let mut cpu = Cpu::new(); // 0x6A42 = LD VA, 0x42 cpu.memory.load_rom(&[0x6A, 0x42]); cpu.step(); assert_eq!(cpu.v[0xA], 0x42); } }
Where this is used
In the test program in main.rs:
0x60, 0x00, // LD V0, 0 — digit index starts at 0
0x61, 0x00, // LD V1, 0 — x position starts at 0
0x62, 0x00, // LD V2, 0 — y position starts at 0
Before the loop even starts, three registers are initialized with 6XNN.
Rust concepts used here: The mask-then-shift pattern is Bitwise Operations. The
as usizeandas u8casts are Type Casting. This opcode establishes the extraction pattern that every subsequent opcode reuses.
LD I, addr — Setting the Index Register
Every time the CPU needs to read from or write to memory — drawing a sprite, loading a font, storing register values — it has to know where to look. That is what the index register I is for. And ANNN is the instruction that points it there.
Encoding
ANNN
│└──┘
│ └─ NNN: 12-bit address
└──── opcode category A
Example: 0xA300 sets I to address 0x300.
Decode and execute
#![allow(unused)] fn main() { 0xA => { let nnn = opcode & 0x0FFF; self.i = nnn; } }
Structurally identical to JP (1NNN) — extract the lower 12 bits and assign them. The only difference is the destination register: pc for JP, I here.
I as a prerequisite
Think of LD I as setup. It rarely does anything visible on its own. Its purpose is to prepare the ground for whatever comes next:
- Before drawing a sprite,
Imust point at the sprite bytes in memory. - Before drawing a digit,
FX29uses I to point at the font sprite for that digit. - Before storing registers to memory with
FX55,Imust point at the destination. - Before reading registers back with
FX65,Imust point at the source.
In almost every non-trivial sequence of CHIP-8 instructions, a LD I — or an opcode that sets I like FX29 — appears just before the operation that needs it. It is the pointer setup step.
Test
#![allow(unused)] fn main() { #[test] fn test_ld_i_addr() { let mut cpu = Cpu::new(); // 0xA300 = LD I, 0x300 cpu.memory.load_rom(&[0xA3, 0x00]); cpu.step(); assert_eq!(cpu.i, 0x300); } }
In the test program
The test program in main.rs draws the 16 hex digit sprites across the screen. Each iteration uses FX29 rather than ANNN directly — but FX29 is really just a specialized form of LD I that calculates the font address for you:
0xF0, 0x29, // LD F, V0 — I = address of font sprite for digit in V0
0xD1, 0x25, // DRW V1, V2, 5 — draw from I at position (V1, V2)
FX29 sets I, then DRW reads from I. The pattern is always the same: point first, then act.
Rust concepts used here: The
0x0FFFmask follows the same bitwise pattern established in The First Opcode: JP. The role ofIin the CPU is introduced in Registers and the CPU. See DRW — Drawing Sprites for the most common use of I, and Font Sprites for howFX29works.
ADD Vx, byte — Adding Without Carry
7XNN adds the value NN to register Vx. If the result overflows 255, it wraps around silently — VF is not affected.
Encoding
7XNN
│││└─ NN: 8-bit value to add
││└── X: register index (0–F)
└──── opcode category 7
Example: 0x7105 means "add 5 to V1".
Decode and execute
#![allow(unused)] fn main() { 0x7 => { let x = ((opcode & 0x0F00) >> 8) as usize; let nn = (opcode & 0x00FF) as u8; self.v[x] = self.v[x].wrapping_add(nn); } }
The extraction is identical to 6XNN. The only difference is the operation: wrapping_add instead of direct assignment.
Why wrapping_add?
Using plain + in debug builds would panic on overflow. wrapping_add wraps silently and consistently in both debug and release:
#![allow(unused)] fn main() { let x: u8 = 0xFF; x.wrapping_add(1) // → 0x00, never panics }
This is the correct CHIP-8 behavior: the spec says 7XNN wraps without setting any flag.
VF is intentionally not affected
This distinguishes 7XNN from 8XY4 (ADD Vx, Vy), which does write the carry to VF. The silent overflow in 7XNN is by design — programs that want to detect overflow use 8XY4 instead.
In the test program
0x71, 0x05, // ADD V1, 5 — advance x position by 5 after each digit
0x70, 0x01, // ADD V0, 1 — increment digit index
Each iteration of the loop moves the drawing position 5 pixels right and increments the digit counter. Both use 7XNN because they do not need carry detection.
Rust concepts used here:
wrapping_addis covered in Integer Overflow and Wrapping — specifically why+is not used directly in emulator arithmetic. The contrast with8XY4(which usesoverflowing_addto capture the carry) is shown in 8XY* — Arithmetic and Logic.
CALL and RET — Subroutines
These two opcodes work as a pair. CALL jumps into a subroutine and saves where to return. RET pops that return address and jumps back. Together they are the CHIP-8's mechanism for reusable code.
CALL 2NNN
Saves the current PC onto the stack, then jumps to address NNN.
2NNN
│└──┘
│ └─ NNN: subroutine address
└──── opcode category 2
#![allow(unused)] fn main() { 0x2 => { self.stack[self.sp as usize] = self.pc; // save return address self.sp += 1; self.pc = opcode & 0x0FFF; // jump to subroutine } }
The return address saved is the PC after the += 2 in step — meaning it points to the instruction immediately after the CALL. When the subroutine returns, execution continues from there.
RET 00EE
Pops the return address and jumps back to it.
#![allow(unused)] fn main() { 0x0 => match opcode { 0x00EE => { self.sp -= 1; self.pc = self.stack[self.sp as usize]; } // ... } }
SP decrements before reading because SP always points to the next free slot, not the top value. To read the top, go back one.
00EE shares the 0x0 prefix with 00E0 (CLS), so a nested match on the full opcode distinguishes them.
Stack state example
initial: sp = 0, stack = [_, ...]
CALL 0x400:
stack[0] = 0x202 ← return address (instruction after CALL)
sp = 1
pc = 0x400
RET:
sp = 0
pc = stack[0] = 0x202
Nesting
The stack supports 16 levels:
CALL 0x300 → stack[0] = 0x202, sp=1, pc=0x300
CALL 0x400 → stack[1] = 0x302, sp=2, pc=0x400
RET → sp=1, pc=0x302
RET → sp=0, pc=0x202
Each CALL pushes one entry; each RET pops one. SP tracks the depth.
Rust concepts used here: The nested
matchon the0x0group is the first time we see multi-level matching — see Pattern Matching. The stack is a fixed array ([u16; 16]), covered in Primitive Types and Arrays. The SP arithmetic usesas usizefor indexing — see Type Casting.
Conditional Opcodes — Skip Instructions
CHIP-8 has no if statement. Instead, it has four "skip" opcodes: they advance PC by an extra 2 bytes when a condition is true — jumping over the next instruction.
Combined with JP, this creates an if/else pattern.
How skip works
A normal fetch advances PC by 2. A skip adds another 2, so:
normal: fetches current instruction, moves to the next
skip: fetches current instruction, jumps over the next
The four skip opcodes
3XNN — SE Vx, byte (skip if Vx == NN)
#![allow(unused)] fn main() { 0x3 => { let x = ((opcode & 0x0F00) >> 8) as usize; let nn = (opcode & 0x00FF) as u8; if self.v[x] == nn { self.pc += 2; } } }
4XNN — SNE Vx, byte (skip if Vx != NN)
The inverse of 3XNN.
5XY0 — SE Vx, Vy (skip if Vx == Vy)
Compares two registers instead of a register and an immediate value:
#![allow(unused)] fn main() { 0x5 => { let x = ((opcode & 0x0F00) >> 8) as usize; let y = ((opcode & 0x00F0) >> 4) as usize; if self.v[x] == self.v[y] { self.pc += 2; } } }
9XY0 — SNE Vx, Vy (skip if Vx != Vy)
The inverse of 5XY0.
Typical usage
SE V0, 16 ; if digit counter == 16, skip next
JP 0x206 ; else loop back to the start
; (halt) ; execution falls here when V0 reaches 16
This is exactly how the test program in main.rs stops the loop:
#![allow(unused)] fn main() { 0x30, 0x10, // SE V0, 16 — if digit == 16, skip JP 0x12, 0x06, // JP 0x206 — else loop 0x12, 0x12, // JP 0x212 — halt (infinite loop) }
Summary
| Opcode | Condition |
|---|---|
3XNN | Vx == NN |
4XNN | Vx != NN |
5XY0 | Vx == Vy |
9XY0 | Vx != Vy |
Rust concepts used here: The extraction pattern uses Bitwise Operations and Type Casting. These opcodes do not introduce new Rust concepts — they reinforce the established masking pattern. See JP — The First Opcode and CALL and RET for the other control flow opcodes.
JP V0 — Computed Jumps
So far, every jump instruction has gone to a fixed address — a number baked into the opcode at the time the program was written. BNNN changes that. It adds the current value of V0 to the address, so where you land depends on what is in V0 at runtime.
This is a small change to the implementation, but a conceptually important one: the destination is no longer fixed at compile time. It can be different every time the instruction runs.
Encoding
BNNN
│└──┘
│ └─ NNN: base address
└──── opcode category B
Example: 0xB300 with V0 = 0x10 jumps to 0x310. The same instruction with V0 = 0x00 would jump to 0x300.
Decode and execute
#![allow(unused)] fn main() { 0xB => { let nnn = opcode & 0x0FFF; self.pc = nnn + self.v[0] as u16; } }
V0 must be cast to u16 before the addition — nnn is u16, and Rust does not silently promote types. Without the cast, the compiler rejects it.
Fixed vs computed: when each one makes sense
The distinction maps to a familiar programming concept:
| Opcode | Destination | Analogy |
|---|---|---|
1NNN | fixed address NNN | goto line_300 |
BNNN | NNN + V0 | goto table[index] |
BNNN is designed for jump tables. Imagine a game state machine with states 0, 1, and 2, each handled by code at a different address. Instead of a chain of conditionals, you can load the state into V0 and jump to a base address plus the offset — one instruction, any number of states.
LD V0, 2 ; state = 2
JP V0, 0x300 ; jump to 0x300 + 2 = 0x302 — handler for state 2
The handlers are laid out consecutively in memory starting at 0x300. V0 selects which one runs.
Rust concepts used here: The
as u16cast is explained in Type Casting. The opcode extraction follows the same0x0FFFmask pattern from The First Opcode: JP. See Conditional Opcodes for the other way to build branching logic in CHIP-8.
8XY* — Arithmetic and Logic
Nine opcodes share the 0x8 prefix. All operate on two registers Vx and Vy, with the last nibble identifying the specific operation. They are the CPU's complete set of register-to-register arithmetic.
Encoding
8XYN
│││└─ N: operation (0–7, E)
││└── Y: source register
│└─── X: destination register
└──── opcode category 8
Decode structure
All nine share the same setup:
#![allow(unused)] fn main() { 0x8 => { let x = ((opcode & 0x0F00) >> 8) as usize; let y = ((opcode & 0x00F0) >> 4) as usize; let op = opcode & 0x000F; match op { 0x0 => { /* LD */ } 0x1 => { /* OR */ } // ... } } }
The nine operations
0 — LD Vx, Vy — copy Vy into Vx.
1 — OR Vx, Vy
#![allow(unused)] fn main() { self.v[x] |= self.v[y]; }
2 — AND Vx, Vy
#![allow(unused)] fn main() { self.v[x] &= self.v[y]; }
3 — XOR Vx, Vy
#![allow(unused)] fn main() { self.v[x] ^= self.v[y]; }
4 — ADD Vx, Vy — adds with carry flag:
#![allow(unused)] fn main() { let (result, carry) = self.v[x].overflowing_add(self.v[y]); self.v[x] = result; self.v[0xF] = carry as u8; // 1 if overflow, 0 if not }
5 — SUB Vx, Vy — Vx = Vx - Vy, VF = 1 if no borrow:
#![allow(unused)] fn main() { let (result, borrow) = self.v[x].overflowing_sub(self.v[y]); self.v[x] = result; self.v[0xF] = !borrow as u8; // inverted: 1 means "did NOT borrow" }
6 — SHR Vx — shift right by 1, VF = bit shifted out:
#![allow(unused)] fn main() { self.v[0xF] = self.v[x] & 0x1; // save LSB before it disappears self.v[x] >>= 1; }
7 — SUBN Vx, Vy — Vx = Vy - Vx, VF = 1 if no borrow (reverse of SUB).
E — SHL Vx — shift left by 1, VF = bit shifted out:
#![allow(unused)] fn main() { self.v[0xF] = (self.v[x] >> 7) & 0x1; // save MSB before it disappears self.v[x] <<= 1; }
The VF ordering rule
For SHR and SHL, VF must be set before the shift — not after. The bit to save exists only before the operation; after the shift, it is gone. Getting this order wrong produces a subtly incorrect implementation that passes most tests but fails on edge cases.
Rust concepts used here: Compound assignments (
|=,&=,^=,>>=,<<=) are in Compound Assignment.overflowing_add/overflowing_subreturning(value, bool)uses Tuples and Destructuring. The VF ordering dependency is the same one called out in Compound Assignment.
ADD I, Vx — Advancing the Pointer
LD I points the index register at an address. But sometimes you need to move that pointer forward — to step through a sequence of sprites, or to store registers at consecutive locations. That is what FX1E does: it adds a register value to I, advancing the pointer by however many bytes you specify.
Encoding
The 0xF group contains several unrelated opcodes that share only their first nibble. They are distinguished by the lower byte:
FX1E
│││└─ E ┐
││└── 1 ┘ 0x1E identifies this specific opcode within the F group
│└─── X: source register
└──── opcode category F
Decode and execute
Because the 0xF group has multiple opcodes, the implementation uses a nested match on the lower byte:
#![allow(unused)] fn main() { 0xF => { let x = ((opcode & 0x0F00) >> 8) as usize; match opcode & 0x00FF { 0x1E => { self.i += self.v[x] as u16; } 0x29 => { /* LD F, Vx — set I to font sprite address */ } // other F opcodes... _ => println!("unknown: {:#06X}", opcode), } } }
self.v[x] is a u8; self.i is a u16. Rust requires an explicit cast before the addition — the types must match.
Why this is useful
Think of I as a cursor in memory. LD I places the cursor. ADD I, Vx moves it forward. Together they let you traverse memory one step at a time without hardcoding addresses.
A typical sequence might look like this:
LD I, 0x300 ; place cursor at the start of a sprite table
ADD I, V2 ; advance by the sprite index (V2 bytes)
DRW V0, V1, 5 ; draw the sprite now at the cursor position
The same pair of opcodes appears when storing or loading a block of registers with FX55 and FX65 — first position I, then optionally advance it to the next block.
Rust concepts used here: The nested match on the lower byte is the same pattern used in the
0x8group — see Pattern Matching. Theas u16cast is Type Casting. See LD I, addr for how I is initially set, and DRW — Drawing Sprites for the most common reason to adjust it.
RND — Random Numbers
CXNN generates a random byte, applies a bitmask, and stores the result in Vx.
Vx = random_byte & NN
Encoding
CXNN
│││└─ NN: mask applied to the random byte
││└── X: destination register
└──── opcode category C
Decode and execute
#![allow(unused)] fn main() { 0xC => { let x: usize = ((opcode & 0x0F00) >> 8) as usize; let nn: u8 = (opcode & 0x00FF) as u8; let rand: u8 = rand::thread_rng().r#gen(); self.v[x] = rand & nn; } }
rand::thread_rng().r#gen() generates a random u8. The r#gen() syntax is required because gen became a reserved keyword in Rust 2024 — see Raw Identifiers.
The AND with NN restricts which bits can be set:
mask 0xFF → all bits random (0–255)
mask 0x0F → only lower 4 bits random (0–15)
mask 0x00 → result is always 0
Adding the rand crate
This opcode requires the first external dependency:
[dependencies]
rand = "0.8"
And in the source file:
#![allow(unused)] fn main() { use rand::Rng; }
The Rng trait must be in scope for .r#gen() to be available — importing the crate alone is not enough.
Testing non-deterministic behavior
Since the result is random, tests cannot assert a specific value. Instead, they verify the invariant — that the mask was applied:
#![allow(unused)] fn main() { // mask 0x0F → upper 4 bits must always be zero assert_eq!(cpu.v[0] & 0xF0, 0); // mask 0x00 → result is always 0 assert_eq!(cpu.v[0], 0x00); }
Rust concepts used here: External crates and how to add them are in External Crates and Cargo.toml. Traits and why
use rand::Rngis required are in Traits. Ther#gen()syntax is Raw Identifiers.
CLS — Clear Screen
00E0 is one of the simplest opcodes in the specification: clear the display. Every pixel goes off. The screen is blank.
Implementing it is one line. But understanding when and why it is used reveals something important about how animation works in CHIP-8.
Encoding and implementation
00E0 is a fixed opcode — no parameters, no register indices, no addresses. It shares the 0x0 prefix with 00EE (RET), so the decode uses a nested match on the full opcode value:
#![allow(unused)] fn main() { 0x0 => match opcode { 0x00E0 => { self.display.clear(); } 0x00EE => { // RET } _ => println!("unknown: {:#06X}", opcode), }, }
And the implementation in the Display module:
#![allow(unused)] fn main() { pub fn clear(&mut self) { self.framebuffer = [[false; WIDTH]; HEIGHT]; } }
A single assignment resets the entire 64×32 boolean array. Rust initializes the whole thing at once — no loop, no index arithmetic.
Why animation needs CLS
The CHIP-8 display is a framebuffer: it remembers its state until something explicitly changes it. If you draw a sprite and then draw it again in a new position, the old one is still there. The DRW opcode uses XOR — drawing the same sprite twice erases it — but that only works if you redraw in exactly the same place. For anything moving freely on screen, the approach is different:
CLS— erase everything- Draw all sprites in their new positions
- Wait for the next frame
- Repeat
Without CLS, sprites leave a trail. With it, each frame is a fresh canvas.
In the test program in main.rs, we do not use CLS because we draw the font digits once at startup and never move them. But in any game — a ball moving, a player walking — the CLS at the top of each frame is essential.
Rust concepts used here: The nested match is introduced in CALL and RET and covered in depth in Pattern Matching. The
[[false; WIDTH]; HEIGHT]array initialization uses the array syntax from Primitive Types and Arrays. See The Display for the framebuffer, and DRW — Drawing Sprites for the other side of the animation loop.
DRW — Drawing Sprites
DXYN is the only drawing opcode. It reads N bytes from memory starting at address I and draws them as a sprite at position (Vx, Vy). It sets VF to 1 if any pixel was erased — which is how games detect collisions.
Encoding
DXYN
│││└─ N: number of sprite rows (1–15)
││└── Y: register holding y coordinate
│└─── X: register holding x coordinate
└──── opcode category D
Example: 0xD125 — draw a 5-row sprite at (V1, V2), reading from address I.
Decode and execute
#![allow(unused)] fn main() { 0xD => { let x = self.v[((opcode & 0x0F00) >> 8) as usize] as usize; let y = self.v[((opcode & 0x00F0) >> 4) as usize] as usize; let n = (opcode & 0x000F) as usize; let sprite: Vec<u8> = (0..n) .map(|row| self.memory.read(self.i + row as u16)) .collect(); let collision = self.display.draw_sprite(x, y, &sprite); self.v[0xF] = collision as u8; } }
Note that X and Y come from the values of Vx and Vy, not the nibbles directly. The nibbles are register indices — the coordinates are whatever those registers contain at runtime.
Reading the sprite
#![allow(unused)] fn main() { let sprite: Vec<u8> = (0..n) .map(|row| self.memory.read(self.i + row as u16)) .collect(); }
(0..n) is a range from 0 to N-1. .map() reads each byte from memory at address I + row. .collect() gathers everything into a Vec<u8>. The sprite size is only known at runtime (it comes from the opcode), so a dynamic Vec is necessary.
Sprite format
Each byte is one row of 8 pixels. Bit 7 (MSB) is the leftmost pixel:
byte 0xF0 = 0b11110000:
████░░░░
The digit '0' is 5 bytes:
0xF0 ████░░░░
0x90 █░░█░░░░
0x90 █░░█░░░░
0x90 █░░█░░░░
0xF0 ████░░░░
Collision
draw_sprite uses XOR. If any lit pixel gets drawn over another lit pixel, it turns off. When that happens, draw_sprite returns true. The CPU stores this in VF:
#![allow(unused)] fn main() { self.v[0xF] = collision as u8; // 1 if collision, 0 if not }
Games use VF to detect when a ball hits a brick, when a player sprite overlaps an enemy, etc.
In the test program
#![allow(unused)] fn main() { 0xF0, 0x29, // LD F, V0 — I = address of font sprite for digit V0 0xD1, 0x25, // DRW V1, V2, 5 — draw 5-row sprite at (V1, V2) }
Each loop iteration: set I to the font sprite address, then draw it. The x position (V1) advances by 5 each time, drawing all 16 hex digits across the screen.
Rust concepts used here:
Vec<u8>as a dynamically-sized collection is Vec<T>. The(0..n).map(...).collect()iterator chain is Iterators. Thedraw_spriteimplementation usesfor (row, &byte) in sprite.iter().enumerate()— see Destructuring. See The Display for the XOR drawing logic and Font Sprites for howFX29provides the sprite address.
The Main Loop
With CPU, memory, and display in place, the final piece is wiring everything together in a loop that runs at 60 frames per second and puts pixels on screen.
The structure
loop:
1. run N CPU cycles (fetch → decode → execute)
2. convert framebuffer to pixels
3. update the window
4. poll input
5. wait for next frame (60 Hz)
Opening the window
We use the minifb crate for windowing:
#![allow(unused)] fn main() { const SCALE: usize = 12; let mut window = Window::new( "CHIP-8", WIDTH * SCALE, HEIGHT * SCALE, WindowOptions::default(), ) .unwrap(); window.set_target_fps(60); }
SCALE multiplies the 64×32 framebuffer up to a visible size. minifb stretches the pixel buffer to fill the window automatically. unwrap() panics if the window cannot be opened — acceptable for this project (see Result and unwrap).
CPU cycles per frame
CHIP-8 ran at roughly 500–700 Hz while the display refreshed at 60 Hz — about 8–12 instructions per frame. We run 10:
#![allow(unused)] fn main() { while window.is_open() && !window.is_key_down(Key::Escape) { for _ in 0..10 { cpu.step(); } // render... } }
This number is not standardized. Different ROMs expect different speeds and it can be tuned.
Converting the framebuffer to pixels
minifb expects a flat Vec<u32> — one u32 per pixel in ARGB format (0xAARRGGBB):
#![allow(unused)] fn main() { let mut buffer = vec![0u32; WIDTH * HEIGHT]; for y in 0..HEIGHT { for x in 0..WIDTH { buffer[y * WIDTH + x] = if cpu.display.pixel(x, y) { 0xFFFFFFFF // white — pixel on } else { 0xFF000000 // black — pixel off }; } } window.update_with_buffer(&buffer, WIDTH, HEIGHT).unwrap(); }
The framebuffer is 2D ([y][x]), but minifb needs a 1D array. The index y * WIDTH + x maps 2D coordinates to the flat array position.
Why a plain loop, not an iterator
The first attempt used a nested iterator chain with flat_map and move closures. The compiler rejected it because cpu.display was being moved on every call to the outer closure. A plain for loop borrows by reference on each iteration — no ownership problem. This is covered in detail in Ownership in Closures.
The complete loop
#![allow(unused)] fn main() { while window.is_open() && !window.is_key_down(Key::Escape) { for _ in 0..10 { cpu.step(); } let mut buffer = vec![0u32; WIDTH * HEIGHT]; for y in 0..HEIGHT { for x in 0..WIDTH { buffer[y * WIDTH + x] = if cpu.display.pixel(x, y) { 0xFFFFFFFF } else { 0xFF000000 }; } } window.update_with_buffer(&buffer, WIDTH, HEIGHT).unwrap(); } }
set_target_fps(60) caps the loop at 60 iterations per second. Without it, the CPU would run as fast as possible, consuming 100% of a core and making the emulator run far too fast.
Rust concepts used here:
Vec<u32>for the pixel buffer is Vec<T>..unwrap()on fallible operations is Result and unwrap. The ownership issue with closures and why the plain loop was chosen is Ownership in Closures. Theminifbcrate dependency is in External Crates.