Game Boy + Arduino Due: Reading a Cartridge Directly (Part 1)

Dmitry (rrock) Shchannikov
calendar_today
schedule 5 mins read

I have always been into retro devices and hardware projects, and the Game Boy is almost ideal for this: the architecture is relatively simple, and the cartridge interface is well documented.

In this article, I will show how I approached reverse-engineering the GB-MAX cartridge and how I implemented minimal read access through an Arduino Due without extra chips like 74HC595/74HC165.


How the project started

I bought a Game Boy on eBay and a universal GB-MAX cartridge plus microSD card on AliExpress. After experimenting with it, I became curious about the low-level design: how the OS is loaded, how bank selection works, and how to dump data directly.

After opening the cartridge, I found that the GB-MAX board uses:

  • CPLD Altera MAX II EPM240T100C5N
  • SRAM LY62L2568
  • Flash M29W640FB

The logic is powered at 3.3V (converted from 5V), and there are resistor-based level drops on address/control/data lines. This is an important detail for safe interfacing.


Why dump onboard Flash if there is microSD?

Even though the cartridge loads the main OS from microSD, it also contains onboard Flash with an older factory firmware version.

The obvious way to read it would be desoldering the chip and using a programmer. But I wanted to do it through the standard Game Boy cartridge interface, as if I were the console reading memory.


Game Boy cartridge interface (short overview)

According to Pan Docs, the cartridge connector exposes a classic parallel bus:1

  • A0..A15 - address (16-bit)
  • D0..D7 - data (8-bit)
  • /CS - chip select
  • /RD - read
  • /WR - write
  • /RES - reset
  • VDD - +5V
  • GND - ground

Useful minimum address map:

  • 0x0000..0x3FFF - fixed ROM bank
  • 0x4000..0x7FFF - switchable ROM bank (via MBC)
  • 0xA000..0xBFFF - external RAM (if present)

Basic read cycle:

  1. Set D0..D7 as input.
  2. Put the target address on A0..A15.
  3. Pull /CS and /RD low.
  4. Read one byte from the data bus.
  5. Release /RD and /CS high.

Why Arduino Due fits this project

I specifically wanted to avoid intermediate shift registers, so I went with direct wiring to a dev board. From what I had available, Arduino Due was the best option:

  • 54 digital pins, enough for address/data/control lines.
  • 84 MHz clock, plenty of timing headroom for a slow external bus.
  • 3.3V logic, which matches the 3.3V logic section inside GB-MAX.

Important: Due GPIO pins are not 5V-tolerant. Do not feed more than 3.3V into inputs.2 In my case, I am working with GB-MAX where the logic already runs at 3.3V and input levels are lowered internally.


Basic wiring

Wiring concept (I will add a real build photo in the post):

  • Cartridge VDD -> Arduino Due 5V
  • GND -> GND
  • A0..A15 -> any 16 GPIO pins
  • D0..D7 -> any 8 GPIO pins (bidirectional)
  • /CS, /RD, /WR, /RES -> dedicated GPIO pins

Practical tips that help:

  • Keep bus wires short.
  • Use a solid common ground topology.
  • During reads, always keep D0..D7 as INPUT to avoid bus contention.

Minimal firmware example (byte reads)

CPP
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#include <Arduino.h>

// Put your own pin numbers here in the same order as your wiring
const uint8_t A_PINS[16] = {22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37};
const uint8_t D_PINS[8]  = {38, 39, 40, 41, 42, 43, 44, 45};

const uint8_t PIN_CS  = 46; // /CS
const uint8_t PIN_RD  = 47; // /RD
const uint8_t PIN_WR  = 48; // /WR
const uint8_t PIN_RST = 49; // /RST

static inline void setAddress(uint16_t addr) {
  for (uint8_t i = 0; i < 16; i++) {
    digitalWrite(A_PINS[i], (addr >> i) & 0x01);
  }
}

static inline void setDataBusInput() {
  for (uint8_t i = 0; i < 8; i++) pinMode(D_PINS[i], INPUT);
}

static inline uint8_t getData() {
  uint8_t data = 0;
  for (uint8_t i = 0; i < 8; i++) {
    data |= (digitalRead(D_PINS[i]) ? 1 : 0) << i;
  }
  return data;
}

uint8_t readByte(uint16_t addr) {
  setDataBusInput();
  setAddress(addr);  

  digitalWrite(PIN_CS, LOW);
  digitalWrite(PIN_RD, LOW);
  delayMicroseconds(1);

  uint8_t data = getData();

  digitalWrite(PIN_RD, HIGH);
  digitalWrite(PIN_CS, HIGH);

  return data;
}

void setup() {
  Serial.begin(115200);

  for (uint8_t i = 0; i < 16; i++) pinMode(A_PINS[i], OUTPUT);
  
  setDataBusInput();

  pinMode(PIN_CS, OUTPUT);
  pinMode(PIN_RD, OUTPUT);
  pinMode(PIN_WR, OUTPUT);
  pinMode(PIN_RST, OUTPUT);

  digitalWrite(PIN_CS, HIGH);
  digitalWrite(PIN_RD, HIGH);
  digitalWrite(PIN_WR, HIGH);
  digitalWrite(PIN_RST, HIGH);

  // Read the first 256 bytes of ROM
  for (uint16_t addr = 0; addr < 0x100; addr++) {
    uint8_t b = readByte(addr);
    if ((addr & 0x0F) == 0) {
      Serial.println();
      if (addr < 0x1000) Serial.print('0');
      if (addr < 0x0100) Serial.print('0');
      if (addr < 0x0010) Serial.print('0');
      Serial.print(addr, HEX);
      Serial.print(": ");
    }
    if (b < 0x10) Serial.print('0');
    Serial.print(b, HEX);
    Serial.print(' ');
  }
  Serial.println();
}

void loop() {}

This is already enough to verify that the bus works and bytes can be read reliably.


Next

In part 2, I will cover:

  • how to select banks through MBC,
  • how to read onboard Flash in a more systematic way,
  • and how to build a full dumper.

  1. Pan Docs, External Connectors / Cartridge Slot: https://gbdev.io/pandocs/External_Connectors.html ↩︎

  2. Arduino Due docs/store warning (I/O max 3.3V): https://store.arduino.cc/products/arduino-due ↩︎

comments powered by Disqus