GameBoy Emulation in JavaScript: Memory Banking

Display mode

Back to GameBoy Emulation in JavaScript

This is part 9 of an article series on emulation development in JavaScript; ten parts are currently available, and others are expected to follow.

Thus far in this series, we've been dealing with the loading and emulation of a simple memory map for the GameBoy, with the entirety of the game ROM fitting into the lower half of memory. There aren't many games that fit into memory in full (Tetris is one of the few); most games are larger than this, and have to employ an independent mechanism to swap "banks" of game ROM into the GameBoy CPU's view.

Some of the first games in the GameBoy library were built with a Memory Bank Controller inside the cartridge, which did this job of swapping banks of ROM into view; over the years, various versions of the cartridge MBC were built for increasingly large games. In the particular example of the demo associated with this part, the first version of the MBC is used to handle the loading of a 64kB ROM.

Figure 1: jsGB implementation with MBC1 support

Banking and memory expansion

Through the years, many computer systems have had to deal with the problem of having too much program to fit into memory. Traditionally, there have been two ways to deal with this problem.

Since the GameBoy is a fixed hardware platform with wide distribution, there's no way to increase the address space when larger games are produced; instead, the Memory Bank Controller built into the cartridge offers a way to switch 16kB banks of ROM into view. In addition to this, the MBC1 supports up to 32kB of "external RAM", which is writable memory in the cartridge; this can be banked into the [A000-BFFF] space in the memory map, if it's available.

In order to facilitate software that uses the MBC1, the first 16kB bank of ROM (bank 0) is fixed at address 0000; the second half of the ROM space can be made into a window on any ROM bank between 1 and 127, for a maximum ROM size of 2048kB. One of the oddities of the MBC1 is that it deals internally in 32's: banks #32, #64 and #96 are inaccessible, since they're treated within the banking system as bank #0. This means that 125 banks apart from the fixed bank #0 are usable.

There are four registers within the MBC1 chip, that allow for switching of banks for the ROM and RAM; these can be changed by writing to the (normally read-only) ROM space anywhere within a certain range. The details are given in the below table.

LocationsRegisterDetails
0000-1FFFEnable external RAM4 bits wide; value of
0x0A enables RAM,
any other value disables
2000-3FFFROM bank (low 5 bits)Switch between banks 1-31 (value 0 is seen as 1)
4000-5FFFROM bank (high 2 bits)
RAM bank
ROM mode: switch ROM bank "set" {1-31}-{97-127}
RAM mode: switch RAM bank 0-3
6000-7FFFMode0: ROM mode (no RAM banks, up to 2MB ROM)
1: RAM mode (4 RAM banks, up to 512kB ROM)
Table 1: MBC1 register set

MBCs and the cartridge header

Since there are multiple kinds of controller for banking, any given game must state which MBC is used, in the cartridge header data. This is the first chunk of data in the cartridge ROM, and follows a specific format.

Location(s)ValueSize
(bytes)
Details
0100-0103hEntry point4Where the game starts
Usually "NOP; JP 0150h"
0104-0133hNintendo logo48Used by the BIOS to verify checksum
0134-0143hTitle16Uppercase, padded with 0
0144-0145hPublisher2Used by newer GameBoy games
0146hSuper GameBoy flag1Value of 3 indicates SGB support
0147hCartridge type1MBC type/extras
0148hROM size1Usually between 0 and 7
Size = 32kB << [0148h]
0149hRAM size1Size of external RAM
014AhDestination10 for Japan market, 1 otherwise
014BhPublisher1Used by older GameBoy games
014ChROM version1Version of the game, usually 0
014DhHeader checksum1Checked by BIOS before loading
014E-014FhGlobal checksum2Simple summation, not checked
0150hStart of game
Table 2: Cartridge header format

In this particular case, we're interested in the value of 0147h, the cartridge type. The cartridge type can be one of the following values, if an MBC1 is fitted to the cartridge:

ValueDefinition
00hNo MBC
01hMBC1
02hMBC1 with external RAM
03hMBC1 with battery-backed external RAM
Table 3: Cartridge type values pertaining to MBC1

For the purposes of this article, a system of battery backing will not be implemented for the external RAM; this feature is often used by games to save their state for later use, and will be looked at in more detail in a later part.

Implementation of MBC1

