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.

GameBoy Advance

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.

SpecificationValue
Screen Width240 px
Screen Height160 px
VRAM96 KBytes
Object RAM1 KByte
Palette RAM1 KByte
Color depth5 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),
        }
    }
}

References