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


Chapter XXXV. MBC3 and the Real Time Clock

Return to Index

Previous Chapter

While the MBC1 and MBC2 have been relatively similar to one another, the MBC3 introduces a new element not seen in the other chips. The MBC3 includes a mechanism known as the Real Time Clock (RTC) which keeps track of the passage of real-world time. The RTC is what allows games like Pokemon Gold/Silver to maintain what time it is. To begin, we'll create a new file at cart/rtc.rs, which will house a new object that stores a new Rtc object. Before we edit that file though, we're actually going to take the unusual step of adding a new dependency.

As we'll soon see, the RTC keeps track of how much time has elapsed since the game began running. The game developer then has the ability to query that time as a combination of days, hours, minutes, and seconds. From our emulator's point of view, we need the ability to store the real-world time at creation, and then calculate how long has elapsed. Rust's std::time::Instant functionality might seem like the obvious datatype for this task, except it has a large problem — it doesn't work with WebAssembly. You'll have to ask a Rust developer for the specifics, but while Instant will work fine for our desktop build, the RTC would break when running it in a web browser. This obviously isn't acceptable, but fortunately a third party solution exists. The wasm-timer crate performs an abstraction that implements a working version for wasm builds. To add it, we'll need to edit our Cargo.toml.


# In core/Cargo.toml
# Unchanged code omitted

[dependencies]
wasm-timer = "0.2.5"
            

The RTC functions by internally storing the amount of time, in seconds, that has elapsed since the game began running. It exposes four different RAM registers that can be read by the developer to obtain the amount of time that has elapsed since then. This is stored like how you would read a clock, in number of days followed by remaining hours, then minutes, and seconds. For example, if the game has been running for 100,000 seconds, reading from the RTC would read as 1 day, 3 hours, 46 minutes, and 40 seconds. However, the registers do not automatically update continuously with the passage of time, meaning that if you kept reading from the RTC, you would always get the same value back. This is because while the RTC continues to update its elapsed time internally, it does not update the registers until explicitly told to. This process — known as latching — sets the registers to the current internal seconds value, where they remain until the RTC is explicitly told to latch again. While this sounds complicated, it's actually quite straight-forward, as we will see.

We'll begin by creating a new Rtc struct, which will contain items for the internal count and the values the developer will access. There is also a field for whether or not the RTC is enabled. We'll also need to include our new wasm-timer object to use now.


// In cart/rtc.rs

extern crate wasm_timer;
use wasm_timer::Instant;

pub struct Rtc {
    start: Instant,
    seconds: u8,
    minutes: u8,
    hours: u8,
    days: u16,
    enabled: bool,
}

impl Rtc {
    pub fn new() -> Self {
        Self {
            start: Instant::now(),
            seconds: 0,
            minutes: 0,
            hours: 0,
            days: 0,
            enabled: false,
        }
    }
}
            

Next, we'll implement what happens when the RTC is told to latch. This should grab how many seconds have elapsed, then break that up into the days, hours, minutes, and seconds fields. We'll add some constants to assist with this math, but this should look pretty familiar.


// In cart/rtc.rs
// Unchanged code omitted

const SECS_IN_MIN: u64  = 60;
const MINS_IN_HOUR: u64 = 60;
const HOURS_IN_DAY: u64 = 24;

impl Rtc {
    pub fn latch_time(&mut self) {
        let now = Instant::now();
        let delta = now.duration_since(self.start);
        let d_sec = delta.as_secs();

        self.seconds = (d_sec % SECS_IN_MIN) as u8;

        let d_min = d_sec / SECS_IN_MIN;
        self.minutes = (d_min % MINS_IN_HOUR) as u8;

        let d_hour = d_min / MINS_IN_HOUR;
        self.hours = (d_hour % HOURS_IN_DAY) as u8;

        let d_days = d_hour / HOURS_IN_DAY;
        self.days = d_days as u16;
    }
}
            

If you've dealt with time before, these calculations should look familiar. We grab the number of seconds elapsed since we started, then divide down to break the total value up into each time frame.

Next, we need to set up how to actually interface with the RTC. Interfacing with the RTC will be done through the MBC3, so some of that handling will happen there. For now, we'll create two stubs for reading and writing to the RTC, which we'll hold off on implementing until it's clearer how the RTC and MBC3 work together. I'm foreshadowing a bit here by including bank parameters for each of these functions. The RTC actually uses the current value of the RAM bank as a parameter. We'll explore how that works when we implement these functions. Finally, we'll also add a getter function for the enabled variable.


// In cart/rtc.rs
// Unchanged code omitted

impl Rtc {
    pub fn is_enabled(&self) -> bool {
        self.enabled
    }

    pub fn read_byte(&self, bank: u8) -> u8 {
        unimplemented!()
    }

    pub fn write_byte(&mut self, bank: u8, val: u8) {
        unimplemented!()
    }
}
            

Let's now get the MBC3 interface set up so it's clearer what needs to happen within the RTC.

MBC3

