In an effort to learn Rust, I built a Game Boy emulator in two weeks. Coming from a mobile and web background, and being used to building huge apps with slow build times, it was really fun to work in a new language on a fun project.
In this post, I'll share my thoughts on how using Rust felt compared to the languages I'm more familiar with (mainly Objective-C and JavaScript).
Enums
Enums in Rust are terrific. In particular, it's really nice that you can associate data with an enum variant and moreso, that the associated data can change with each variant. For instance, I used enums to store all the Game Boy opcodes.
pub enum Opcode {
Noop,
Stop,
Halt,
Load16(Register, Register, u16),
Load8(Register, u8),
LoadReg(Register, Register),
LoadAddress(Register, u16),
LoadAddressFromRegisters(Register, Register, Register),
LoadRegisterIntoMemory(Register, Register, Register),
...
}
It was super nice to have all the information about an opcode stored concisely, whereas in Objective-C I might have stored this kind of information through subclassing.
match
Rust's version of switch
is called match
and it's quite enjoyable to use with enums, as you can destructure so easily. For instance, this is some code from my emulator's fetch-execute cycle.
match opcode {
Opcode::Noop => (),
Opcode::Jump(address) => jump_location = Some(address),
Opcode::JumpCond(flag, set, address) => {
cycle_cost = 12;
if self.get_flag(flag) == set {
jump_location = Some(address);
cycle_cost = 16;
}
}
...
}
While something like this is definitely possible in JavaScript, I don't think it would be as concise as it is here.
Always return the last expression
One concept that is a bit hard to explain but extremely intuitive to use is the last expression in any block will be the "return" value for that block. For instance, in this function there is no return
statement.
fn read_memory_bit(&self, address: u16, bit: u8) -> bool {
assert!(bit <= 7);
(self.read_memory(address) & (1 << bit)) != 0
}
But Rust will automatically return the last line. It gets better, in this function there is again no return
statement but there is an if
block.
fn cond_memory_bit<T>(&self, address: u16, bit: u8, not_set: T, set: T) -> T {
if self.read_memory_bit(address, bit) {
set
} else {
not_set
}
}
In this case, Rust will return either set
or not_set
, as they are the last expressions to be evaluated. What's even more crazy is that this idea of "last expression in any block is returned automatically" applies to everything, not just functions. For instance, take a look at this bit
variable:
let bit = match new_stat_mode_flag {
0 => LCDCInterruptBit::HBlank,
1 => LCDCInterruptBit::VBlank,
2 => LCDCInterruptBit::OAM,
_ => unreachable!(),
};
bit
will be assigned to the right-hand expression of whichever match
statement succeeds, and it is so easy to read! In JavaScript, you could do something like this with a dictionary but it would definitely look more hacky. And then in Objective-C, this is usually written as a series of if-else
blocks.
Tests inline
In Rust, it is common to write unit tests in the same file, right next to function definitions! For instance, these were some of my opcode tests:
fn opcode(bytes: &[u8]) -> (gba::Opcode, u16, u16) {
let rom = gba::ROM::from_bytes(bytes.to_vec());
rom.opcode(0, |address| bytes[address as usize])
}
#[test]
fn test_opcodes() {
assert_eq!(opcode(&[0x0]), (Opcode::Noop, 1, 4));
}
Having the tests right next to the method felt really good, as it was helpful to see usages of the method right next to its definition. This also made it so easy to add a new unit test, as there was such a low barrier. Compared to JavaScript, where tests are usually in a separate-but-adjacent file, or Objective-C, where tests live in an entirely different build target, Rust tests felt really easy to make.
Very Concise
Overall, I felt like Rust allowed me to be very concise in my coding. My toy emulator takes up only 3,000 lines of code. Though it can only play some simple ROMs, it implements most of the Game Boy system. If I were to have written it in JavaScript or Objective-C, I would expect it to be at least 10,000 lines of code. But more importantly, with Rust, the code still feels easy to follow, and not frustratingly dense.
The best example of this is in the opcode parsing code. There are around 70 different opcodes, and the parsing code generally looks like this:
pub fn opcode(&self, address: u16, reader: impl Fn(u16) -> u8) -> (Opcode, u16, u16) {
let opcode_value = reader(address);
match opcode_value {
0x00 => (Opcode::Noop, 1, 4),
0x08 => (Opcode::SaveSP(immediate16()), 3, 20),
0x09 => (Opcode::AddHL(Register::B, Register::C), 1, 8),
0x19 => (Opcode::AddHL(Register::D, Register::E), 1, 8),
0x29 => (Opcode::AddHL(Register::H, Register::L), 1, 8),
0xCB => {
let cb_instr = immediate8();
let cycle_count = if (cb_instr & 0x7) == 0x6 {
if cb_instr >= 0x40 && cb_instr <= 0x7F {
12
} else {
16
}
} else {
8
};
(self.cb_opcode(cb_instr), 2, cycle_count)
}
}
}
Check out the branches of this match
statement. While some generate an opcode based merely on the value of opcode_value
, the 0xCB
block runs a bunch of logic and eventually returns an opcode. And because of the "last expression in a block always returns" rule, you can easily express this kind of logic.
Overall, with Rust, the code can be short-and-simple if possible, or long-and-complicated if necessary. But critically, these two solutions can easily co-exist.
Cargo
Having an official package manager for your programming language is truly a godsend. In JavaScript, there's so much headache from dealing with the competing standards that npm, Yarn, TypeScript, Babel, etc. all inject into your project. And then in Objective-C, there's CocoaPods vs dylibs vs git submodule vs copy-and-paste code.
Suffice to say, being able to specify dependencies in an easy, standardised way was truly enjoyable.
Conclusion
Overall, as you can tell, I really enjoyed writing this emulator in Rust. While I don't get to use it in my day job, I definitely want to find a way to use it more often. Or at the very least, incorporate some of the nice bits into whatever I use.