How ChromA Works

An interactive guide to emulating Game Boy on Game Boy Advance

ChromA runs Game Boy and Game Boy Color games on Game Boy Advance hardware. It maps a Z80-derived CPU onto ARM assembly, converts 2bpp tiles to 4bpp on the fly, and fits the entire CPU core in 32 KB of fast IWRAM.

The Hardware

Game Boy / Color

LR35902 CPU 4.19 MHz (8.38 GBC) PPU 160x144 / 2bpp tiles VRAM 8K / 16K (GBC) WRAM 8K / 32K (GBC) OAM 160B / 40 sprites I/O + HRAM FF00-FFFF Cartridge ROM + MBC + SRAM Up to 8 MB ROM, 128 KB SRAM
GB Memory Map
0000-3FFF  ROM Bank 0 (16 KB)
4000-7FFF  ROM Bank 1-N (switchable)
8000-9FFF  VRAM (8K, 2 banks on GBC)
A000-BFFF  External SRAM
C000-CFFF  WRAM Bank 0 (4 KB)
D000-DFFF  WRAM Bank 1-7 (GBC switchable)
FE00-FE9F  OAM (sprite attributes)
FF00-FF7F  I/O Registers
FF80-FFFE  HRAM (127 bytes, fast)
FFFF       Interrupt Enable

Game Boy Advance

ARM7TDMI CPU 16.78 MHz (32-bit) PPU (Bitmap + Tile) 240x160 / 4bpp or 8bpp IWRAM 32 KB (fast, 32-bit) EWRAM 256 KB (slow, 16-bit) VRAM 96 KB OAM + I/O 1 KB + registers Game Pak ROM Up to 32 MB at 0x08000000
GBA Memory Map
00000000  BIOS (16 KB)
02000000  EWRAM (256 KB, 16-bit bus)
03000000  IWRAM (32 KB, 32-bit bus)
04000000  I/O Registers
05000000  Palette RAM (1 KB)
06000000  VRAM (96 KB)
07000000  OAM (1 KB)
08000000  Game Pak ROM (up to 32 MB)
0E000000  Game Pak SRAM (64 KB)

The Challenge

The GBA's ARM7TDMI is 4x faster but speaks a completely different instruction set. The screen is wider (240x160 vs 160x144). Tiles are 4bpp instead of 2bpp. Every aspect must be translated—and the entire CPU core must fit in 32 KB of IWRAM, because accessing EWRAM costs wait states that destroy the 3:1 cycle ratio ChromA needs to keep up with the emulated Game Boy.

The Translation: CPU

The Game Boy's LR35902 has 8-bit registers (A, B, C, D, E, H, L, F) and a 16-bit SP/PC. ChromA maps these onto the ARM's plentiful 32-bit registers, keeping the hot path entirely in registers with no memory loads.

Register Mapping

Click a Z80 register to see where it lives on ARM.

GB (LR35902) A + F B + C D + E H + L SP PC Cycles GBA (ARM7TDMI) r4 / r3 r5 r6 r7 r11 r9 r8
Click a register to see the mapping.

Fetch-Execute Loop

Each GB instruction is a handler in IWRAM. The main loop reads one opcode byte from the ROM pointer (mapped GB PC), uses it as an index into a 256-entry jump table, and branches. Each handler does its work, updates the cycle counter, and jumps back.