The MBC3 is similar to the MBC1 in many ways, with only a handful of differences centered around interfacing with the RTC. As we've stated before, reading from ROM memory is always the same, so let's begin with reading from external RAM space. The MBC3 can only support up to four RAM banks, so if the ram_bank value is between 0x00 and 0x03, we simply read from the correct RAM bank as we've done for the other MBC chips.

However, the MBC3's RAM bank value can actually be set to an "invalid" bank index, higher than the four it supports. Most values aren't useful at all, but if it's set between 0x08 and 0x0C, then the MBC3 turns to the RTC to read its needed data. In that case, the RAM address the developer specifies isn't actually used, it only depends on what the RAM bank was set to.

RAM Bank ValueRTC Return Value
0x08Seconds
0x09Minutes
0x0AHours
0x0BDays (Low Byte)
0x0CFlags

The above table lays out what return value the RTC yields based on what the RAM bank is currently set as. Most of them are straight-forward, returning the latched seconds, minutes, hours, or the low byte of days (as it's a 16-bit value). If the RAM bank was set to 0x0C, the return value is a bitfield containing the remaining day bits and whether or not the RTC is halted. We'll cover this in more detail when we complete the RTC read_byte function, but for now we'll expand the external RAM read function to account for the MBC3. We'll also take this opportunity to add the RTC object to Cart.


// In cart/mod.rs
// Unchanged code omitted

use rtc::Rtc;

pub struct Cart {
    rom: Vec<u8>,
    ram: Vec<u8>,
    rom_bank: u16,
    ram_bank: u8,
    mbc: MBC,
    rtc: Rtc,
    rom_mode: bool,
    ram_enabled: bool,
}

impl Cart {
    pub fn new() -> Self {
        Self {
            rom: Vec::new(),
            ram: Vec::new(),
            rom_bank: 1,
            ram_bank: 0,
            mbc: MBC::NONE,
            rtc: Rtc::new(),
            rom_mode: true,
            ram_enabled: false,
        }
    }
}
            

Next, expanding the read_ram function to incorporate the MBC3. Up until now, all of our MBC types have had the same behavior. This is still somewhat the case, but the MBC3 also can reference the RTC under the conditions described above.


// In cart/mod.rs
// Unchanged code omitted

impl Cart {
    pub fn read_ram(&self, addr: u16) -> u8 {
        match self.mbc {
            MBC::NONE | MBC::MBC1 | MBC::MBC2 => {
                self.read_ram_helper(addr)
            },
            MBC::MBC3 => {
                self.mbc3_read_ram(addr)
            }
            _ => unimplemented!()
        }
    }

    fn mbc3_read_ram(&self, addr: u16) -> u8 {
        if self.rtc.is_enabled() && (0x08 >= self.ram_bank && self.ram_bank <= 0x0C) {
            self.rtc.read_byte(self.ram_bank)
        } else {
            self.read_ram_helper(addr)
        }
    }

    fn read_ram_helper(&self, addr: u16) -> u8 {
        let rel_addr = (addr - EXT_RAM_START) as usize;
        let bank_addr = (self.ram_bank as usize) * RAM_BANK_SIZE + rel_addr;
        self.ram[bank_addr]
    }
}
            

Rather than copy paste the same code, I've moved the base external RAM access functionality into a shared helper function.

Next, we'll look at what happens when you write to the MBC3's external RAM. This is essentially what we did for mbc3_read_ram but in reverse. If we are trying to write to external RAM while the bank is set between 0x00 and 0x03, the write occurs as normal. If it is set between 0x08 and 0x0C, it gets passed on to the RTC (if it's any other bank value, it's undefined behavior and we'll ignore it). To keep things tidy I'll also pull out the shared RAM writing code into a shared helper function.


// In cart/mod.rs
// Unchanged code omitted

impl Cart {
    pub fn write_ram(&mut self, addr: u16, val: u8) {
        match self.mbc {
            MBC::NONE => {
                let rel_addr = addr - EXT_RAM_START;
                self.ram[rel_addr as usize] = val;
            },
            MBC::MBC1 => self.write_ram_helper(addr, val),
            MBC::MBC3 => self.mbc3_write_ram(addr, val),
            _ => unimplemented!()
        }
    }

    fn mbc3_write_ram(&mut self, addr: u16, val: u8) {
        match self.ram_bank {
            0x00..=0x03 => {
                self.write_ram_helper(addr, val);
            },
            0x08..=0x0C => {
                if self.ram_enabled {
                    self.rtc.write_byte(self.ram_bank, val);
                }
            },
            _ => {}
        }
    }

    fn write_ram_helper(&mut self, addr: u16, val: u8) {
        if self.ram_enabled {
            let rel_addr = (addr - EXT_RAM_START) as usize;
            let ram_addr = (self.ram_bank as usize) * RAM_BANK_SIZE + rel_addr;
            self.ram[ram_addr] = val;
        }
    }
}
            

This now leaves us with handling write to ROM space. This will again look very similar to the MBC1 implementation, with the ROM address space broken up into four pieces, each 8 KiB in size. The first, from 0x0000 to 0x1FFF, is exactly the same as the MBC1, and will set if external RAM access is the value written is 0x0A. The second 8 KiB also sets the ROM bank index, with the small change that the MBC3 ROM bank can only go up to 31, so only the lower five bits are used, and that 0 is not a valid index. If you attempt to set it to zero, bank 1 will be loaded instead.

The third 8 KiB sets the RAM bank index, which as we discussed above also doubles as parameters for the RTC. The final 8 KiB is the most unique behavior, as writing to here latches the RTC. This does not occur if any value is written here though, the RTC will only latch if a 0 followed by a 1 is written to it. We'll handle that distinction within the RTC class itself, here we'll just call the appropriate functions.


// In cart/mod.rs
// Unchanged code omitted

impl Cart {
    pub fn write_cart(&mut self, addr: u16, val: u8) {
        match self.mbc {
            MBC::NONE => {},
            MBC::MBC1 => { self.mbc1_write_rom(addr, val); },
            MBC::MBC2 => { self.mbc2_write_rom(addr, val); },
            MBC::MBC3 => { self.mbc3_write_rom(addr, val); },
            _ => unimplemented!()
        }
    }

    fn mbc3_write_rom(&mut self, addr: u16, val: u8) {
        match addr {
            RAM_ENABLE_START..=RAM_ENABLE_STOP => {
                self.ram_enabled = val == 0x0A;
            },
            ROM_BANK_NUM_START..=ROM_BANK_NUM_STOP => {
                if val == 0x00 {
                    self.rom_bank = 0x01;
                } else {
                    self.rom_bank = val as u16;
                }
            },
            RAM_BANK_NUM_START..=RAM_BANK_NUM_STOP => {
                self.ram_bank = val;
            },
            ROM_RAM_MODE_START..=ROM_RAM_MODE_STOP => {
                self.rtc.write_byte(self.ram_bank, val);
            },
            _ => unreachable!()
        }
    }
}
            

This concludes the implementation of the MBC3 chip, but there is still a bit of RTC behavior we have yet to add.

RTC, again

We now return to the RTC handling to cover its final behavior, namely when manually reading or writing to it. As mentioned, if the MBC3 RAM bank is set between 0x08 and 0x0C, any attempts to read or write to the external RAM space will instead be sent to the RTC itself, using that RAM bank number to differentiate the register. These are outlined in the table above.

The first four, from 0x08 to 0x0B, are pretty straight-forward. Each corresponds to a different unit of time that can be read or written to. The only complex entry is the 0x0C register, which contains several flags. Bit 0 is simply the final, most significant bit of the day counter, since it is a 9-bit value. Bits 1 through 5 are undefined, while bit 6 is the "halt" flag. Finally, bit 7 is the overflow flag for the day counter. This does exactly what it sounds like, which somewhat strangely makes the day counter function more like a 10-bit value than the official 9-bit value it claims to be.

If the bank is instead set to a value not between 0x08 and 0x0C, writing a value instead enables or disables the RTC altogether. If a value of 0 is written, then the RTC is disabled. Once the RTC is disabled, writing a value of 1 re-enables the RTC and "latches" the current time to the RTC registers. The latched data will not changed until the 0x00 -> 0x01 process is repeated.


// In cart/rtc.rs
// Unchanged code omitted

use crate::utils::ModifyBits;

const DAY_HIGH_BIT: u8      = 0;
const HALT_BIT: u8          = 6;
const DAY_OVERFLOW_BIT: u8  = 7;

impl Rtc {
    pub fn read_byte(&self, bank: u8) -> u8 {
        match bank {
            0x08 => { self.seconds },
            0x09 => { self.minutes },
            0x0A => { self.hours },
            0x0B => { (self.days & 0xFF) as u8 },
            0x0C => {
                let mut ret = 0;
                ret.set_bit(DAY_HIGH_BIT, self.days.get_bit(9));
                ret.set_bit(HALT_BIT, self.halted);
                ret.set_bit(DAY_OVERFLOW_BIT, self.days.get_bit(10));
                ret
            },
            _ => { unreachable!() }
        }
    }

    pub fn write_byte(&mut self, bank: u8, val: u8) {
        match bank {
            0x08 => { self.seconds = val; },
            0x09 => { self.minutes = val; },
            0x0A => { self.hours = val; },
            0x0B => {
                self.days = (self.days & 0xFF00) | (val as u16);
            },
            0x0C => {
                self.days.set_bit(9, val.get_bit(DAY_HIGH_BIT));
                self.halted = val.get_bit(HALT_BIT);
                self.days.set_bit(10, val.get_bit(DAY_OVERFLOW_BIT));
            },
            _ => {
                if val == 0x00 {
                    self.enabled = false;
                } else if val == 0x01 && !self.enabled {
                    self.enabled = true;
                    self.latch_time();
                }
            }
        }
    }
}
            
Next Chapter