Rust Programming: From Jim to Him Pt 1 — CHIP8 Emulator
A quick dive into Rust and WebAssembly
Introduction
This year it is time to double down on learning Rust. Rust has been a language that has fascinated me for a few years now because of it’s unique approach to memory management. However, despite reading the book a few times and doing a couple odd ball projects, I haven’t taken the time to properly get my hands dirty; I am still at a level where I have to Google basic Rust syntax.
My path from Jim to Him has been broken down into quarters:
Q1 — Simple/guided projects.
The goal is to become comfortable with the syntax in various situations and gain broad exposure to the capabilities of the language. I’m not going to be a masochist here and bang my head against the wall trying to get things to work. While that learning style has value, and generally, I endorse it, being a full-time software engineer and a full-time college student means my wall already needs a break from my head. The objective here is to minimize friction and get comfortable simply using Rust.
Q2 — Rust/Cargo Deep Dive
Cargo is a behemoth on its own accord. After riding with the tricycles in the first three months of the year, it is time to take a deep dive into Cargo and Rust implementation specifics. A heuristic of mine is to be comfortable two layers below abstraction for any technology. This quarter will include going through a deep dive on the Rust and Cargo source code, issues, and community.
Q3 — Advanced Projects
Not going to elaborate too much, we’ve done some guided stuff, we took a deep dive into the language, it’s time to put everything to use.
Q4 — Open Source/Continued Advanced Projects
Again, not going to elaborate too much. Going to begin working on open source projects in Rust while continuing my own projects. It would be nice to start a large project here, but we will have to see.
Well, that is really all you need to know and likely more than you care about. I hate when a cooking recipe on an online blog is turned into a light novel, so let’s just to get to the point.
This project comes from the Chip8 book here https://github.com/aquova/chip8-book/blob/master/src/1-intro.md These notes are going to be the TLDR version of the Chip8 book. For deeper insights/questions, go there to view the source material.
Chip8 Introduction
Chip8 is a good architecture for beginning emulation development. The technical specifications are as follows:
- 63x32 monochrome display; sprites are 8 pixels wide but between 1 and 16 pixels tall
- 16 8-bit general purpose registers labeled V0-VF; VF doubles as flag register for overflow operations
- 16-bit program counter
- 1 16-bit register used as a pointer for memory access; I Register
- RAM; usually 4 KB
- 16 bit stack for subroutine calls and returns
- 16 key keyboard input
- 2 special registers for delay timer and sound timer
Download the game ROM files here: https://www.zophar.net/pdroms/chip8/chip-8-games-pack.html
Emulation Basics
What’s in a Chip-8 Game
ROM (Read Only Memory) are files that cannot be modified by the computer system. Inside all of the game logic and assets are contained. These instructions are 2 bytes each and represented by hexadecimal numbers, as seen in the picture from the original text below:
CPU
In this context the CPUs primary purpose is performing mathematical operations needed such as jumping to different sections of code or retrieving/saving data. The game ROM contains the instructions necessary for the CPU to execute. Each instruction has an associated opcode. Opcodes are 2 bytes each and contain instructions along with additional information relative to each opcode. N’s indicate hexadecimal literals where X or Y may specify a register.
Registers
Registers are single byte storage locations and are used directly when working with opcodes. CHIP8 utilizes 16 registers labeled V0-VF, or 0–15 in hexadecimal. Opcode are 2 bytes, so we will have to combine the contents of 2 registers to decode the instruction. The program counter (PC) is an additional memory register that serves a specific purpose. This register allows for a 16 bit value to be stored and is used for keeping track of instruction execution. The index of the current instruction is stored in the PC. Instructions may tell the PC to go somewhere else, but generally it will start at the first byte and increment by 2 bytes each execution cycle.
RAM
The Chip8 standard for RAM is 4096 bytes (4kb). Chip8 is not a physical system so this is more of a convention than a hard requirement. The Chip8 CPU has free read and write access to RAM. The CPU will take the game ROM and copy it into RAM — this circumvents the read only access for ROM files and makes read operations more efficient than reading from game files repeatedly. This process takes place at the start of the game. The ROM file is copied into RAM with a 512 byte (0x200) offset. This ties to historical physical system constraints. Previously, the Chip8 system needed the first 512 bytes of RAM to run. Despite resources no longer being a constraint, Chip8 games are still designed with this spec in mind.
Setup
Again, this is the TLDR version of the guided project. For deeper insights/questions, go here.
Use Cargo to initialize a Rust library named chip8_core:
cargo init chip8_core --lib
Create a separate package for the frontend:
cargo init desktop
This should be the file structure:
Test setup with:
cargo run
We will be working in chip8_core/src/lib.rs
First off, make sure the cargo.toml for the chip8 library looks like this
[package]
name = "chip8_core"
version = "0.1.0"
authors = ["aquova <abrick912@gmail.com>"]
edition = "2018"
[dependencies]
rand = { version = "^0.7.3", features = ["wasm-bindgen"] }
Emulation is executing program originally targeted towards different system where we program a digital CPU to orchestrate the fetch-decode-execute loop.
Our data structure to represent the CPU in our backend will be a Rust struct. Add the following to chip8_core/src/lib.rs:
//pub keyword makes functionality accessible from external modules
pub struct Emu {
}
We will now add the program counter. The PC is responsible for the “fetch” in the fetch-decode-execute loop. It will be incremented throughout the game as it runs and may even be modified to jump to or exit from a subroutine.
pub struct Emu {
//u16 is the 16-bit unsigned integer type
pc: u16;
}
Remember, we will use 4kb of RAM for our ROM to load into.
//usize is pointer-sized unsigned integer type
//the size is how many bytes it takes to reference any
//memory location target
const RAM_SIZE: usize = 4096;
pub struct Emu {
pc: u16,
//RAM array of 8 byte unsigned integers that is 4kb in total size
//arrays are collections of objects of the same type, stored contiguously
//in memory
ram: [u8; RAM_SIZE];
}
The display will need to be accessible by the frontend. Chip8 utilizes a 64x32 monochrome display. Instead of the screen being cleared and redrawn every frame, Chip8 maintains the screen state and draws new sprites onto it.
The display is a 1-bit display, so we can utilize an array of Booleans.
pub const SCREEN_WIDTH: usize = 64;
pub const SCREEN_HEIGHT: usize = 32;
const RAM_SIZE: usize = 4096;
pub struct Emu {
pc: u16,
ram: [u8; RAM_SIZE],
//Display is represented with boolean array of size 64*32
screen: [bool; SCREEN_WIDTH * SCREEN_HEIGHT]
}
RAM access is considered slow, so we will also utilize 16 8-bit registers known as V registers. These are a performance improvement and are labeled V0 to VF. We will update our code below:
pub const SCREEN_WIDTH: usize = 64;
pub const SCREEN_HEIGHT: usize = 32;
const RAM_SIZE: usize = 4096;
const NUM_REGS: usize = 16;
pub struct Emu {
pc: u16,
ram: [u8; RAM_SIZE],
screen: [bool; SCREEN_WIDTH * SCREEN_HEIGHT],
v_reg: [u8; NUM_REGS],
}
There is another 16-bit register, known as the I register, which is used for indexing into RAM for read and write operations. We will simply need to add a u16 field called i_reg to our Emu struct.
pub const SCREEN_WIDTH: usize = 64;
pub const SCREEN_HEIGHT: usize = 32;
const RAM_SIZE: usize = 4096;
const NUM_REGS: usize = 16;
pub struct Emu {
pc: u16,
ram: [u8; RAM_SIZE],
screen: [bool; SCREEN_WIDTH * SCREEN_HEIGHT],
v_reg: [u8; NUM_REGS],
i_reg: u16,
}
Our CPU will utilize a 16-bit array based stack to enter or exit a subroutine. We utilize a simple array to avoid complications between our desktop and webassembly builds.
pub const SCREEN_WIDTH: usize = 64;
pub const SCREEN_HEIGHT: usize = 32;
const RAM_SIZE: usize = 4096;
const NUM_REGS: usize = 16;
//const declared for size of stack
const STACK_SIZE: usize = 16;
pub struct Emu {
pc: u16,
ram: [u8; RAM_SIZE],
screen: [bool; SCREEN_WIDTH * SCREEN_HEIGHT],
v_reg: [u8; NUM_REGS],
i_reg: u16,
//stack pointer used to track top of stack
sp: u16,
//array based stack
stack: [u16; STACK_SIZE],
}
Next we move on to key presses. Booleans can be used to track which keys are pressed. Chip8 supports a total of 16 different keys.
pub const SCREEN_WIDTH: usize = 64;
pub const SCREEN_HEIGHT: usize = 32;
const RAM_SIZE: usize = 4096;
const NUM_REGS: usize = 16;
const STACK_SIZE: usize = 16;
//const val for number of keys
const NUM_KEYS: usize = 16;
pub struct Emu {
pc: u16,
ram: [u8; RAM_SIZE],
screen: [bool; SCREEN_WIDTH * SCREEN_HEIGHT],
v_reg: [u8; NUM_REGS],
i_reg: u16,
sp: u16,
stack: [u16; STACK_SIZE],
//boolean key array to track states
keys: [bool; NUM_KEYS],
}
The last 2 special registers are both timers — delay and sound timers. Delay timer functions as a standard timer and counts down the clock cycles. The sound timer functions the same, but upon landing on 0 emits a sound. These registers are only 8-bit, so we can declare them as u8 fields in our Emu struct
pub const SCREEN_WIDTH: usize = 64;
pub const SCREEN_HEIGHT: usize = 32;
const RAM_SIZE: usize = 4096;
const NUM_REGS: usize = 16;
const STACK_SIZE: usize = 16;
const NUM_KEYS: usize = 16;
pub struct Emu {
pc: u16,
ram: [u8; RAM_SIZE],
screen: [bool; SCREEN_WIDTH * SCREEN_HEIGHT],
v_reg: [u8; NUM_REGS],
i_reg: u16,
sp: u16,
stack: [u16; STACK_SIZE],
keys: [bool; NUM_KEYS],
//8bit timer registers
dt: u8,
st: u8,
}
The final action in the setup phase is implementing the new constructor. Rust technically does not have constructors as a language construct. By convention, an associated function called new is used to create an object. Associated functions are the functions we define within our structs impl. The entirety of the code for this section is listed below:
pub const SCREEN_WIDTH: usize = 64;
pub const SCREEN_HEIGHT: usize = 32;
const RAM_SIZE: usize = 4096;
const NUM_REGS: usize = 16;
const STACK_SIZE: usize = 16;
const NUM_KEYS: usize = 16;
//start address offset by 0x200 bytes per chip8 system design
const START_ADDR: u16 = 0x200;
pub struct Emu {
pc: u16,
ram: [u8; RAM_SIZE],
screen: [bool; SCREEN_WIDTH * SCREEN_HEIGHT],
v_reg: [u8; NUM_REGS],
i_reg: u16,
sp: u16,
stack: [u16; STACK_SIZE],
keys: [bool; NUM_KEYS],
//8bit timer registers
dt: u8,
st: u8,
}
impl Emu {
//pub keyword exposes functionality to outside modules
// -> specifies the return type is of the Emu type (referenced by Self)
pub fn new() -> Self {
//create and return EMU initialized Emu object
Self {
pc: START_ADDR,
ram: [0; RAM_SIZE],
screen: [false; SCREEN_WIDTH * SCREEN_HEIGHT],
v_reg: [0; NUM_REGS],
i_reg: 0,
sp: 0,
stack: [0; STACK_SIZE],
keys: [false; NUM_KEYS],
dt: 0,
st: 0,
}
}
}
Emulation Methods
Push and pop for our stack:
impl Emu {
//pass a mutable reference to the push function
//to let the rust borrow checker know you may modify
//the referred value
//val is 16 bit value being added to our stack
fn push(&mut self, val: u16) {
self.stack[self.sp as usize] = val;
self.sp += 1;
}
//returns 16bit value from stack
fn pop(&mut self) -> u16 {
self.sp -= 1;
self.stack[self.sp as usize]
}
}
To render sprites, we will need to store the sprite data in the empty RAM buffer we created (the 0x200 offset). The screen is represented via a pixel binary pixel grid. A single bit that represents a white/black screen is assigned to each pixel. Sprites in Chip-8 are 8 pixels wide, meaning a pixel row requires a single byte. We will create a const array of bytes that will be loaded into the RAM buffer.
//this goes at the top of lib.rs
const FONTSET_SIZE: usize = 80;
const FONTSET: [u8; FONTSET_SIZE] = [
0xF0, 0x90, 0x90, 0x90, 0xF0, // 0
0x20, 0x60, 0x20, 0x20, 0x70, // 1
0xF0, 0x10, 0xF0, 0x80, 0xF0, // 2
0xF0, 0x10, 0xF0, 0x10, 0xF0, // 3
0x90, 0x90, 0xF0, 0x10, 0x10, // 4
0xF0, 0x80, 0xF0, 0x10, 0xF0, // 5
0xF0, 0x80, 0xF0, 0x90, 0xF0, // 6
0xF0, 0x10, 0x20, 0x40, 0x40, // 7
0xF0, 0x90, 0xF0, 0x90, 0xF0, // 8
0xF0, 0x90, 0xF0, 0x10, 0xF0, // 9
0xF0, 0x90, 0xF0, 0x90, 0x90, // A
0xE0, 0x90, 0xE0, 0x90, 0xE0, // B
0xF0, 0x80, 0x80, 0x80, 0xF0, // C
0xE0, 0x90, 0x90, 0x90, 0xE0, // D
0xF0, 0x80, 0xF0, 0x80, 0xF0, // E
0xF0, 0x80, 0xF0, 0x80, 0x80 // F
];
We will now update our Emu constructor to load the fontset into RAM and be able to reset the object without creating a new one.
pub fn new() -> Self {
let mut new_emu = Self {
pc: START_ADDR,
ram: [0; RAM_SIZE],
screen: [false; SCREEN_WIDTH * SCREEN_HEIGHT],
v_reg: [0; NUM_REGS],
i_reg: 0,
sp: 0,
stack: [0; STACK_SIZE],
keys: [false; NUM_KEYS],
dt: 0,
st: 0,
};
new_emu.ram[..FONTSET_SIZE].copy_from_slice(&FONTSET);
new_emu
}
pub fn reset(&mut self) {
self.pc = START_ADDR;
self.ram = [0; RAM_SIZE];
self.screen = [false; SCREEN_WIDTH * SCREEN_HEIGHT];
self.v_reg = [0; NUM_REGS];
self.i_reg = 0;
self.sp = 0;
self.stack = [0; STACK_SIZE];
self.keys = [false; NUM_KEYS];
self.dt = 0;
self.st = 0;
self.ram[..FONTSET_SIZE].copy_from_slice(&FONTSET);
}
We now move on to defining how the CPU processes the instructions. Our loop is as follows:
- Fetch value from our game in RAM at memory address in Program Counter
- Decode the instruction
- Execute instruction
- Move program counter to next instruction
- repeat
Here is the starter code for the tick function:
// -- Unchanged code omitted --
pub fn tick(&mut self) {
// Fetch
let op = self.fetch();
// Decode
// Execute
}
fn fetch(&mut self) -> u16 {
// TODO
}
We will need to begin thinking about opcodes. Chip8 supports 35 opcodes which we must implement. A Chip8 opcode is 2 bytes in length and contains the entire instruction to be executed. Here is the code for fetching an instruction:
fn fetch(&mut self) -> u16 {
let higher_byte = self.ram[self.pc as usize] as u16;
let lower_byte = self.ram[(self.pc + 1) as usize] as u16;
// << is a left shift by 8 bits, filling the
// the remaining digits with 0s
// | is a bitwise or operation that performs boolean OR on each bit
// of integer arguments
// we are effectively combining the values as Big Endian
let op = (higher_byte << 8) | lower_byte;
//increment pc by 2 bytes to factor in program counter
self.pc += 2;
op
}
Our timers will execute once per frame and need to be implemented separately.
pub fn tick_timers(&mut self) {
if self.dt > 0 {
self.dt -= 1;
}
if self.st > 0 {
if self.st == 1 {
//sound not implemented
}
self.st -= 1;
}
}
Opcode Execution
It is recommended you read the details about opcode execution here.
There are 35 total needing to be implemented, and taking the time to explain each one would be too much of reinventing the wheel. Instead, I trust you will reference the background information while I provide the finished code below:
use rand::Rng;
pub const SCREEN_WIDTH: usize = 64;
pub const SCREEN_HEIGHT: usize = 32;
const START_ADDR: u16 = 0x200;
const RAM_SIZE: usize = 4096;
const NUM_REGS: usize = 16;
const STACK_SIZE: usize = 16;
const NUM_KEYS: usize = 16;
const FONTSET_SIZE: usize = 80;
const FONTSET: [u8; FONTSET_SIZE] = [
0xF0, 0x90, 0x90, 0x90, 0xF0, // 0
0x20, 0x60, 0x20, 0x20, 0x70, // 1
0xF0, 0x10, 0xF0, 0x80, 0xF0, // 2
0xF0, 0x10, 0xF0, 0x10, 0xF0, // 3
0x90, 0x90, 0xF0, 0x10, 0x10, // 4
0xF0, 0x80, 0xF0, 0x10, 0xF0, // 5
0xF0, 0x80, 0xF0, 0x90, 0xF0, // 6
0xF0, 0x10, 0x20, 0x40, 0x40, // 7
0xF0, 0x90, 0xF0, 0x90, 0xF0, // 8
0xF0, 0x90, 0xF0, 0x10, 0xF0, // 9
0xF0, 0x90, 0xF0, 0x90, 0x90, // A
0xE0, 0x90, 0xE0, 0x90, 0xE0, // B
0xF0, 0x80, 0x80, 0x80, 0xF0, // C
0xE0, 0x90, 0x90, 0x90, 0xE0, // D
0xF0, 0x80, 0xF0, 0x80, 0xF0, // E
0xF0, 0x80, 0xF0, 0x80, 0x80 // F
];
pub struct Emu {
pc: u16,
ram: [u8; RAM_SIZE],
screen: [bool; SCREEN_WIDTH * SCREEN_HEIGHT],
v_reg: [u8; NUM_REGS],
i_reg: u16,
sp: u16,
stack: [u16; STACK_SIZE],
keys: [bool; NUM_KEYS],
dt: u8,
st: u8,
}
impl Emu {
pub fn new() -> Self {
let mut new_emu = Self {
pc: START_ADDR,
ram: [0; RAM_SIZE],
screen: [false; SCREEN_WIDTH * SCREEN_HEIGHT],
v_reg: [0; NUM_REGS],
i_reg: 0,
sp: 0,
stack: [0; STACK_SIZE],
keys: [false; NUM_KEYS],
dt: 0,
st: 0,
};
new_emu.ram[..FONTSET_SIZE].copy_from_slice(&FONTSET);
new_emu
}
pub fn reset(&mut self) {
self.pc = START_ADDR;
self.ram = [0; RAM_SIZE];
self.screen = [false; SCREEN_WIDTH * SCREEN_HEIGHT];
self.v_reg = [0; NUM_REGS];
self.i_reg = 0;
self.sp = 0;
self.stack = [0; STACK_SIZE];
self.keys = [false; NUM_KEYS];
self.dt = 0;
self.st = 0;
self.ram[..FONTSET_SIZE].copy_from_slice(&FONTSET);
}
fn push(&mut self, val: u16) {
self.stack[self.sp as usize] = val;
self.sp += 1;
}
fn pop(&mut self) -> u16 {
self.sp -= 1;
self.stack[self.sp as usize]
}
pub fn tick(&mut self) {
// Fetch
let op = self.fetch();
// Decode & execute
self.execute(op);
}
pub fn get_display(&self) -> &[bool] {
&self.screen
}
pub fn keypress(&mut self, idx: usize, pressed: bool) {
self.keys[idx] = pressed;
}
pub fn load(&mut self, data: &[u8]) {
let start = START_ADDR as usize;
let end = (START_ADDR as usize) + data.len();
self.ram[start..end].copy_from_slice(data);
}
pub fn tick_timers(&mut self) {
if self.dt > 0 {
self.dt -= 1;
}
if self.st > 0 {
if self.st == 1 {
// BEEP
}
self.st -= 1;
}
}
fn fetch(&mut self) -> u16 {
let higher_byte = self.ram[self.pc as usize] as u16;
let lower_byte = self.ram[(self.pc + 1) as usize] as u16;
let op = (higher_byte << 8) | lower_byte;
self.pc += 2;
op
}
fn execute(&mut self, op: u16) {
let digit1 = (op & 0xF000) >> 12;
let digit2 = (op & 0x0F00) >> 8;
let digit3 = (op & 0x00F0) >> 4;
let digit4 = op & 0x000F;
match (digit1, digit2, digit3, digit4) {
// NOP
(0, 0, 0, 0) => return,
// CLS
(0, 0, 0xE, 0) => {
self.screen = [false; SCREEN_WIDTH * SCREEN_HEIGHT];
},
// RET
(0, 0, 0xE, 0xE) => {
let ret_addr = self.pop();
self.pc = ret_addr;
},
// JMP NNN
(1, _, _, _) => {
let nnn = op & 0xFFF;
self.pc = nnn;
},
// CALL NNN
(2, _, _, _) => {
let nnn = op & 0xFFF;
self.push(self.pc);
self.pc = nnn;
},
// 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 VX != NN
(4, _, _, _) => {
let x = digit2 as usize;
let nn = (op & 0xFF) as u8;
if self.v_reg[x] != nn {
self.pc += 2;
}
},
// SKIP VX == VY
(5, _, _, _) => {
let x = digit2 as usize;
let y = digit3 as usize;
if self.v_reg[x] == self.v_reg[y] {
self.pc += 2;
}
},
// VX = NN
(6, _, _, _) => {
let x = digit2 as usize;
let nn = (op & 0xFF) as u8;
self.v_reg[x] = nn;
},
// VX += NN
(7, _, _, _) => {
let x = digit2 as usize;
let nn = (op & 0xFF) as u8;
self.v_reg[x] = self.v_reg[x].wrapping_add(nn);
},
// VX = VY
(8, _, _, 0) => {
let x = digit2 as usize;
let y = digit3 as usize;
self.v_reg[x] = self.v_reg[y];
},
// VX |= VY
(8, _, _, 1) => {
let x = digit2 as usize;
let y = digit3 as usize;
self.v_reg[x] |= self.v_reg[y];
},
// VX &= VY
(8, _, _, 2) => {
let x = digit2 as usize;
let y = digit3 as usize;
self.v_reg[x] &= self.v_reg[y];
},
// VX ^= VY
(8, _, _, 3) => {
let x = digit2 as usize;
let y = digit3 as usize;
self.v_reg[x] ^= self.v_reg[y];
},
// VX += VY
(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 -= VY
(8, _, _, 5) => {
let x = digit2 as usize;
let y = digit3 as usize;
let (new_vx, borrow) = self.v_reg[x].overflowing_sub(self.v_reg[y]);
let new_vf = if borrow { 0 } else { 1 };
self.v_reg[x] = new_vx;
self.v_reg[0xF] = new_vf;
},
// VX >>= 1
(8, _, _, 6) => {
let x = digit2 as usize;
let lsb = self.v_reg[x] & 1;
self.v_reg[x] >>= 1;
self.v_reg[0xF] = lsb;
},
// VX = VY - VX
(8, _, _, 7) => {
let x = digit2 as usize;
let y = digit3 as usize;
let (new_vx, borrow) = self.v_reg[y].overflowing_sub(self.v_reg[x]);
let new_vf = if borrow { 0 } else { 1 };
self.v_reg[x] = new_vx;
self.v_reg[0xF] = new_vf;
},
// VX <<= 1
(8, _, _, 0xE) => {
let x = digit2 as usize;
let msb = (self.v_reg[x] >> 7) & 1;
self.v_reg[x] <<= 1;
self.v_reg[0xF] = msb;
},
// SKIP VX != VY
(9, _, _, 0) => {
let x = digit2 as usize;
let y = digit3 as usize;
if self.v_reg[x] != self.v_reg[y] {
self.pc += 2;
}
},
// I = NNN
(0xA, _, _, _) => {
let nnn = op & 0xFFF;
self.i_reg = nnn;
},
// JMP V0 + NNN
(0xB, _, _, _) => {
let nnn = op & 0xFFF;
self.pc = (self.v_reg[0] as u16) + nnn;
},
// VX = rand() & NN
(0xC, _, _, _) => {
let x = digit2 as usize;
let nn = (op & 0xFF) as u8;
let rng: u8 = rand::thread_rng().gen();
self.v_reg[x] = rng & nn;
},
// DRAW
(0xD, _, _, _) => {
// Get the (x, y) coords for our sprite
let x_coord = self.v_reg[digit2 as usize] as u16;
let y_coord = self.v_reg[digit3 as usize] as u16;
// The last digit determines how many rows high our sprite is
let num_rows = digit4;
// Keep track if any pixels were flipped
let mut flipped = false;
// Iterate over each row of our sprite
for y_line in 0..num_rows {
// Determine which memory address our row's data is stored
let addr = self.i_reg + y_line as u16;
let pixels = self.ram[addr as usize];
// Iterate over each column in our row
for x_line in 0..8 {
// Use a mask to fetch current pixel's bit. Only flip if a 1
if (pixels & (0b1000_0000 >> x_line)) != 0 {
// Sprites should wrap around screen, so apply modulo
let x = (x_coord + x_line) as usize % SCREEN_WIDTH;
let y = (y_coord + y_line) as usize % SCREEN_HEIGHT;
// Get our pixel's index in the 1D screen array
let idx = x + SCREEN_WIDTH * y;
// Check if we're about to flip the pixel and set
flipped |= self.screen[idx];
self.screen[idx] ^= true;
}
}
}
// Populate VF register
if flipped {
self.v_reg[0xF] = 1;
} else {
self.v_reg[0xF] = 0;
}
},
// SKIP KEY PRESS
(0xE, _, 9, 0xE) => {
let x = digit2 as usize;
let vx = self.v_reg[x];
let key = self.keys[vx as usize];
if key {
self.pc += 2;
}
},
// SKIP KEY RELEASE
(0xE, _, 0xA, 1) => {
let x = digit2 as usize;
let vx = self.v_reg[x];
let key = self.keys[vx as usize];
if !key {
self.pc += 2;
}
},
// VX = DT
(0xF, _, 0, 7) => {
let x = digit2 as usize;
self.v_reg[x] = self.dt;
},
// 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
self.pc -= 2;
}
},
// DT = VX
(0xF, _, 1, 5) => {
let x = digit2 as usize;
self.dt = self.v_reg[x];
},
// ST = VX
(0xF, _, 1, 8) => {
let x = digit2 as usize;
self.st = self.v_reg[x];
},
// I += VX
(0xF, _, 1, 0xE) => {
let x = digit2 as usize;
let vx = self.v_reg[x] as u16;
self.i_reg = self.i_reg.wrapping_add(vx);
},
// I = FONT
(0xF, _, 2, 9) => {
let x = digit2 as usize;
let c = self.v_reg[x] as u16;
self.i_reg = c * 5;
},
// BCD
(0xF, _, 3, 3) => {
let x = digit2 as usize;
let vx = self.v_reg[x] as f32;
// Fetch the hundreds digit by dividing by 100 and tossing the decimal
let hundreds = (vx / 100.0).floor() as u8;
// Fetch the tens digit by dividing by 10, tossing the ones digit and the decimal
let tens = ((vx / 10.0) % 10.0).floor() as u8;
// Fetch the ones digit by tossing the hundreds and the tens
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;
},
// STORE V0 - VX
(0xF, _, 5, 5) => {
let x = digit2 as usize;
let i = self.i_reg as usize;
for idx in 0..=x {
self.ram[i + idx] = self.v_reg[idx];
}
},
// LOAD V0 - VX
(0xF, _, 6, 5) => {
let x = digit2 as usize;
let i = self.i_reg as usize;
for idx in 0..=x {
self.v_reg[idx] = self.ram[i + idx];
}
},
(_, _, _, _) => unimplemented!("Unimplemented opcode: {:#04x}", op),
}
}
}
Writing Our Desktop Frontend
Lets update our Emu object to expose the core to the frontend
impl Emu {
// -- Unchanged code omitted --
//pub makes the screen accessible to external modules
pub fn get_display(&self) -> &[bool] {
//pointer to screen buffer returned to render display
&self.screen
}
// -- Unchanged code omitted --
}
Handling key presses:
impl Emu {
// -- Unchanged code omitted --
//this function exposes an interface to write to our keys array
pub fn keypress(&mut self, idx: usize, pressed: bool) {
self.keys[idx] = pressed;
}
// -- Unchanged code omitted --
}
The last backend update we will need to make is add the functionality to load the ROM into RAM.
impl Emu {
// -- Unchanged code omitted --
pub fn load(&mut self, data: &[u8]) {
let start = START_ADDR as usize;
let end = (START_ADDR as usize) + data.len();
self.ram[start..end].copy_from_slice(data);
}
// -- Unchanged code omitted --
}
Frontend setup:
//desktop/src/main.rs
use std::env;
fn main() {
//read command line args for game ROM path
let args: Vec<_> = env::args().collect();
if args.len() != 2 {
println!("Usage: cargo run path/to/game");
return;
}
}
Creating a Window
Update desktop/cargo.toml to look like this:
[dependencies]
chip8_core = { path = "../chip8_core" }
sdl2 = "^0.34.3
Add the chip8 backend public functions to your frontend source code
//desktop/src/main.rs
//adding chip8 backend
use chip8_core::*;
use std::env;
fn main() {
let args: Vec<_> = env::args().collect();
if args.len() != 2 {
println!("Usage: cargo run path/to/game");
return;
}
}
We will now use the SDL module we added to our library to draw our games to the screen.
fn main() {
// -- Unchanged code omitted --
// Setup SDL
let sdl_context = sdl2::init().unwrap();
let video_subsystem = sdl_context.video().unwrap();
//create screen according to size and position in center of monitor
let window = video_subsystem
.window("Chip-8 Emulator", WINDOW_WIDTH, WINDOW_HEIGHT)
.position_centered()
.opengl()
.build()
.unwrap();
let mut canvas = window.into_canvas().present_vsync().build().unwrap();
canvas.clear();
canvas.present();
}
Next, lets add the code to utilize SDL’s event pumps for loop-based event polling
Add the following to the top of frontend file:
use sdl2:
Edit our main function as follows:
fn main() {
// -- Unchanged code omitted --
// Setup SDL
let sdl_context = sdl2::init().unwrap();
let video_subsystem = sdl_context.video().unwrap();
let window = video_subsystem
.window("Chip-8 Emulator", WINDOW_WIDTH, WINDOW_HEIGHT)
.position_centered()
.opengl()
.build()
.unwrap();
let mut canvas = window.into_canvas().present_vsync().build().unwrap();
canvas.clear();
canvas.present();
let mut event_pump = sdl_context.event_pump().unwrap();
//setup game loop
'gameloop: loop {
for evt in event_pump.poll_iter() {
match evt {
Event::Quit{..} => {
break 'gameloop;
},
_ => ()
}
}
}
}
Now that we can create an emulation window, lets add the code to read in a game file
use std::fs::File;
use std::io::Read;
fn main() {
// -- Unchanged code omitted --
let mut chip8 = Emu::new();
let mut rom = File::open(&args[1]).expect("Unable to open file");
let mut buffer = Vec::new();
rom.read_to_end(&mut buffer).unwrap();
chip8.load(&buffer);
'gameloop: loop {
// -- Unchanged code omitted --
}
}
Running Emulator
Add tick function to the gameloop
fn main() {
// -- Unchanged code omitted --
'gameloop: loop {
for event in event_pump.poll_iter() {
// -- Unchanged code omitted --
}
chip8.tick();
}
}
Timer will now tick everytime game loop runs. Add the following imports at the top of the frontend source file:
use sdl2::pixels::Color;
use sdl2::rect::Rect;
use sdl2::render::Canvas;
use sdl2::video::Window;
Draw screen steps:
- Clear canvas to erase previous frame
- Iterate through screen buffer and draw white square when true value found
fn draw_screen(emu: &Emu, canvas: &mut Canvas<Window>) {
// Clear canvas as black
canvas.set_draw_color(Color::RGB(0, 0, 0));
canvas.clear();
let screen_buf = emu.get_display();
// Now set draw color to white, iterate through each point and see if it should be drawn
canvas.set_draw_color(Color::RGB(255, 255, 255));
for (i, pixel) in screen_buf.iter().enumerate() {
if *pixel {
// Convert our 1D array's index into a 2D (x,y) position
let x = (i % SCREEN_WIDTH) as u32;
let y = (i / SCREEN_WIDTH) as u32;
// Draw a rectangle at (x,y), scaled up by our SCALE value
let rect = Rect::new((x * SCALE) as i32, (y * SCALE) as i32, SCALE, SCALE);
canvas.fill_rect(rect).unwrap();
}
}
canvas.present();
}
The next update to make is call this in the game loop:
fn main() {
// -- Unchanged code omitted --
'gameloop: loop {
for event in event_pump.poll_iter() {
// -- Unchanged code omitted --
}
chip8.tick();
draw_screen(&chip8, &mut canvas);
}
}
Test running game:
$ cargo run path/to/game
A window should have popped up. Next, we will set our TICKS_PER_FRAME constant and update our game loop. This is so our tick function can loop several times before drawing to the screen. Feel free to experiment.
const TICKS_PER_FRAME: usize = 10;
// -- Unchanged code omitted --
fn main() {
// -- Unchanged code omitted --
'gameloop: loop {
for event in event_pump.poll_iter() {
// -- Unchanged code omitted --
}
for _ in 0..TICKS_PER_FRAME {
chip8.tick();
}
draw_screen(&chip8, &mut canvas);
}
}
The last step to do for now is to update both timers in the gameloop at the same point as when the screen is modified
fn main() {
// -- Unchanged code omitted --
'gameloop: loop {
for event in event_pump.poll_iter() {
// -- Unchanged code omitted --
}
for _ in 0..TICKS_PER_FRAME {
chip8.tick();
}
//tick both values
chip8.tick_timers();
draw_screen(&chip8, &mut canvas);
}
}
Adding User Input
At the top of the file, put:
use sdl2::keyboard::Keycode;
Write the following function that takes in the keycode and outputs an 8-bit value:
fn key2btn(key: Keycode) -> Option<usize> {
match key {
Keycode::Num1 => Some(0x1),
Keycode::Num2 => Some(0x2),
Keycode::Num3 => Some(0x3),
Keycode::Num4 => Some(0xC),
Keycode::Q => Some(0x4),
Keycode::W => Some(0x5),
Keycode::E => Some(0x6),
Keycode::R => Some(0xD),
Keycode::A => Some(0x7),
Keycode::S => Some(0x8),
Keycode::D => Some(0x9),
Keycode::F => Some(0xE),
Keycode::Z => Some(0xA),
Keycode::X => Some(0x0),
Keycode::C => Some(0xB),
Keycode::V => Some(0xF),
_ => None,
}
}
Now, we will add KeyDown and KeyUp events in our game loop. Update it as follows:
fn main() {
// -- Unchanged code omitted --
'gameloop: loop {
for evt in event_pump.poll_iter() {
//pattern matching the event
match evt {
//quit program when pressing escape
Event::Quit{..} | Event::KeyDown{keycode: Some(Keycode::Escape), ..}=> {
break 'gameloop;
},
//track when key is pressed
Event::KeyDown{keycode: Some(key), ..} => {
//only satisfied if value on right matches on
//left
if let Some(k) = key2btn(key) {
chip8.keypress(k, true);
}
},
//track when key released
Event::KeyUp{keycode: Some(key), ..} => {
if let Some(k) = key2btn(key) {
chip8.keypress(k, false);
}
},
_ => ()
}
for _ in 0..TICKS_PER_FRAME {
chip8.tick();
}
chip8.tick_timers();
draw_screen(&chip8, &mut canvas);
}
// -- Unchanged code omitted --
}
WebAssembly
Web assembly allows us to run code originally meant for binary executable files in a browser instead. Rust, C, and C++ are the most supported WebAssembly languages.
We will need to install Rust’s wasm-pack with cargo.
cargo install wasm-pack
Then, we will run
cargo init wasm --lib
Inside of our main project directory, we should now have the chip8_core, desktop, and wasm directories at the same level in the hierarchy. Next, create a directory called web and a file to it called index.html. This is where we’ll put some basic HTML code.
<!DOCTYPE html>
<html>
<head>
<title>Chip-8 Emulator</title>
<meta charset="utf-8">
</head>
<body>
<h1>My Chip-8 Emulator</h1>
</body>
</html>
When you run your webassembly application, you will also need a web server to host on. Python 3 is capable of starting a simple web server via
$ python3 -m http.server
You should be able to see your webassembly application by navigating to localhost.
Defining Web Assembly API
Add the following to wasm/Cargo.toml
[dependencies]
chip8_core = { path = "../chip8_core" }
js-sys = "^0.3.46"
wasm-bindgen = "^0.2.69"
[dependencies.web-sys]
version = "^0.3.46"
features = [
"KeyboardEvent"
]
[lib]
crate-type = ["cdylib"]
We will now create a struct that contains our Emu object plus our frontend functionality.
// wasm/src/lib.rs
use chip8_core::*;
use wasm_bindgen::prelude::*;
use js_sys::Uint8Array;
//let compiler know to prep for webassembly
#[wasm_bindgen]
pub struct EmuWasm {
chip8: Emu,
}
#[wasm_bindgen]
impl EmuWasm {
//specify constructor for compiler
#[wasm_bindgen(constructor)]
pub fn new() -> EmuWasm {
EmuWasm {
chip8: Emu::new(),
}
}
#[wasm_bindgen]
pub fn tick(&mut self) {
self.chip8.tick();
}
#[wasm_bindgen]
pub fn tick_timers(&mut self) {
self.chip8.tick_timers();
}
#[wasm_bindgen]
pub fn reset(&mut self) {
self.chip8.reset();
}
#[wasm_bindgen]
pub fn keypress(&mut self, evt: KeyboardEvent, pressed: bool) {
let key = evt.key();
if let Some(k) = key2btn(&key) {
self.chip8.keypress(k, pressed);
}
}
#[wasm_bindgen]
pub fn load_game(&mut self, data: Uint8Array) {
self.chip8.load(&data.to_vec());
}
#[wasm_bindgen]
pub fn draw_screen(&mut self, scale: usize) {
// TODO
}
}
fn key2btn(key: &str) -> Option<usize> {
match key {
"1" => Some(0x1),
"2" => Some(0x2),
"3" => Some(0x3),
"4" => Some(0xC),
"q" => Some(0x4),
"w" => Some(0x5),
"e" => Some(0x6),
"r" => Some(0xD),
"a" => Some(0x7),
"s" => Some(0x8),
"d" => Some(0x9),
"f" => Some(0xE),
"z" => Some(0xA),
"x" => Some(0x0),
"c" => Some(0xB),
"v" => Some(0xF),
_ => None,
}
}
It is similar to the desktop implementation. Before we write the draw_screen code, lets setup our HTML. In index.html, insert:
<!DOCTYPE html>
<html>
<head>
<title>Chip-8 Emulator</title>
<meta charset="utf-8">
</head>
<body>
<h1>My Chip-8 Emulator</h1>
<label for="fileinput">Upload a Chip-8 game: </label>
<input type="file" id="fileinput" autocomplete="off"/>
<br/>
<canvas id="canvas">If you see this message, then your browser doesn't support HTML5</canvas>
</body>
<script type="module" src="index.js"></script>
</html>
This adds three things: a button, a message to users with an out of date browser, and to load a file called index.js. Create index.js in the same directory as your index.html, and then write:
import init, * as wasm from "./wasm.js"
//constants and basic setup
const WIDTH = 64
const HEIGHT = 32
const SCALE = 15
const TICKS_PER_FRAME = 10
let anim_frame = 0
const canvas = document.getElementById("canvas")
canvas.width = WIDTH * SCALE
canvas.height = HEIGHT * SCALE
const ctx = canvas.getContext("2d")
ctx.fillStyle = "black"
ctx.fillRect(0, 0, WIDTH * SCALE, HEIGHT * SCALE)
const input = document.getElementById("fileinput")
//load EmuWasm and handl main emulation
async function run() {
await init()
let chip8 = new wasm.EmuWasm()
document.addEventListener("keydown", function(evt) {
chip8.keypress(evt, true)
})
document.addEventListener("keyup", function(evt) {
chip8.keypress(evt, false)
})
input.addEventListener("change", function(evt) {
// Handle file loading
}, false)
}
run().catch(console.error)
//loading in file when button pressed
input.addEventListener("change", function(evt) {
// Stop previous game from rendering, if one exists
if (anim_frame != 0) {
window.cancelAnimationFrame(anim_frame)
}
let file = evt.target.files[0]
if (!file) {
alert("Failed to read file")
return
}
// Load in game as Uint8Array, send to .wasm, start main loop
let fr = new FileReader()
fr.onload = function(e) {
let buffer = fr.result
const rom = new Uint8Array(buffer)
chip8.reset()
chip8.load_game(rom)
mainloop(chip8)
}
fr.readAsArrayBuffer(file)
}, false)
//main emulation loop
function mainloop(chip8) {
// Only draw every few ticks
for (let i = 0; i < TICKS_PER_FRAME; i++) {
chip8.tick()
}
chip8.tick_timers()
// Clear the canvas before drawing
ctx.fillStyle = "black"
ctx.fillRect(0, 0, WIDTH * SCALE, HEIGHT * SCALE)
// Set the draw color back to white before we render our frame
ctx.fillStyle = "white"
chip8.draw_screen(SCALE)
anim_frame = window.requestAnimationFrame(() => {
mainloop(chip8)
})
}
Compiling WebAssembly Binary and Drawing to the Canvas
To compile, change directories to wasm and run:
$ wasm-pack build --target web
In order to draw to the canvas, we will need to add the following to our wasm/cargo.toml:
[dependencies.web-sys]
version = "^0.3.46"
features = [
"CanvasRenderingContext2d",
"Document",
"Element",
"HtmlCanvasElement",
"ImageData",
"KeyboardEvent",
"Window"
]
We will now change our new constructor to obtain the canvas object and the context which the canvas gets the draw function called upon it. Update your code to:
use wasm_bindgen::JsCast;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, KeyboardEvent};
#[wasm_bindgen]
pub struct EmuWasm {
chip8: Emu,
ctx: CanvasRenderingContext2d,
}
#[wasm_bindgen]
impl EmuWasm {
#[wasm_bindgen(constructor)]
pub fn new() -> Result<EmuWasm, JsValue> {
let chip8 = Emu::new();
let document = web_sys::window().unwrap().document().unwrap();
let canvas = document.get_element_by_id("canvas").unwrap();
let canvas: HtmlCanvasElement = canvas
.dyn_into::<HtmlCanvasElement>()
.map_err(|_| ())
.unwrap();
let ctx = canvas.get_context("2d")
.unwrap().unwrap()
.dyn_into::<CanvasRenderingContext2d>()
.unwrap();
Ok(EmuWasm{chip8, ctx})
}
#[wasm_bindgen]
pub fn draw_screen(&mut self, scale: usize) {
let disp = self.chip8.get_display();
for i in 0..(SCREEN_WIDTH * SCREEN_HEIGHT) {
if disp[i] {
let x = i % SCREEN_WIDTH;
let y = i / SCREEN_WIDTH;
self.ctx.fill_rect(
(x * scale) as f64,
(y * scale) as f64,
scale as f64,
scale as f64
);
}
}
}
// -- Unchanged code omitted --
}
Finally, rebuild by running the following in wasm/
$ wasm-pack build --target web
$ mv pkg/wasm_bg.wasm ../web
$ mv pkg/wasm.js ../web
Start the web server with the python command from above and you should be good to play on localhost in your browser.
Thanks for reading, reference the original chip8 book here, for which all credit for the original project goes towards.