@ the `fetch` macro (src/gbz80mac.h) -- every handler *ends* with this
    subs    cycles, cycles, #N*CYCLE   @ r8  -- charge this instruction
    ldrplb  r0, [gb_pc], #1        @ r9  -- fetch opcode, advance PC
    ldrpl   pc, [gb_optbl, r0, lsl #2] @ r10 -- jump straight to the handler
    ldr     pc, nexttimeout          @ budget spent: run the scanline hook

There is no cpu_loop label to return to: this dispatch is tail-duplicated into the end of every handler, so a finished instruction jumps directly to the next one with no intervening branch. The pl condition on the middle two instructions is the whole trick — subs sets the flags, and if the cycle counter in cycles (r8) has gone negative the fetch and dispatch are skipped, falling through to nexttimeout. Checking the budget costs no extra instructions at all.

Deep Dive: Flag Mapping

The GB's flag register (F) has 4 flags: Z, N, H, C in bits 7-4. The ARM CPSR also has Z and C flags, so ChromA uses native ARM flag operations for many instructions—an adds sets Z and C natively. The half-carry (H) and subtract (N) flags have no ARM equivalent and must be computed manually and stored in the upper bits of the AF register pair.

The Translation: Memory

The GB's 64 KB address space must be mapped onto the GBA's flat 32-bit memory. ROM stays in the Game Pak. Everything else goes into EWRAM buffers, except the hottest state variables which live in IWRAM alongside the CPU core.

Click any region on either side to see the mapping.

Game Boy Game Boy Advance ROM 0x0000-0x7FFF VRAM 0x8000-0x9FFF SRAM 0xA000-0xBFFF WRAM 0xC000-0xDFFF OAM 0xFE00-0xFE9F I/O Registers 0xFF00-0xFF7F HRAM Game Pak ROM 0x08000000+ EWRAM (VRAM buf) XGB_VRAM in EWRAM EWRAM (SRAM copy) Buffered in EWRAM IWRAM (WRAM buf) XGB_RAM; GBC banks 2-7 in EWRAM EWRAM (OAM buf) OAM shadow buffer IWRAM (handlers) Trap + emulate in code IWRAM (state vars)
Click a memory region to see how it maps.
Deep Dive: Memory Read/Write Handlers

When the emulated CPU reads from or writes to an address, ChromA must decode which region it falls in and route it to the correct GBA memory. ROM reads use the GB PC pointer directly into the Game Pak. VRAM, WRAM, and SRAM reads use base pointers into EWRAM buffers. I/O writes are the most complex: each register (LCDC, STAT, SCX, SCY, etc.) has its own handler that may trigger side effects like DMA transfers or PPU mode changes.

@ Example: reading from HL (common pattern)
@ r5 = HL, r7 = memory base table
mov     r0, r5, lsr #12    @ page = addr >> 12
ldr     r1, [r7, r0, lsl #2]  @ base = table[page]
ldrb    r0, [r1, r5]         @ value = base[addr]

The Translation: Graphics

Tile Format Conversion

GB tiles are 2bpp with bitplane interleaving: each row of 8 pixels is encoded as 2 bytes. Byte 0 holds the low bit of each pixel, byte 1 holds the high bit. The GBA needs 4bpp linear: 2 pixels per byte, 4 bytes per row.

The trick: CHR_DECODE is a 256-entry lookup table. Each byte (8 bits representing one bitplane for a row) is expanded to 8 nibbles (32 bits). One lookup per bitplane byte, OR them together with a shift, and you have 4bpp output. No per-pixel bit twiddling needed.

Worked Example

Consider a row where pixels are: 3, 1, 2, 0, 1, 3, 0, 2. The bitplane encoding works like this:

Pixel colors:    3  1  2  0  1  3  0  2
In binary:      11 01 10 00 01 11 00 10

Bitplane 0 (low bits):  1 1 0 0 1 1 0 0  = 0xCC
Bitplane 1 (high bits): 1 0 1 0 0 1 0 1  = 0xA5

GB stores this as two bytes per row: CC A5

CHR_DECODE[0xCC] expands 8 bits to 8 nibbles.  build_chr_decode (lcd.s)
puts input bit 0 in the *top* nibble and bit 7 in the bottom one, so the
leftmost pixel ends up in the low nibble -- which is where the GBA's 4bpp
format wants it:
0x00110011   (pixels, left to right: 1,1,0,0,1,1,0,0)

CHR_DECODE[0xA5] expands the same way:
0x10100101   (1,0,1,0,0,1,0,1)

Final 4bpp = decode[0xCC] | (decode[0xA5] << 1):
0x00110011 | 0x20200202 = 0x20310213
nibbles, low to high: 3,1,2,0,1,3,0,2  -- the original colors!

Paint a tile and see the encoding

Click cells to cycle colors (0-3). Watch how the bytes change.

8x8 Tile Editor
GB Format (2bpp interleaved):
Blue = bitplane 0 (low bits), Orange = bitplane 1 (high bits)
CHR_DECODE expansion:
Each byte becomes 4 bytes (8 bits become 8 nibbles)
GBA Format (4bpp linear):
decode[bp0] | (decode[bp1] << 1) per row
Key insight: Without CHR_DECODE, converting one 8x8 tile would require extracting 128 individual bits (64 pixels x 2 bitplanes), shifting and masking each one. The lookup table converts 8 pixels per table read—just 16 reads per tile instead of 128 bit operations. A GB tile set is 384 tiles per VRAM bank (768 on GBC, the hardware maximum), and a heavy frame re-converts a large fraction of them every frame; at that rate this is the difference between making the deadline and not.

Scanline Rendering

The Game Boy doesn't render its screen all at once. It draws one horizontal line at a time, from top to bottom, exactly like a CRT television. Each of the 154 lines per frame goes through a fixed sequence of phases totaling 456 clock cycles. ChromA must emulate this precise rhythm because games depend on it — they read PPU status registers to know which phase is active and time their VRAM writes around it.

Below, the Y axis is scanlines (0–153) and the X axis is time within each scanline (456 cycles). The 160 pixels of actual screen output are produced only during the Pixel Transfer phase.

Line: 0 / Mode: OAM Scan
Phases of each scanline:
Mode 2: OAM Scan (80 cycles)
The PPU autonomously scans Object Attribute Memory (OAM) to find which sprites overlap this scanline (up to 10). The CPU is free to run game code — but OAM is locked, so any writes to it are silently dropped.
Mode 3: Pixel Transfer (172–289 cycles)
The PPU renders 160 pixels for this scanline — reading tile data from VRAM, combining background, window, and sprite layers, and pushing them to the LCD. This is fully automatic; the CPU keeps running game logic but both OAM and VRAM are locked. Games that need mid-scanline effects must wait for HBlank. On real hardware this phase is variable-length (172–289 dots, depending on sprite count and fine scroll) and steals the difference from Mode 0; ChromA renders a scanline at a time rather than a dot at a time, so it uses the 172-dot minimum.
Mode 0: HBlank (204 cycles)
Line done. ChromA fires all 4 GBA DMA channels:
DMA0: scroll + BG control (6 words from bigbuffer)
DMA1: DISPCNT (1 word)
DMA2: WIN0H (1 word)
DMA3: palette — 1-word self-refresh or 64-word buffer
DMA3 mode is chosen by a write counter (cleared each GBA VBlank). ≤4 palette writes during visible scanlines → 1-word: reads and writes the same PALRAM word, a no-op. >4 writes → 64-word: replays a per-scanline buffer from EWRAM. A counter (not a boolean) is needed because one GB frame spans ~2 GBA frames — a flag would race with the clear.
64-word can't be used for all games: EWRAM DMA is too slow (~256 cycles) and overruns HBlank, corrupting normal gameplay. Fixed-source + 64 words is also impossible — GBA has no source-reload, so it smears one 4-byte value across all palette slots.
Mode 1: VBlank (lines 144–153)
All 144 visible lines are drawn. The PPU is idle for 10 GB lines. ChromA uses the corresponding GBA VBlank (68 GBA scanlines) to do bulk work: render all sprites, convert dirty 2bpp tiles to 4bpp, transfer the final palette, and set up DMA for the next frame.
Why not spread this work across active scanlines? Two reasons. First, writing to GBA VRAM or OAM during active display would cause visible tearing — VBlank is the only safe window. Second, ChromA's per-scanline hook runs between every GB scanline to keep the two CPUs in sync, and its budget is effectively zero — a handful of instructions. ~70 ARM cycles is not the budget but the figure that broke Hercules GBC, which busy-waits on the STAT register in a tight loop (see KNOWN_ISSUES.md). Any extra work per scanline blows that budget.
In practice, even the heaviest game (Shantae) uses only 35% of the VBlank budget. The worst offender is sprite processing (~19 GBA scanlines for Shantae), but there's still comfortable headroom. The constraint isn't total compute — it's where that compute can run.
ChromA Events:
Deep Dive: timeout.s State Machine

ChromA's timeout.s manages the per-scanline state machine. After the CPU executes enough cycles for one scanline (456 GB cycles ≈ 1,368 ARM cycles at 3:1), the timeout handler fires. It checks the current LY (scanline counter) and decides what to do:

  • Line 0: Frame start. Refresh input, snapshot the full palette into pal_before (128 bytes). This frozen snapshot feeds transfer_palette_ at VBlank — insulating PALRAM from mid-frame game writes.
  • Lines 0-143: Per HBlank: DMA0 (scroll+BG, 6 words), DMA1 (DISPCNT, 1 word), DMA2 (WIN0H, 1 word), DMA3 (palette, 1 or 64 words). Palette splits recorded by ff69_w_tail every 32nd FF69 write when scanline changes.
  • Line 144 (VBlank): Copy pal_before → PALRAM. Render sprites. Convert dirty tiles. Read/clear pal_scanline_active counter. Arm DMA0-3 for next frame. Set up VCount splits if detected.
  • Lines 145-153: Continue VBlank work. Remaining tile rows converted, SRAM flushed if dirty.

The 3:1 cycle ratio is critical here: every GB cycle costs ~3 ARM cycles. With IWRAM full (the entire CPU core lives there), there is zero margin for EWRAM wait states in the hot loop.

Epilogue

ChromA translates an entire platform: CPU instructions one-by-one via a jump table, memory through page-table indirection, tiles through lookup-table expansion, and timing through a scanline-locked state machine. Every byte of IWRAM is spoken for.

The source is open. The demo runs in your browser.