Introduction
In this multi-part tutorial series we are going to make a renderer for a GameBoy Advance emulator. We start this series off with this part, where you will learn a bit more about the GameBoy Advance, set up our first window and draw our first pixels.
The GameBoy Advance was my favourite gaming handheld on which I have spent hours gaming on. I remember playing a ton of Pokemon Emerald and Super Mario Bros. Creating an emulator is like a puzzle, you start with a ROM (game) and slowly, step by step figure out what isn’t working, fixing that and repeating the cycle.
For this tutorial no in-depth knowledge about the GameBoy Advance is required, we will not touch on any CPU related topics and other hardware interactions.
Any code used in this tutorial is also shared on GitHub in this repository. The master branch will always feature the latest part. Branches are created per part.
GameBoy Advance
The complete specifications of the GameBoy Advance can best be described by GBATEK. The following specifications are important to write a renderer.
Specification | Value |
---|---|
Screen Width | 240 px |
Screen Height | 160 px |
VRAM | 96 KBytes |
Object RAM | 1 KByte |
Palette RAM | 1 KByte |
Color depth | 5 bits |
From this we can write our constants and create our memory arrays.
Change main.rs
to
const WINDOW_WIDTH: u32 = 240;
const WINDOW_HEIGHT: u32 = 160;
fn main() {
let mut vram = vec![0u8; 96 * 1024];
let mut palette = vec![0u8; 1 * 1024];
let mut oam = vec![0u8; 1 * 1024];
}
We start initializing the vectors with 0, we will fill these with relevant data later on.
Registers & RAM
The GBA has registers which are used to configure the renderer, these registers fall under the “LCD registers”
(See GBATEK).
We will implement most of these registers. Not all registers in this section are required by the renderer,
the DISPSTAT
and VCOUNT
registers are, for the purposes of our renderer, read-only and not used
for actual rendering.
We can now create a new file lcd.rs
where we can our LCD
struct. Note that we omit the DISPSTAT
and VCOUNT
registers. We try to group registers in arrays as well, so that we can for example index
bg_cnt[0]
for control of background 0.
#[derive(Debug)]
pub struct LCD {
pub disp_cnt: u16,
// General BG
pub bg_cnt: [u16; 4],
pub bg_offset: [[u16; 2]; 4],
// Rotation/Scaling
pub bg2_param: [u16; 4],
pub bg2_reference: [u32; 2],
pub bg3_param: [u16; 4],
pub bg3_reference: [u32; 2],
// Window
pub win_horz: [u16; 2],
pub win_vert: [u16; 2],
pub win_in: u16,
pub win_out: u16,
pub mosaic: u16,
// Blending
pub bld_cnt: u16,
pub bld_alpha: u16,
pub bld_y: u16,
}
Loading State
To make saving and loading our RAM and register state easier we’ll make use of the base64
crate.
Storing our state as base64 makes it easier to share it with others. We will start off with loading
RAM and afterwards the registers.
RAM
The load_ram_from_file
function opens a given file and decodes the base64 in that file
to the correct vector. The file probably contains a terminating newline, so we trim this using
the str.trim()
function.
We use the decode_slice_unchecked
function here to decode our base64 string retrieved from the file into a given vector.
This uses the unchecked variant since the base64 crate will estimate the length of the decoded slice when using the checked variant,
which is always a multiple of 3 and thus fail on the palette and OAM RAM (1024 % 3 != 0).
For now we put this function into main.rs
for simplicity’s sake.
use base64::{engine::general_purpose, Engine};
use std::fs;
/// Load RAM contents from file. File must be base64 encoded
fn load_ram_from_file(filename: &str, ram: &mut Vec<u8>) {
let contents = fs::read_to_string(filename).expect("Cannot read file `{filename}`");
let contents = contents.trim();
// We use unchecked decode here since base64 decodes in blocks of 3 bytes.
// The base64 crate will estimate the length and return Err if it thinks the buffer is too small
// when using the checked variants
general_purpose::STANDARD
.decode_slice_unchecked(contents, ram)
.unwrap();
}
We can now change our main
function to load our VRAM (96kB), Palette RAM (1kB) and Object Attributes RAM (1kB) from disk. Update the main
function
with the following:
fn main() {
// Create memory regions
let mut vram = vec![0u8; 96 * 1024];
load_ram_from_file("data/vram.dat", &mut vram);
let mut palette = vec![0u8; 1 * 1024];
load_ram_from_file("data/palette.dat", &mut palette);
let mut oam = vec![0u8; 1 * 1024];
load_ram_from_file("data/oam.dat", &mut oam);
}
Registers
To aid with constructing a LCD
struct from a vector of bytes, we will implement two helper functions.
These are get_u16
and get_u32
. These functions take an offset: usize
and a data: Vec<u8>
and return
either a u16
or u32
. The provided data
vector is little-endian encoded, the least-significant-byte (LSB, 50
in 0x4D50
)
is stored in the first memory position. For simplicity’s sake, the get_u32
function calls get_u16
twice,
instead of having an own implementation.
These functions are part of the lcd.rs
file.
/// Get 16 bit value from Vec<u8>. Data must be little endian encoded.
fn get_u16(offset: usize, data: &Vec<u8>) -> u16 {
let low = data[offset] as u16;
let high = data[offset + 1] as u16;
(high << 8) | low
}
/// Get 32 bit value from Vec<u8>. Data must be little endian encoded.
fn get_u32(offset: usize, data: &Vec<u8>) -> u32 {
let low = get_u16(offset, data) as u32;
let high = get_u16(offset + 2, data) as u32;
(high << 16) | low
}
Now we can create our from_file
function as part of the LCD
struct. This function takes a filename,
reads and decodes the base64 encoded data and stores it into a Vec<u8>
. We then take 16-bit or 32-bit
values from this vector based on the offset of the registers on a real GBA (See GBATEK/IO_MAP).
Some registers from this IO map are present in the loaded data, but are not used for our implementation.
impl LCD {
/// Reads base64 encoded registers from filename.
/// Registers are encoded in the same order as the LCD I/O map gives.
///
/// See https://problemkaputt.de/gbatek.htm#gbaiomap
pub fn from_file(filename: &str) -> Self {
let contents = fs::read_to_string(filename).expect("Cannot read file `{filename}`");
let contents = contents.trim();
let data = general_purpose::STANDARD.decode(contents).unwrap();
Self {
disp_cnt: get_u16(0x00, &data),
bg_cnt: [
get_u16(0x08, &data),
get_u16(0x0A, &data),
get_u16(0x0C, &data),
get_u16(0x0E, &data),
],
bg_offset: [
[get_u16(0x10, &data), get_u16(0x12, &data)],
[get_u16(0x14, &data), get_u16(0x16, &data)],
[get_u16(0x18, &data), get_u16(0x1A, &data)],
[get_u16(0x1C, &data), get_u16(0x1E, &data)],
],
bg2_param: [
get_u16(0x20, &data),
get_u16(0x22, &data),
get_u16(0x24, &data),
get_u16(0x26, &data),
],
bg2_reference: [get_u32(0x28, &data), get_u32(0x2C, &data)],
bg3_param: [
get_u16(0x30, &data),
get_u16(0x32, &data),
get_u16(0x34, &data),
get_u16(0x36, &data),
],
bg3_reference: [get_u32(0x38, &data), get_u32(0x3C, &data)],
win_horz: [get_u16(0x40, &data), get_u16(0x42, &data)],
win_vert: [get_u16(0x44, &data), get_u16(0x46, &data)],
win_in: get_u16(0x48, &data),
win_out: get_u16(0x4A, &data),
mosaic: get_u16(0x4C, &data),
bld_cnt: get_u16(0x50, &data),
bld_alpha: get_u16(0x52, &data),
bld_y: get_u16(0x54, &data),
}
}
}
“Hello World”, SDL style
Now the housekeeping is finished we can start creating our first window and drawing our first pixels on the screen.
As rendering backend we’ll make use of SDL. SDL is actually a C-library, which means we need
to use bindings to Rust. The sdl2
crate contains these bindings.
This crate does not ship with SDL libraries, these must be manually installed. To install these for Ubuntu
run sudo apt-get install libsdl2-dev
and for Arch run sudo pacman -S sdl2
.
Creating a window
We start by creating a basic window with a red background.
let sdl_context = sdl2::init().unwrap();
let video_subsystem = sdl_context.video().unwrap();
let window = video_subsystem
.window("gba_renderer", WINDOW_WIDTH, WINDOW_HEIGHT)
.position_centered()
.build()
.unwrap();
let mut canvas = window.into_canvas().build().unwrap();
This creates a window, with title gba_renderer
of 240x160 px
centered in the screen.
We can now set the default drawing color on the canvas. Add the following code after the SDL initialization.
canvas.set_draw_color(Color::RGB(255, 0, 0));
Finally, in our infinite loop we can clear our screen (filling it with the chosen draw color) and presenting it to the user. We sleep our thread for 16 milliseconds afterwards to get our 60 frames per second (fps).
loop {
canvas.clear();
canvas.present();
std::thread::sleep(Duration::from_millis(16));
}
Our main.rs
now looks like:
use base64::{engine::general_purpose, Engine};
use lcd::LCD;
use sdl2::pixels::Color;
use std::{fs, time::Duration};
mod lcd;
const WINDOW_WIDTH: u32 = 240;
const WINDOW_HEIGHT: u32 = 160;
/// Load RAM contents from file. File must be base64 encoded
fn load_ram_from_file(filename: &str, ram: &mut Vec<u8>) {
let contents = fs::read_to_string(filename).expect("Cannot read file `{filename}`");
let contents = contents.trim();
// We use unchecked decode here since base64 decodes in blocks of 3 bytes.
// The base64 crate will estimate the length and return Err if it thinks the buffer is too small
// when using the checked variants
general_purpose::STANDARD
.decode_slice_unchecked(contents, ram)
.unwrap();
}
fn main() {
// Create memory regions
let mut vram = vec![0u8; 96 * 1024];
load_ram_from_file("data/vram.dat", &mut vram);
let mut palette = vec![0u8; 1 * 1024];
load_ram_from_file("data/pal.dat", &mut palette);
let mut oam = vec![0u8; 1 * 1024];
load_ram_from_file("data/oam.dat", &mut oam);
let mut lcd = LCD::from_file("data/lcd.dat");
let sdl_context = sdl2::init().unwrap();
let video_subsystem = sdl_context.video().unwrap();
let window = video_subsystem
.window("gba_renderer", WINDOW_WIDTH, WINDOW_HEIGHT)
.position_centered()
.build()
.unwrap();
let mut canvas = window.into_canvas().build().unwrap();
canvas.set_draw_color(Color::RGB(255, 0, 0));
loop {
canvas.clear();
canvas.present();
std::thread::sleep(Duration::from_millis(16));
}
}
If we now run this program, we get a nice red window. Notice that the window does not respond to closing
and must be terminated using the task manager (or pkill/kill
). To fix this, we need to implement the Event loop.
Event loop, or how to quit a window
To handle events, we need to have an event loop. After we create a canvas, add the following line:
let mut event_pump = sdl_context.event_pump().unwrap();
With this we have a working event_pump
which we can poll to get events.
To poll for events we can use the poll_iter
of the event_pump
.
This returns an Iterator
which we can loop over.
Add the following code inside the infinite loop, between the rendering and sleeping:
for event in event_pump.poll_iter() {
match event {
Event::Quit { .. }
| Event::KeyDown {
keycode: Some(Keycode::Escape),
..
} => break 'running,
_ => {}
}
}
We now poll the event_pump
, and on a Quit
or KeyDown
with Escape
event, we quit our infinite loop.
You may not have encountered the syntax used for the Event::KeyDown
case yet, in this case we match any
Event::KeyDown
where within this enum, the keycode
field is Some(Keycode::Escape)
, but we do not care about
any other fields (denoted by the ..
).
We have to change our infinite loop to add a label, such that our event handler can break out of the infinite loop,
and not the event_pump
loop.
'running: loop {
...
}
Try running the program again and terminating it using either the close window button, or escape.
Note: In real world usage, this renderer (and the event loop) must be in a separate thread. After personal testing for my own renderer, the event loop takes up >50% time, even when no events are available. This dramatically reduced my processing power of the emulator.
Reloading LCD/RAM
Let’s first try to extend our event handler by allowing us to reload our data files. Add the following case to the match statement.
Event::KeyDown {
keycode: Some(Keycode::R),
..
} => {
load_ram_from_file("data/vram.dat", &mut vram);
load_ram_from_file("data/pal.dat", &mut palette);
load_ram_from_file("data/oam.dat", &mut oam);
lcd = LCD::from_file("data/lcd.dat");
}
Now whenever we press the R
key, we will reload all RAM and our registers from our data files again.
Our first pixels
Drawing our first pixels in SDL makes use of something called textures
.
To create such a texture we need to create a texture_creator
first. From this texture_creator
we can create a texture. For this texture we need to specify the size of course,
but also what pixel format we are using. For now we will use PixelFormatEnum::RGB24
, which gives
us 8 bits per color, without the alpha channel.
let texture_creator = canvas.texture_creator();
let mut texture = texture_creator
.create_texture_streaming(PixelFormatEnum::RGB24, WINDOW_WIDTH, WINDOW_HEIGHT)
.unwrap();
Now we have a texture in which we can draw pixels. For a streaming texture we will make use of the with_lock
function. This locks the texture for write-only access. This function requires a function
that accepts the buffer (&mut [u8]
) and a pitch (usize
). The pitch defines the bytes per row.
Notably we do not have access to the width and height of the texture in this function, this is up to the
programmer.
We can loop over the width and height and set individual pixels. As mentioned earlier, the texture is set to use
the RGB24
pixel format. This means that our first byte denotes the red component, the second byte
the green and the third byte the blue.
For our demo we will make a 50/50 split between black and white for our texture.
Between the canvas.clear()
and canvas.present()
function calls in the infinite loop, add the following code:
canvas.clear();
// Draw to texture
texture
.with_lock(None, |buffer: &mut [u8], pitch: usize| {
for y in 0..WINDOW_HEIGHT as usize {
for x in 0..WINDOW_WIDTH as usize {
let offset = y * pitch + x * 3;
if x < (WINDOW_WIDTH / 2) as usize {
buffer[offset + 0] = 0;
buffer[offset + 1] = 0;
buffer[offset + 2] = 0;
} else {
buffer[offset + 0] = 255;
buffer[offset + 1] = 255;
buffer[offset + 2] = 255;
}
}
}
})
.unwrap();
canvas.copy(&texture, None, None).unwrap();
canvas.present();
We calculate our offset by multiply the y value with the pitch (remember, pitch is bytes per row) and adding the x offset multiplied by 3 (3 bytes per pixel).
When we are done writing our texture we can copy it to the canvas using the canvas.copy
function.
This function takes a texture to copy, a source rectangle and a destination rectangle.
For any Some
source and/or destination rectangle the texture will be modified accordingly. This
technique will be used later on in another part.
If we now run our program we should be greeted with a nice black and white split window.
Final Program
Our final program should look like the following. The code can also be found in my [github repository](
main.rs
use base64::{engine::general_purpose, Engine};
use lcd::LCD;
use sdl2::{
event::Event,
keyboard::Keycode,
pixels::{Color, PixelFormatEnum},
};
use std::{fs, time::Duration};
mod lcd;
const WINDOW_WIDTH: u32 = 240;
const WINDOW_HEIGHT: u32 = 160;
/// Load RAM contents from file. File must be base64 encoded
fn load_ram_from_file(filename: &str, ram: &mut Vec<u8>) {
let contents = fs::read_to_string(filename).expect("Cannot read file `{filename}`");
let contents = contents.trim();
// We use unchecked decode here since base64 decodes in blocks of 3 bytes.
// The base64 crate will estimate the length and return Err if it thinks the buffer is too small
// when using the checked variants
general_purpose::STANDARD
.decode_slice_unchecked(contents, ram)
.unwrap();
}
fn main() {
// Create memory regions
let mut vram = vec![0u8; 96 * 1024];
load_ram_from_file("data/vram.dat", &mut vram);
let mut palette = vec![0u8; 1 * 1024];
load_ram_from_file("data/pal.dat", &mut palette);
let mut oam = vec![0u8; 1 * 1024];
load_ram_from_file("data/oam.dat", &mut oam);
let mut lcd = LCD::from_file("data/lcd.dat");
// Create context, subsystem and window
let sdl_context = sdl2::init().unwrap();
let video_subsystem = sdl_context.video().unwrap();
let window = video_subsystem
.window("gba_renderer", WINDOW_WIDTH, WINDOW_HEIGHT)
.position_centered()
.build()
.unwrap();
// Create canvas, texture_creator and event_pump
let mut canvas = window.into_canvas().build().unwrap();
let texture_creator = canvas.texture_creator();
let mut event_pump = sdl_context.event_pump().unwrap();
// Set the default draw color to red.
canvas.set_draw_color(Color::RGB(255, 0, 0));
// Create a texture
let mut texture = texture_creator
.create_texture_streaming(PixelFormatEnum::RGB24, WINDOW_WIDTH, WINDOW_HEIGHT)
.unwrap();
'running: loop {
// Clear canvas
canvas.clear();
// Draw to texture
texture
.with_lock(None, |buffer: &mut [u8], pitch: usize| {
for y in 0..WINDOW_HEIGHT as usize {
for x in 0..WINDOW_WIDTH as usize {
// pitch is the amount of bytes (not pixels!) per row.
// RGB24 pixel format uses 3 bytes per pixel.
let offset = y * pitch + x * 3;
// Left half will be black, right half will be white.
// With RGB24 format:
// pixel[offset + 0] => R
// pixel[offset + 1] => B
// pixel[offset + 2] => G
if x < (WINDOW_WIDTH / 2) as usize {
buffer[offset + 0] = 0;
buffer[offset + 1] = 0;
buffer[offset + 2] = 0;
} else {
buffer[offset + 0] = 255;
buffer[offset + 1] = 255;
buffer[offset + 2] = 255;
}
}
}
})
.unwrap();
// Copy texture to canvas
canvas.copy(&texture, None, None).unwrap();
// Draw canvas
canvas.present();
// Update
for event in event_pump.poll_iter() {
match event {
Event::Quit { .. }
| Event::KeyDown {
keycode: Some(Keycode::Escape),
..
} => break 'running,
Event::KeyDown {
keycode: Some(Keycode::R),
..
} => {
println!("Reloading RAM/Registers");
// Reload VRAM, palette, OAM and LCD from files
load_ram_from_file("data/vram.dat", &mut vram);
load_ram_from_file("data/pal.dat", &mut palette);
load_ram_from_file("data/oam.dat", &mut oam);
lcd = LCD::from_file("data/lcd.dat");
}
_ => {}
}
}
// Sleep for 16 ms = 62.5 fps
std::thread::sleep(Duration::from_millis(16));
}
}
lcd.rs
use base64::{engine::general_purpose, Engine};
use std::fs;
#[derive(Debug)]
pub struct LCD {
pub disp_cnt: u16,
// General BG
pub bg_cnt: [u16; 4],
pub bg_offset: [[u16; 2]; 4],
// Rotation/Scaling
pub bg2_param: [u16; 4],
pub bg2_reference: [u32; 2],
pub bg3_param: [u16; 4],
pub bg3_reference: [u32; 2],
// Window
pub win_horz: [u16; 2],
pub win_vert: [u16; 2],
pub win_in: u16,
pub win_out: u16,
pub mosaic: u16,
// Blending
pub bld_cnt: u16,
pub bld_alpha: u16,
pub bld_y: u16,
}
/// Get 16 bit value from Vec<u8>. Data must be little endian encoded.
fn get_u16(offset: usize, data: &Vec<u8>) -> u16 {
let low = data[offset] as u16;
let high = data[offset + 1] as u16;
(high << 8) | low
}
/// Get 32 bit value from Vec<u8>. Data must be little endian encoded.
fn get_u32(offset: usize, data: &Vec<u8>) -> u32 {
let low = get_u16(offset, data) as u32;
let high = get_u16(offset + 2, data) as u32;
(high << 16) | low
}
impl LCD {
/// Reads base64 encoded registers from filename.
/// Registers are encoded in the same order as the LCD I/O map gives.
///
/// See https://problemkaputt.de/gbatek.htm#gbaiomap
pub fn from_file(filename: &str) -> Self {
let contents = fs::read_to_string(filename).expect("Cannot read file `{filename}`");
let contents = contents.trim();
let data = general_purpose::STANDARD.decode(contents).unwrap();
Self {
disp_cnt: get_u16(0x00, &data),
bg_cnt: [
get_u16(0x08, &data),
get_u16(0x0A, &data),
get_u16(0x0C, &data),
get_u16(0x0E, &data),
],
bg_offset: [
[get_u16(0x10, &data), get_u16(0x12, &data)],
[get_u16(0x14, &data), get_u16(0x16, &data)],
[get_u16(0x18, &data), get_u16(0x1A, &data)],
[get_u16(0x1C, &data), get_u16(0x1E, &data)],
],
bg2_param: [
get_u16(0x20, &data),
get_u16(0x22, &data),
get_u16(0x24, &data),
get_u16(0x26, &data),
],
bg2_reference: [get_u32(0x28, &data), get_u32(0x2C, &data)],
bg3_param: [
get_u16(0x30, &data),
get_u16(0x32, &data),
get_u16(0x34, &data),
get_u16(0x36, &data),
],
bg3_reference: [get_u32(0x38, &data), get_u32(0x3C, &data)],
win_horz: [get_u16(0x40, &data), get_u16(0x42, &data)],
win_vert: [get_u16(0x44, &data), get_u16(0x46, &data)],
win_in: get_u16(0x48, &data),
win_out: get_u16(0x4A, &data),
mosaic: get_u16(0x4C, &data),
bld_cnt: get_u16(0x50, &data),
bld_alpha: get_u16(0x52, &data),
bld_y: get_u16(0x54, &data),
}
}
}