System Of Levers

Solving problems no one else seems to be having


2023-05-12

A Very Big GameBoy "Game"

I made a big GameBoy "game"! Here's the code including the ROM.

All you can do is scroll around to see the level. It only has one level and it's 32,766x16,384 pixels big!

It looks like this: a screenshot from my big "game"

Ok not very exciting.

Let's see how it works!

How it Works

I'm not using any compression or in-game procedural generation for the level map so the entire 32,768x16,384 pixel level is stored entirely in the game ROM or on the cartridge if you were to put it on a cartridge for some reason. The GameBoy uses tile-based graphics with 8x8 tiles. So the level is 4,096x2,048 tiles big.

The game is designed to use an MBC5 type cartridge. I chose that type of cartridge because it's allows the biggest ROM size for standard cartridge size, Giving me 8MB to work with! It supports 512 ROM banks, each 16KB.

To make things simple bank 0 is only used for code and the tile graphics. The map data is in the other 511 banks.

The map data is made up of 1-byte tile indexes that say what tile goes where. As you move around the level the game copies those indexes from the game ROM to the tile map in the GameBoy's VRAM.

So the real problem is to know where to copy from and where to copy to. I ended up solving that problem by keeping track of what part of the level map was on screen with two 16-bit variables: vWorldColumn and vWorldRow. I used those two variables track what tile in the world/level map is being displayed in the top left corner of the screen. The trick was that I could use a world column and row coordinate to get the ROM address of the needed tile map entry, and the address of where it goes in the VRAM tile map.

Why it's Complicated

If you've ever done any programming where you've had to switch between 2D array indexing and 1D array indexing this might not sound very complicated. If you know the 2D array's dimensions WxH and you want the value at some 2D index x,y then you can do some math:

index = x + y * W

and that gives you the 1D index. So that would let you get the ROM address of the map data you're looking for. To get the VRAM address you could do some more math:

romIndex = (x % 32) + (y % 32) * 32

The first problem is that the GameBoy is an 8-bit computer and those variables are 16-bits. That math isn't coming cheap. Getting the romIndex would be easy enough but the x + y * W would be a pain.

The other problem is ROM banks. If you're not careful then you could end up doing a bunch of bank changes while copying one column of tile map entries. I'd rather avoid that.

How I Map

I got around these problems by assigning meaning to the different bits of vWorldRow and vWorldColumn.

vWorldRow has the structure:


0000 0PQQ QQRr rrrr

and vWorldColumn has the structure:


0000 SSSS TTTt tttt

The first thing to notice is the leading zeros in both. vWorldRow only uses the 11 least significant bits and vWorldColumn uses 12. That lets me have 2,048 rows and 4,096 columns.

The different letters categorize the rest of the bits into their different meanings.

P and Q from vWorldRow and S from vWorldColumn are the 9 bits of the bank number:


0000 000P QQQQ SSSS

The rest are for the ROM address to where the tile map entry is stored:


01Rr rrrr TTTt tttt

The address starts with 01 because that's the banked half of the ROM addresses (controlled by the current bank number). Address that start with 00 are always bank 0. Address that start with 1 aren't for ROM.

The lower case letters give you the tile map entry's address in VRAM:


1001 10rr rrrt tttt

The address starts with 1001 10 because I'm using tile map 0, which starts at $9800.

How Does That Compute

The easy part is getting the ROM address since the needed bits align nicely to 8-bit values. I can just take the rightmost 8 bits from vWorldColumn as is to get the rightmost 8 bits of the ROM address. I can copy the rightmost 8 bits of vWorldRow and set the left 2 bits to 01 to get the leftmost 8 bits of the ROM address.

Since the bank number portion of vWorldRow is spread across 2 bytes it's a bit more trouble. Here's some code:

worldToSource:
  ;; Converts vWorldColumn and vWorldRow into a copy source address and bank.
  ;; Stores the address in vCopySoure and the bank in vSourceBank.
  ;; Modifies:
  ;; A, B, C
  ;; vWorldRow has the form:
  ;;   xxxx xPQQ QQRR RRRR
  ;; vWorldColumn has the form:
  ;;   xxxx SSSS TTTT TTTT
  ;;
  ;; This function produces
  ;; vCopySource with the form:
  ;;   01RR RRRR TTTT TTTT
  ;; vSourceBank with the form:
  ;;   0000 000P QQQQ SSSS

  ;; First get the source address because it's simple.
  ldh a, [vWorldRow + 1]
  ld c, a ; need this later for the bank too
  and %00111111
  set 6, a
  ldh [vCopySource], a
  ldh a, [vWorldColumn + 1]
  ldh [vCopySource + 1], a

  ;; Next get the source bank.
  ldh a, [vWorldRow]
  and %00000111

  sla c ; Get the highest bit of the lower byte of vWorldRow in the carry flag.
  rla ; Rotate the carry flag into the higher byte of vWorldRow and get the
      ; high bit into the carry flag.
  sla c
  rla
  ;; A is now %000PQQQQ
  swap a
  ;; A is now %QQQQ000P
  ld b, a
  res 0, b ; B is now %QQQQ0000

  and %00000001 ; Clear all but P from A.
  ldh [vSourceBank], a

  ldh a, [vWorldColumn]
  and %00001111
  ;; A now has 0000 SSSS
  ;; and B has QQQQ 0000
  or b
  ;; Now A has QQQQ SSSS
  ldh [vSourceBank + 1], a
  ret

Getting the VRAM address involves a bit of bit twiddling. Getting the right bits out of vWorldColumn and vWorldRow is simple enough, but the bits need to be shifted around to get the right VRAM address:

worldToDest:
  ;; Converts vWorldColumn and vWorldRow into a copy destination based on
  ;; _SCRN0. Stores the address in vCopyDest.
  ;; Modifies:
  ;;   A, B
  ;; vWorldRow has the form:
  ;;   xxxx xPQQ QQRr rrrr
  ;; vWorldColumn has the form:
  ;;   xxxx SSSS TTTt tttt
  ;;
  ;; This function produces
  ;; vCopyDest with the form:
  ;;   1001 10rr rrrt tttt
  ldh a, [vWorldRow + 1]
  and %00011111
  sla a
  ;; A now has 00rr rrr0.
  swap a
  ld b, a ; Will use B to calculate the lower byte later.
  ;; Use A to calculate the high byte.
  and %00000011
  or HIGH(_SCRN0)
  ;; A now contains 1001 10rr.
  ld [vCopyDest], a
  ld a, b
  and %11100000
  ld b, a
  ldh a, [vWorldColumn + 1]
  and %00011111
  or b
  ld [vCopyDest + 1], a
  ret

Problem

One problem that this scheme has is that it assumes bank 0 is usable for tile map entries but I said that I avoided putting any of that data in bank 0. To fix that I just created a big dead-zone for the part of the level that would have been stored in bank 0. I render it as just # characters. That area is 256x64 tiles big so the level is actually 32,766x16,384 - 2048x512 pixels. You can still go to that area it's just even more boring than the rest of the level.

Generating the Level

The level is generating with a python script. I made it to help debug and validate the GameBoy code. It splits the level into 8 tile wide chunks and each chunk displays its row and column. That way it helps me figure out if it's loading the right tile map entries as I scroll.

The code is pretty short:

#!/usr/bin/python3

import random

def pad(l, length):
  needed_padding = length - len(l)
  return ([0] * needed_padding) + l


def toHexCharacters(n):
  return [int(d, 16) for d in list(hex(n))[2:]]


def main():
  tile_values_sections = []

  col_start = 0x100
  for i in range(0x4000, 0x800000, 8):
    col_addr = i & 0xff
    row_addr = (i >> 8) & 0x3f
    col_bank = (i >> 14) & 0xf
    row_bank = (i >> 18) & 0x1f
    row = (row_bank << 6) | row_addr
    col = (col_bank << 8) | (col_addr >> 3)
    row_digits = pad(toHexCharacters(row), 3)
    col_digits = pad(toHexCharacters(col), 3)
    tile_values_sections.append(row_digits + [16] + col_digits + [17])
  map_values = [i for c in tile_values_sections for i in c]
  map_bytes = bytes(map_values)
  with open('long_map.tilemap', 'wb') as f:
    f.write(map_bytes)


if __name__ == '__main__':
  main()