Chip-8 Book
repository·master·Indexed 43 days ago
https://github.com/aquova/chip8-bookAn educational resource and tutorial for building a Chip-8 emulator from scratch using Rust, targeting both desktop and WebAssembly (Wasm). It includes the chip8_core crate (v0.1.0) featuring the Emu struct for core emulation logic and the EmuWasm class for browser-based rendering and input handling.
What's inside chip8-book
- This project is an introductory tutorial for developing a Chip-8 emulator using the Rust programming language. It targets both desktop environments and web browsers via WebAssembly (Wasm). The tutorial is designed for developers with basic Rust knowledge and no prior emulation experience. It covers the theoretical components of emulation, the inner workings of the Chip-8 system, and provides a step-by-step implementation walkthrough.
Implement Arithmetic and Bitwise Operations
masterArithmetic operations often interact with the
VFflag register (the 16th V register,self.v_reg[0xF]), which stores carry or borrow information.- VX = NN (6XNN): Set register
VXto constantNN. - VX += NN (7XNN): Add
NNtoVXusingwrapping_addto prevent Rust panics. - VX += VY (8XY4): Add
VYtoVX. SetVFto 1 if an overflow occurs, otherwise 0. Useoverflowing_add. - VX -= VY (8XY5): Subtract
VYfromVX. SetVFto 0 if a borrow occurs (underflow), otherwise 1. Useoverflowing_sub. - VX = VY (8XY0): Copy value from
VYtoVX. - Bitwise OR (8XY1):
VX |= VY. - Bitwise XOR (8XY2):
VX ^= VY(implied by context). - Bitwise AND (8XY3):
VX &= VY(implied by context). - Right Shift (8XY6): Shift
VXright by 1. Store the dropped Least Significant Bit (LSB) inVF. - Left Shift (8XYE): Shift
VXleft by 1. Store the dropped Most Significant Bit (MSB) inVF. - VX = VY - VX (8XY7): Subtract
VXfromVYand store inVX. SetVFto 1 if no borrow occurs, 0 if borrow occurs.
// VX += VY with Carry Flag (8, _, _, 4) => { let x = digit2 as usize; let y = digit3 as usize; let (new_vx, carry) = self.v_reg[x].overflowing_add(self.v_reg[y]); let new_vf = if carry { 1 } else { 0 }; self.v_reg[x] = new_vx; self.v_reg[0xF] = new_vf; }- VX = NN (6XNN): Set register
What are Chip-8 Registers?
masterRegisters are small, named, high-speed storage locations used directly by the CPU during opcode execution.
There are two types of registers in Chip-8:
General Purpose Registers (V0-VF): Sixteen 8-bit registers named
V0throughVF(or0throughFin hex). These can be used freely by the programmer for data storage.- Example: The opcode
8XY4performsVX += VY. If the opcode is8124, it adds the value inV1to the value inV2and stores the result inV1.
- Example: The opcode
Program Counter (PC): A 16-bit register that holds the memory address (index) of the instruction currently being executed. The PC typically increments by 2 bytes after every instruction to move to the next opcode, but certain instructions can change the PC to jump to a different part of the program.
Implement Subroutine Call and Return
masterSubroutines (functions) allow the program to jump to a specific address and later return to the original execution point. This is managed using a stack.
- Call Subroutine (2NNN): Push the current Program Counter (
self.pc) onto the stack, then setself.pcto the target addressnnn. - Return from Subroutine (00EE): Pop the return address from the stack and set
self.pcto that value.
// CALL NNN (2, _, _, _) => { let nnn = op & 0xFFF; self.push(self.pc); self.pc = nnn; } // RET (0, 0, 0xE, 0xE) => { let ret_addr = self.pop(); self.pc = ret_addr; }- Call Subroutine (2NNN): Push the current Program Counter (
Implement Wait for Key Press (FX0A)
masterThe
FX0Ainstruction is a blocking operation. The emulator must pause and wait until a key is pressed.Implementation Strategy: To avoid blocking the entire system (which would prevent new key presses from being registered), do not use a simple
whileloop. Instead, check if any key is pressed; if not, decrement the Program Counter (self.pc -= 2) to re-execute the same opcode in the next fetch cycle. This allows the main loop to continue running and updating thekeysarray.When a key is found, store its index in
VX.// WAIT KEY (0xF, _, 0, 0xA) => { let x = digit2 as usize; let mut pressed = false; for i in 0..self.keys.len() { if self.keys[i] { self.v_reg[x] = i as u8; pressed = true; break; } } if !pressed { // Redo opcode to allow key registration in main loop self.pc -= 2; } }How the Chip-8 CPU and opcodes work
masterThe CPU is responsible for performing mathematical operations (addition, subtraction, equality checks) and control flow (jumping to different code sections, fetching/saving numbers).
Key concepts:
- Opcode: A unique number representing a specific mathematical or logical operation.
- Tick/Cycle: The process of the emulator grabbing the next opcode from the ROM and executing the corresponding operation.
- Opcode Layout: Opcodes are two bytes long. They use specific patterns to pack information. In documentation, literal hexadecimal values are represented by:
N: Single digitNN: Two digitsNNN: Three digitsXorY: Specifies a register index.
Implement Conditional Skips
masterChip-8 uses 'skip' instructions to provide if-else functionality. If the condition is met, the Program Counter (
self.pc) is incremented by 2 to skip the next instruction.- Skip if VX == NN (3XNN): Skip if register
VXequals the constantNN. - Skip if VX != NN (4XNN): Skip if register
VXdoes not equal the constantNN. - Skip if VX == VY (5XY0): Skip if register
VXequals registerVY. - Skip if VX != VY (9XY0): Skip if register
VXdoes not equal registerVY. - Skip if Key Pressed (E9E): Skip if the key indexed by
VXis currently pressed. - Skip if Key Not Pressed (E A1): Skip if the key indexed by
VXis not currently pressed.
// SKIP VX == NN (3, _, _, _) => { let x = digit2 as usize; let nn = (op & 0xFF) as u8; if self.v_reg[x] == nn { self.pc += 2; } }- Skip if VX == NN (3XNN): Skip if register
Implement Binary-Coded Decimal (FX33)
masterThe
FX33instruction converts the 8-bit value inVXinto its Binary-Coded Decimal (BCD) representation and stores the three resulting digits in RAM starting at the address in theIregister.Example: If
VXis0x64(decimal 100), the bytes stored inRAM[I],RAM[I+1], andRAM[I+2]will be1,0, and0respectively.Implementation Logic:
- Hundreds digit:
(vx / 100.0).floor() - Tens digit:
((vx / 10.0) % 10.0).floor() - Ones digit:
(vx % 10.0)
// BCD (0xF, _, 3, 3) => { let x = digit2 as usize; let vx = self.v_reg[x] as f32; let hundreds = (vx / 100.0).floor() as u8; let tens = ((vx / 10.0) % 10.0).floor() as u8; let ones = (vx % 10.0) as u8; self.ram[self.i_reg as usize] = hundreds; self.ram[(self.i_reg + 1) as usize] = tens; self.ram[(self.i_reg + 2) as usize] = ones; }- Hundreds digit:
How Chip-8 RAM and ROM interact
masterChip-8 uses RAM (Random Access Memory) as a large array of numbers for the CPU to read and write.
Memory Specifications:
- RAM Size: While not strictly defined by the original hardware, emulators typically implement 4096 bytes (4 KB) of RAM.
- ROM Loading: The CPU cannot read directly from the ROM file. Instead, the entire game ROM must be copied into RAM at startup.
- Memory Offset: ROM data is not loaded at the beginning of RAM. It is offset by 512 bytes (0x200). This means the first byte of the ROM is stored at RAM address
0x200, the second at0x201, and so on. - ROM Size Limit: Because the ROM is loaded into RAM, Chip-8 games are effectively limited to a maximum size of 4 KB.
Interpret Chip-8 Opcode notation
masterChip-8 opcodes use a specific shorthand notation to describe operations. When reading the opcode table, understand the following symbols:
- Hexadecimal digits (0-9, A-F): Interpreted literally to determine the specific operation.
- X or Y: Wildcards representing the index of a register (e.g.,
VXorVYrefers to the value stored in that specific register). - N: A literal hexadecimal value.
- NN: A two-digit hexadecimal value.
- NNN: A three-digit hexadecimal value.
Example: The instruction
0xD123matches theDXYNopcode pattern, whereVXisV1,VYisV2, andNis3. This instruction tells the system to draw an 8x3 sprite at coordinates (V1, V2).Implement Jump and Jump to V0 + NNN
masterJumps change the Program Counter (
self.pc) to a new address.- Jump (1NNN): Set
self.pcdirectly to the 12-bit addressnnnextracted from the opcode. - Jump to V0 + NNN (BNNN): Set
self.pcto the sum of the value in registerV0and the 12-bit addressnnnprovided in the opcode.
// JMP NNN (1, _, _, _) => { let nnn = op & 0xFFF; self.pc = nnn; } // JMP V0 + NNN (0xB, _, _, _) => { let nnn = op & 0xFFF; self.pc = (self.v_reg[0] as u16) + nnn; }- Jump (1NNN): Set
Understand Chip-8 ROM file structure
masterA Chip-8 ROM (Read-Only Memory) file contains the game logic and graphical assets required to run a game. It is a binary file consisting of a sequence of numbers, where every two bytes represent a single instruction called an opcode.
To inspect the raw contents of a ROM, use a hex editor. In a hex editor:
- The offset (left column) indicates the byte position from the start of the file.
- The data values (right column) are displayed in hexadecimal.
- Instructions are typically grouped in pairs because each opcode is two bytes long.