Porting Rust to the Game Boy Color
Porting Rust to the Game Boy Color
As a youth, I was glued to my Game Boy, pouring countless hours into playthroughs of pokemon blue. I had a playthrough using every starter (charmander, squirtle, bulbasaur) and I wanted to see if I could make a toolchain to write rust and get back assembly for the Game Boy to run through the rgbds toolchain.
tl;dr: I recommend using rust-gb and gbdk for your retro programming fix, since I’m not satisfied with the codegen on my project, but it was an amusing exercise in getting a fairly high level language on a fairly old CPU.
The Game Boy ISA:
Some people say the Game Boy Color’s assembly is an Z80, Intel 8080,
LR35902, or an SM83. Whatever the architecture, it’s an 8-bit computer
with 7 general purpose registers a, b,
c, d, e, h,
l, and a separate flags register, f.
a is the accumulator, which is special because it is the
target for a few 16-bit ops. Every register other than a
can be paired to become a 16-bit register, so:
b+c->bcd+e->deh+l->hl
Otherwise, the Game Boy is a CISC, so it’s meant to be written rather than compiled to. I don’t believe there were many people writing C and then compiling to asm for game boy games.
There’s a few groups of instructions supported like:
- Moves:
ld,ldh,push,pop,swap - Arithmetic:
add,adc,sub,sbc,and,xor,cp,inc,dec - Rotates:
rlca,rla,rrca,rra,rlc,rl,rrc,rr,sla,sra - Bit manip:
bit,res,set - Jumping:
jp,jr,call,ret,reti, - Misc:
nop,halt,stop,rst
Note there’s no multiply or divide, but thankfully there’s adding
with carry and subbing with carry (adc + sbc)
which ease use of 16-bit registers. Also of note, there are no floating
point numbers. Lots of operations in rust use 32-bit ops
(i32, u32, f32), and we also need
a word size here in Rust, and as most other compilers set it to 16-bit
to access the 32Kb of ROM memory. And that doesn’t even address the
elephant in the room, how are we going to deal with i128
and u128?
The architecture of sangoku
I decided to call my project sangoku, because the game
boy’s chip is sometimes called the lr35902. In Japanese,
san -> 3, go -> 5, and ku -> 9, so it’s basically 359 read out
individually as numbers. Crafty name aside, I needed a way to turn Rust
to a game boy ROM.
I decided to make a fork of rust to add support for a
“z80” backend, and then a fork of rustc_codegen_cranelift,
and cranelift, to hook everything together. Basically the
pieces go:
rustc (frontend) ->
rustc_codegen_cranelift (middle end) ->
cranelift (backend/optimizations) -> LR35902 asm ->
rgbasm/rgblink/rgbfix (to
generate the ROM).
Rustc didn’t need much in terms of changes, just adding in the new
target. More changes have to go in rustc_codegen_cranelift
(I’ll refer to this as cg_clif), like disabling C
variadics, lowering traps, and disabling float builtins.
Cranelift houses most of the changes, like making registers
8-bit in regalloc (cranelift targets 64-bit targets), making a specific
ABI, marking which registers are clobbered or not, and then emitting
assembly instead of a binary (which cranelift does by default), and then
filling out all the information for the LR35902, and optimizations for
the backend itself. Most of the changes go here.
Compiling Core
My goal was to make it to core. Rust basically has 4
levels of platforms: no_core, core,
alloc, and std.
no_core is nightly only and not as well known as the
others, but it basically defines nothing, where you have to implement
basic traits like Copy, Sized, and operators
like + or other language items. This is barely support at
all, but it’s a sanity check that the toolchain works end to end.
core defines the language itself. All the goodies like
i64, bool, Option,
&str, Result, Iterator,
traits, stuff like that come here. You can’t alloc memory though, so
Box, Vec, String and friends are
out of scope, but this is a fairly decent bare metal target; it’s about
equivalent to -ffreestanding for a C compiler.
alloc was a possible stretch goal, although with 32KB of
ROM, it might be hard to cram everything in without the runtime taking
up too much space, since we need an allocator and to support lots of
larger types.
std isn’t possible, since we don’t have an OS to run on,
so alloc is about as far as my ambitions can go on the Game
Boy.
Compiling to core
Let’s talk about what we need for core: first, we need to lower
i8 and i16. Those are doable since we have
registers for that, and most of the work is in mapping each arithmetic
operation to a suitable asm op. But we don’t have multiply or
divide!
This is where libcalls (library calls) come in.
Libcalls are software based routines that shim operations your target
doesn’t have hardware operations for. In our case, that means we need to
implement our own multiply; we can do this by adding and shifting. For
i8 multiplication:
; b and c are operands, result goes in a
__gbc_mul8::
ld d, 8 ; bit counter
xor a ; acc = 0
.loop
add a, a ; acc <<= 1
sla c ; shift the multiplier's top bit into carry
jr nc, .skip ; bit was 0: nothing to add
add b ; bit was 1: acc += multiplicand
.skip
dec d ; decrement d by 1
jr nz, .loop ; if zero flag is not set, jump back to loop
ret ; return (result in a)For i8 division:
__gbc_div8_core::
ld a, c ; move c to a
or a ; bitwise or a
jr nz, .nonzero ; jump to loop if zero flag is not set
rst $10 ; divisor == 0 ? trap (div by zero)
.nonzero
xor a ; remainder = 0
ld e, 8 ; load 8 into e
.loop
sla b ; top bit of the dividend/quotient register → carry
rla ; ...and into the remainder (remainder = rem*2 + bit)
cp c ; remainder >= divisor ?
jr c, .next ; no: quotient bit stays 0
sub c ; yes: remainder -= divisor
inc b ; ...and set the quotient bit (B's low bit was just vacated by sla)
.next
dec e ; decrement e
jr nz, .loop ; jump back to loop if zero flag is not set
ret ; return (result in a)Next up, we need to implement wider widths; for i8 and
i16, we can implement all the arithmetic ops since we have
software mul and divide, but we also need to handle i32,
which would require stitching together 4 free registers. But
i64 stops, since we have 7 registers that have 8-bits each,
so we only have 56 bits of register, which can’t express a 64-bit type.
That blocks us.
To do that, we do something called legalization, which
turns unsupported data types (like i64) into some form the
target hardware can understand. In our case, that means in
cranelift, turning an i64 into 2
i32s, and stitching those together. For an
i128, we split into 4 i32s (this required a
bit of a change since cranelift only targets 64-bit targets, so there’s
only a need to split i128 into 2 64-bit registers, so I
made a change to build a 128-bit type out of 4 32-bit registers. Float
types also needed support; there is no hardware float support, so the
change was to treat any float as an int of the same type, and then
direct its lowering ops to similar libcalls, to emulate floats in
software.
There’s a lot of other busywork (implementing casts between types)
and also implementing memory libcalls (like memcpy,
memmove, etc). After implementing all of the types
properly, rust itself can compile core!
Core compiled
I decided to compile a test program that basically called
sort_unstable and then println! to sort and
print a list of integers. sort_unstable doesn’t require
memory allocation, so it’s on core, and neither does printing.
My program was 202KB. Each ROM of the Game boy is 32kb
at max. Ouch. I had 3 problems:
sort_unstablecan panic, where it callsslice_index_failandnew_display, sosort_unstablepulls in both formatting code and panic handling code. At this point that was about150KBof code, far exceeding my limit.- I implemented almost every operation in cranelift with inlining,
only using libcalls where absolutely necessary (like multiply, divide,
or floats). This wasn’t going to fly, because trying to access say a
u64with just 8-bit registers spills and emits a lot of instructions. Even one access could easily balloon to 1KB of code. - I needed better debugging.
Lots of things in rust can panic; indexing, slice::swap,
overflow checks, unwrap each call
panic_bounds_check / slice_index_fail /
panic_fmt, which build fmt::Arguments and call
fmt::write. Therefore, any method that can
panic pulls in both formatting and panicking machinery. For the game
boy, this was about 22kb of code. For reference, the code
itself was 2kb.
By setting panic=immediate-abort, panics turn into a
trap instruction (rst 10h), which split the binary size by
basically half immediately, since panic handling code isn’t
generated(202kb -> 115kb). After also
applying Zmir-strip-debuginfo=all-locals, the formatting
code was also removed.
As a sidenote, it’s possible to nudge the compiler to delete
panicking code by using if let, or iter(), or
some higher level construct that doesn’t panic. Since the compiler knows
panicking isn’t possible, it doesn’t emitting the panicking or
formatting machinery, but this is a bad default, especially for a memory
constrained ROM.
Unicode lookups
Next up was to try out strings; I did one call to
to_lowercase on a non-ASCII character. That was already
47KB, well over the 32KB limit, with ~42KB
coming from lookups. Groan.
In Rust, to_lowercase is unicode-aware. Since there’s
not necessarily any rhyme or reason to lowercasing or uppercasing a
unicode character, the best way (and the way Rust does it) is by
employing a lookup table. The tables themselves were about
11KB on the gameboy, and they required
bitset_search, and skip_search (skiplist
search) as well as a u64::rotate_left. Most of the overhead
came from these. Only way around it is not using these methods, maybe by
adding custom lints for the target. On the flip side, this allowed some
optimizations, like optimizing c >> 16 from 16
srls to whole-byte ushr, handling
i32s as i16s or i8s when
possible, and optimizing binary search.
Alloc, the oasis
Alloc in general doesn’t need much else other than an allocator. A
simple bump allocator (there’s no OS) suffices, but trying to compile
programs that used the heap had the same issue as core did, a basic
program that used a Vec was 64kb. Since Rust is heavily
monomorphic, it was pretty easy to have to make many copies of
Vec<T>, which push memory cost up considerably.
Box, on the other hand, is much smaller.
Instead, a custom Vec type, FixedVec<T, N> is much
smaller; about 1.8KB. Still fairly large, but it’s a little better than
the about 13KB of overhead that a Vec had.
Optimization ad nauseam, ad infinitum, ad mortem
After being able to compile to alloc, the rest of the work is optimization. I look at a fixture/code to check if the code could be better, and then try to target optimizations. Most of the higher level ones are getting rid of inlining, and then trying to optimize for size vs. speed. For now, most work involves ripping out inlining for ops that aren’t easily supported by hardware (i.e. 32/64/128 bit ops) and then moving them out to libcalls. One arithmetic op on an i128 ranges from 1.5KB to 5KB, so inlining that would basically overrun the ROM capacity in about one line.
Some fixes are config based: -Zinline-mir=yes in
cranelift handles inlining MIR away, which can cut binary size by half
or more, and splitting out the runtime itself (which was a bunch of
libcalls in one file) into an implementation per file also dropped size
a lot (rgblink doesn’t do garbage collection to delete unused
sections).
Another interesting to note, compilers have say -s/-z
for size, and then -O1 | -O2 | -O3 for optimization
options. I generally always used -O3 for release (or
-O2 in C), and very rarely used -s. However,
since the LR35902 has such little space, one idea I had was to provide a
space optimized and a time optimized version of a function. That way, if
the user provides -s/-z, they can get smaller functions
even in libcall heavy code, and for the optimization flags, they can get
a libcall that’s more tilted towards speed.
One example is memset:
Optimized for speed:
SECTION "memset", ROM0
__gbc_memset::
push bc
ld d, a
ld hl, sp + 7
ld a, [hli]
ld h, [hl]
ld l, a
push hl
ld h, c
ld l, b
pop bc
ld a, d
srl b
rr c
jr nc, .skip_one
ld [hli], a
.skip_one:
srl b
rr c
inc b
inc c
jr nc, .test
jr .fill_two
.fill_four:
ld [hli], a
ld [hli], a
.fill_two:
ld [hli], a
ld [hli], a
.test:
dec c
jr nz, .fill_four
dec b
jr nz, .fill_four
.done:
pop bc
ld a, b
ld b, c
retOptimized for size:
SECTION "memset", ROM0
__gbc_memset::
push bc
ld d, a
ld a, b
ld b, c
ld c, a
ld hl, sp + 7
ld a, [hli]
ld h, [hl]
ld l, a
.loop:
ld a, h
or l
jr z, .done
ld a, d
ld [bc], a
inc bc
dec hl
jr .loop
.done:
pop bc
ld a, b
ld b, c
retThe size optimized version is 24 bytes, the speed optimized version is 43 bytes, so speed optimized is about 2x as large.
The size optimized version sets bytes one by one in a loop, whereas the speed version unrolls the loop to a factor of 4 and then fills in a loop.
There’s more overhead in the speed optimized version (about 100 cycles), but each byte setted takes about 12 cycles. For the size optimized version, each byte is filled in 56 cycles, so for very small memset calls, the size optimized version is always better, but for large byte arrays, the speed optimized version approaches 56/12x faster (4.6x). It’s fairly unlikely that you’ll want to set a large buffer at runtime, but even a 16-byte buffer is about 2.5x faster for the speed optimized version.
Optimizing Codegen (for dec r)
For an 8-bit register, n -= 1 on it looked like:
ld b, 1
ld a, .. ; n
sub bBut this can obviously be:
dec rTo do this, this required a new instruction in inst.isle:
(IncDec8 (dst WritableReg) (src Reg) (decrement bool))
And then an arm to implement it in lowering:
if iadd or isub, and type = i8,
check whether or not the instruction is an inc/dec by 1
then, call our new helperEncoding:
IncDec8: put1((if dec 0x05 else 0x04 | (hw_enc(dst) << 3)))Then labelling in regalloc to make sure that dst and
src get the same reg.
How much worse than C is Sangoku’s Rust?
Pretty bad, actually. Here are some examples byte sizes:
| prog | Sangoku | SDCC |
|---|---|---|
| empty | 95 | 2 |
| hello | 3,893 | 67 |
| primes | 9,533 | 355 |
| sort | 14,849 | 613 |
| heap | 24,975 | 2,000 |
On a large program there’s about 10x overhead, on some smaller programs, 15x+ overhead. SDCC has an advantage in that it compiles C, and has been around for a while, but Sangoku is quite far from usable.
Rust on Anything?
Given the whole rewrite it in rust crowd wants everything rewritten in rust, can you write rust on the Game Boy? Probably, but at least this project would need a few months of work before it would be anywhere near comparable to the titans of gbdk or rgbasm, but that makes sense, they’ve been around for years. It might be possible but the task is sisyphean, there is so much to optimize to get anywhere near close to a good toolchain.
Rust is still pretty cool
Here’s the start of the surfing track from Pokemon Red/Blue: This is all in core, and you can use most of the language OOB.
pub const TEMPO: u8 = 160;
pub const WAVE: [u8; 16] = [
0x02, 0x46, 0x8a, 0xce, 0xff, 0xfe, 0xed, 0xdc, 0xcb, 0xa9, 0x87, 0x65, 0x44, 0x33, 0x22, 0x11,
];
use Event::*;
use sangoku_audio::Event;
use sangoku_audio::Note::{A, B, C, CSharp, D, DSharp, E, FSharp, GSharp};
pub static CH1: &[Event] = &[
Duty(3),
Vibrato(12, 3, 4),
PerfectPitch,
NoteType(12, 11, 5),
Rest(6),
Octave(2),
// ...
];Compare that to the original (this is from pret/pokered):
Music_Surfing_Ch1::
tempo 160
volume 7, 7
duty_cycle 3
vibrato 12, 3, 4
toggle_perfect_pitch
note_type 12, 11, 5
rest 6
octave 2You get some nice abstraction. Not so bad. I’ll have to keep reading the book I bought to learn more about the GBC, Game Boy Coding Adventure, and hopefully as I chip through the compiler, maybe I can even write some parts in rust without overruning my ROM.