aquova.net Github's logo Mastodon's logo Blue Sky's logo Backloggd's logo Pico-8's logo The RSS logo


Chapter XV. Cartridge ROM

Return to Index

Previous Chapter

At this point in our project, a user is able to use either of our frontends to identify a file they would like to attempt to play. Either frontend reads that file in and passes it to our Cpu object as a byte array. The Cpu then passes that data along to the Bus, where it... stops. We need somewhere to store the game ROM while also making it accessible via the Bus. It might be tempting to simply load this data into the first half of our RAM array, but as previously noted, only the smallest of games are able to fit into this space. We shall need a dedicated object to manage this.

Inside of core/src, create a new cart directory. As we'll eventually see, there's more than one way to map ROM to RAM, and we will handle these different implementations together in this directory. We'll first need to add this new module to core/src/lib.rs.


// In lib.rs

pub mod bus;
pub mod cart;
pub mod cpu;
pub mod utils;
            

Create cart/mod.rs, where we will handle all of the common logic for ROM handling. Our first step will be to create a new object which will hold the original ROM data as passed in from the frontend. This data will be stored, unaltered until the game emulation stops. Since Game Boy titles shipped in several different sizes, we cannot guarantee how much space this may occupy; so we will need to store the data in a Vec. We'll also implement a function load_cart which finally ends the journey of the ROM data provided from the frontend.


// In cart/mod.rs

pub struct Cart {
    rom: Vec<u8>,
}

impl Cart {
    pub fn new() -> Self {
        Self {
            rom: Vec::new(),
        }
    }

    pub fn load_cart(&mut self, rom: &[u8]) {
        self.rom = rom.to_vec();
    }
}
            

We will next need to create the Bus::load_rom function that we promised when setting up the frontends. This will accept the ROM data and give it to a Cart instance, which will be a member of Bus.


// In bus.rs

use crate::cart::Cart;

pub struct Bus {
    rom: Cart,
    ram: [u8; 0x10000],
}

impl Bus {
    // Unchanged code omitted

    pub fn new() -> Self {
        Self {
            rom: Cart::new(),
            ram: [0; 0x10000],
        }
    }

    pub fn load_rom(&mut self, data: &[u8]) {
        self.rom.load_cart(data);
    }
}
            

Excellent, the ROM data now lives within the Cart object. This does raise a question about how the bus's read_ram and write_ram functions are meant to function. We said that the first 32 KiB of RAM was meant for accessing a copy of ROM data; do we need to maintain a subset of that data ourselves in our ram array? While this is certainly possible, and perhaps more similar to how a real Game Boy functions, it's rather wasteful and prone to errors. We have no need to keep two copies of the same data when one will do. Instead, anytime the system wants to access a RAM address that we know falls into the cartridge memory space (address 0x0000 through 0x7FFF), we will simply pass that request along to Cart to handle. Anything outside of that range we'll supply from our ram array. This means that the first 32 KiB of ram would never actually be used, and thus we don't need to keep it. Let's start by adding adding functions to Cart to access its data. We'll ignore the intricacies of bank switching for the moment. We'll also create some constants in here for use elsewhere.


// In cart/mod.rs

pub const ROM_START: u16    = 0x0000;
pub const ROM_STOP: u16     = 0x7FFF;

impl Cart {
    // Unchanged code omitted

    pub fn read_cart(&self, addr: u16) -> u8 {
        // TODO: Handle bank switching
        self.rom[addr as usize]
    }

    pub fn write_cart(&mut self, addr: u16, val: u8) {
        // TODO: Handle bank switching
    }
}
            

As mentioned, this is a bit naive. For the majority of games, their stored ROM data will exceed what we can access with our 16-bit address, so the data we return here won't be correct. For smaller titles though, this will work for now. Those will be small enough to function correctly, which is fortunate for us. Now that Cart is handling the Cartridge RAM memory space, we can trim down the size of the bus's array. Note that since we're removing the front of the array here, anything that legitimately needs to access it will need to be offset by some constant to ensure it's mapped to the start of the array, in this case offset by 0x8000, the size of Cartridge RAM.


// In bus.rs

use crate::cart::{Cart, ROM_START, ROM_STOP};

pub struct Bus {
    rom: Cart,
    ram: [u8; 0x8000],
}

impl Bus {
    pub fn new() -> Self {
        Self {
            rom: Cart::new(),
            ram: [0; 0x8000],
        }
    }

    // Unchanged code omitted

    pub fn read_ram(&self, addr: u16) -> u8 {
        match addr {
            ROM_START..=ROM_STOP => {
                self.rom.read_cart(addr)
            },
            _ => {
                let offset = addr - ROM_STOP - 1;
                self.ram[offset as usize]
            }
        }
    }

    pub fn write_ram(&mut self, addr: u16, val: u8) {
        match addr {
            ROM_START..=ROM_STOP => {
                self.rom.write_cart(addr, val);
            },
            _ => {
                let offset = addr - ROM_STOP - 1;
                self.ram[offset as usize] = val;
            }
        }
    }
}
            

We'll again use Rust's pattern matching to assist us here. If the system is looking for an address corresponding to Cartridge RAM (0x0000 through 0x7FFF), then forward them along to the Cart object. If instead they're looking for a value above that, then offset the Cartridge RAM size and use that address to access the bus's ram array. With this, either the desktop or html projects should be able to read a Game Boy file in and store it into the Cart object. Feel free to add some debug statements to ensure that this is indeed the case.

Even with the prospect of bank switching looming over us, we shall be satisfied with what we have for now and move away from the cartridge handling. Instead, we shall implement the final item needed for CPU verification — basic video rendering.