The memory bank controllers are an obvious manipulation of memory, and thus fit neatly into the MMU. Since the first ROM bank (bank #0) is fixed, an offset need only be maintained for the MBC to indicate where it's reading for the second bank. In order to allow for more MBC handling to be added later, an array of data can be used to hold the state of a given controller:

MMU.js: MBC state and reset

MMU = {
    // MBC states
    _mbc: [],

    // Offset for second ROM bank
    _romoffs: 0x4000,

    // Offset for RAM bank
    _ramoffs: 0x0000,

    // Copy of the ROM's cartridge-type value
    _carttype: 0,

    reset: function()
    {
        ...

	// In addition to previous reset code,
	// initialise MBC internal data
	MMU._mbc[0] = {};
	MMU._mbc[1] = {
	    rombank: 0,		// Selected ROM bank
	    rambank: 0,		// Selected RAM bank
	    ramon: 0,		// RAM enable switch
	    mode: 0		// ROM/RAM expansion mode
	};

	MMU._romoffs = 0x4000;
	MMU._ramoffs = 0x0000;
    },

    load: function(file)
    {
        ...
	MMU._carttype = MMU._rom.charCodeAt(0x0147);
    }
}

As can be seen in the above code, the internal state of the MBC1's four registers is represented by an object within the MMU, associated with MBC type 1. When these are changed, the ROM and RAM offsets can be modified to point into the appropriate bank of memory; once the pointers are set, access to the memory can proceed almost as normal.

MMU.js: MBC1-based access

MMU = {
    rb: function(addr)
    {
    	switch(addr & 0xF000)
	{
	    ...

	    // ROM (switched bank)
	    case 0x4000:
	    case 0x5000:
	    case 0x6000:
	    case 0x7000:
	        return MMU._rom.charCodeAt(MMU._romoffs +
		                           (addr & 0x3FFF));

	    // External RAM
	    case 0xA000:
	    case 0xB000:
	        return MMU._eram[MMU._ramoffs +
		                 (addr & 0x1FFF)];
	}
    }
};

The calculation of these pointer offsets is performed when the MBC registers are written, as shown below.

MMU.js: MBC1 control

    wb: function(addr, val)
    {
        switch(addr & 0xF000)
	{
	    // MBC1: External RAM switch
	    case 0x0000:
	    case 0x1000:
	        switch(MMU._carttype)
		{
		    case 2:
		    case 3:
			MMU._mbc[1].ramon =
			    ((val & 0x0F) == 0x0A) ? 1 : 0;
			break;
		}
		break;

	    // MBC1: ROM bank
	    case 0x2000:
	    case 0x3000:
	        switch(MMU._carttype)
		{
		    case 1:
		    case 2:
		    case 3:
		        // Set lower 5 bits of ROM bank (skipping #0)
			val &= 0x1F;
			if(!val) val = 1;
			MMU._mbc[1].rombank =
			    (MMU._mbc[1].rombank & 0x60) + val;

			// Calculate ROM offset from bank
			MMU._romoffs = MMU._mbc[1].rombank * 0x4000;
			break;
		}
		break;

	    // MBC1: RAM bank
	    case 0x4000:
	    case 0x5000:
	        switch(MMU._carttype)
		{
		    case 1:
		    case 2:
		    case 3:
		    	if(MMU._mbc[1].mode)
			{
			    // RAM mode: Set bank
			    MMU._mbc[1].rambank = val & 3;
			    MMU._ramoffs = MMU._mbc[1].rambank * 0x2000;
			}
			else
			{
			    // ROM mode: Set high bits of bank
			    MMU._mbc[1].rombank =
			    	(MMU._mbc[1].rombank & 0x1F) +
				((val & 3) << 5);
			
			    MMU._romoffs = MMU._mbc[1].rombank * 0x4000;
			}
			break;
		}
		break;

	    // MBC1: Mode switch
	    case 0x6000:
	    case 0x7000:
	        switch(MMU._carttype)
		{
		    case 2:
		    case 3:
		    	MMU._mbc[1].mode = val & 1;
			break;
		}
		break;

	    ...

	    // External RAM
	    case 0xA000:
	    case 0xB000:
	        MMU._eram[MMU._ramoffs + (addr & 0x1FFF)] = val;
		break;
	}
    }

In the above control code, instances of MBC1 that are stated as having external RAM attached are the ones which have RAM banking. With this code in place, the demo shown in Figure 1 loads and runs properly; without the MBC1 handler, the code would crash while attempting to access sprite and background data for the display.

Coming up

Aside from being able to fit larger games into memory, one of the more important aspects of a game is the ability to keep time: a clock-based game, for example, is useless without some kind of timing mechanism on which to base its clock. As mentioned previously, many games use the vertical blanking interrupt for this timing, but some require a finer-grained time structure; this is provided in the GameBoy by a hardware timer, tied into the CPU clock.

The timer also provides a method of examining the CPU clock, which makes it useful as a seed for random number generators; Tetris, for example, picks its blocks using this functionality of the hardware timer. In the next part, I'll look at the details of how the timer works, and how it can be implemented.

Imran Nazar <tf@imrannazar.com>, Dec 2010.