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:

  1. Getting Started — setting up the project with Cargo
  2. The CPU — modeling the processor as a Rust struct
  3. Memory — the 4KB address space and how to organize code
  4. Fetch, Decode, Execute — the CPU loop and our first opcodes
  5. Data and Arithmetic Opcodes — loading values, doing math
  6. Control Flow Opcodes — jumps, calls, conditions, and randomness
  7. The Display — the framebuffer and drawing sprites
  8. 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 test after every opcode implementation to verify correctness, and cargo run to open the window and see pixels on screen. The modules section (Modules) covers how we split the project into cpu.rs, memory.rs, and display.rs as 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

OOPRust
classstruct + impl
constructornew() (convention, not a keyword)
methodfunction in impl
this / self&self or &mut self
inheritancedoesn't exist — use Traits
private/publicpub 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, and Display are all structs with their own impl blocks. Cpu owns a Memory and a Display as fields. The CHIP-8 Registers chapter describes what each field of Cpu represents in hardware terms. The Constructors chapter explains why new() is needed for Cpu specifically.

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

TypeBitsRangeUse in the CPU
u880–255V registers, SP
u16160–65535PC, 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 u8 or u16. 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

TraitWhat it does
Defaultprovides a "zero" value, enables Cpu::default()
Debugenables {:?} formatting, essential for tests and logging
Cloneenables 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. The Default trait comes up when discussing Constructors. The trait system is also what makes external crates like rand work — we bring the Rng trait 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 to 0x200, zeroes all registers, and delegates to Memory::new() and Display::new(). Memory::new() also loads the 16 font sprites into RAM at address 0x000 — something Default could 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:

DeclarationCompiler 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 pub which 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

AttributeEffect
#[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:

SpecifierWhat 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. rand powers the CXNN opcode that generates random numbers — see RND — Random Numbers. minifb opens 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

ConversionWhat happens
small → largezeros added on the left
large → smallupper bits discarded
any → usizerequired for array indexing

In the emulator: Type casting appears in nearly every opcode. The register index extracted from an opcode (u16) must be cast to usize before indexing into self.v[x]. Values loaded into registers must be cast to u8. 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 modeCommandBehavior
Debugcargo buildpanic at runtime
Releasecargo build --releasewraps 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_add is used for 7XNN (ADD Vx, byte) which wraps silently. overflowing_add and overflowing_sub are used for 8XY4 and 8XY5 which 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. The as casts are explained in Type Casting, and the match that 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 use wrapping_add and overflowing_add instead, 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:

TypeSize at compile time?Use case
[u8; 16]YesFixed hardware (registers, stack)
&[u8]NoROM 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_rom uses &[u8] so it works equally well with an inline test array like &[0x13, 0x00] or a file read from disk. The copy_from_slice pattern places the ROM at exactly 0x200. 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

TypeSizeLocationUse case
[T; N]fixed, compile-timestackregisters, framebuffer
&[T]unknown, compile-timeanywherefunction parameters, views
Vec<T>dynamic, runtimeheapcollections 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 the DXYN opcode to build the sprite byte array before passing it to draw_sprite. Vec<u32> is used in main.rs as the pixel buffer for minifb. 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 in draw_sprite to iterate over sprite rows with their index. The (0..n).map(...).collect() pattern builds the sprite byte array in the DXYN opcode. See DRW — Drawing Sprites and The Display. The collect() 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 match on the first nibble, with nested matches for the 0x0, 0x8, and 0xF groups. See The First Opcode: JP for where this structure first appears, 8XY* — Arithmetic and Logic for the inner match on the 0x8 group, 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) and 8XY5 (SUB with borrow) opcodes, where overflowing_add and overflowing_sub return (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 in draw_sprite to iterate over sprite rows. See The Display for the full implementation and DRW — Drawing Sprites for the opcode that calls it. The let (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 in main.rs when 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

ModeSyntaxWhen used
Immutable referencedefaultclosure only reads
Mutable referencedefault when neededclosure 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 plain for loop 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:

EditionYearNotable changes
2015original
2018async/await, module improvements
2021closure capture improvements
2024latestgen 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 the CXNN opcode implementation. See RND — Random Numbers for the full implementation, and External Crates for how the rand crate 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:

OpcodeWhat VF receives
ADD Vx, Vy1 if result overflowed (carry), 0 otherwise
SUB Vx, Vy1 if Vx ≥ Vy (no borrow), 0 otherwise
SHR Vxthe bit shifted out
DRW Vx, Vy, n1 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:

  1. Read the 2-byte opcode at memory[pc] and memory[pc + 1]
  2. Advance pc by 2
  3. 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

RegisterSizeRole
V0–VE8-bitGeneral-purpose data
VF8-bitStatus flag (carry, borrow, collision)
I16-bitMemory address pointer
PC16-bitNext instruction address
SP8-bitStack depth counter
stack16 × 16-bitReturn address storage

Rust concepts used here: The CPU is modeled as a struct — see Structs and impl Blocks. The choice of u8 and u16 for each field is explained in Primitive Types and Arrays. The new() function that initializes PC to 0x200 is 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 for load_rom is explained in Slices and Ranges. The private ram field and pub methods 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_slice with a range is covered in Slices and Ranges. The constant array FONTS uses the array syntax from Primitive Types and Arrays. See DRW — Drawing Sprites for how sprites are read from memory and drawn using I.

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 match on the first nibble is the pattern used for every opcode — see Pattern Matching. The as u16 casts in fetch are explained in Type Casting. The {:#06X} format specifier prints hex values with 0x prefix, 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 usize and as u8 casts 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, I must point at the sprite bytes in memory.
  • Before drawing a digit, FX29 uses I to point at the font sprite for that digit.
  • Before storing registers to memory with FX55, I must point at the destination.
  • Before reading registers back with FX65, I must 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 0x0FFF mask follows the same bitwise pattern established in The First Opcode: JP. The role of I in the CPU is introduced in Registers and the CPU. See DRW — Drawing Sprites for the most common use of I, and Font Sprites for how FX29 works.

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_add is covered in Integer Overflow and Wrapping — specifically why + is not used directly in emulator arithmetic. The contrast with 8XY4 (which uses overflowing_add to 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 match on the 0x0 group 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 uses as usize for 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

OpcodeCondition
3XNNVx == NN
4XNNVx != NN
5XY0Vx == Vy
9XY0Vx != 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:

OpcodeDestinationAnalogy
1NNNfixed address NNNgoto line_300
BNNNNNN + V0goto 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 u16 cast is explained in Type Casting. The opcode extraction follows the same 0x0FFF mask 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_sub returning (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 0x8 group — see Pattern Matching. The as u16 cast 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::Rng is required are in Traits. The r#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:

  1. CLS — erase everything
  2. Draw all sprites in their new positions
  3. Wait for the next frame
  4. 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. The draw_sprite implementation uses for (row, &byte) in sprite.iter().enumerate() — see Destructuring. See The Display for the XOR drawing logic and Font Sprites for how FX29 provides 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. The minifb crate dependency is in External Crates.