Game-Boy-Emulation in C# – ROM-Dumping
Retro-Hardware mit .NET in Software nachbilden, Teil 1
Wer einen Emulator schreibt, steht früh vor einem klassischen Henne-Ei-Problem. Um den Emulator zu testen, braucht man ein ROM. Und um ein ROM zu nutzen, braucht man einen funktionierenden Emulator. Klingt nach einem Kreisproblem ohne Ausweg. Ist es aber nicht.
Das Henne-Ei-Problem
Im Leitartikel zu dieser Serie [1] haben wir die CPU des Game Boy emuliert, in dessen Folgeartikel [2] ging es um die PPU. Aber womit testet man das? Die naheliegendste Antwort: Man schreibt eigene kleine Programme in Assembler. Mit der RGBDS-Toolchain ist das erstaunlich angenehm, und für einzelne Opcodes oder die CPU-/PPU-Timings reicht es völlig. Wir haben es selbst so gemacht, und es hat uns weit gebracht.
Aber irgendwann reicht es nicht mehr. Man will wissen, ob der Emulator wirklich stimmt – nicht nur im Kleinen, sondern im Großen. Ob „Tetris“ das Spielfeld aufbaut, ob die Menüs von „Zelda: Link’s Awakening“ scrollen, ob „Pokémon Rot“ das Intro zeigt. Das leisten selbstgeschriebene Testprogramme nicht. Dafür braucht man echte ROMs – die Spieledaten echter Cartridges. Und hier beginnt das eigentliche Abenteuer.
Legale Grundlage: Dump deine eigene Cartridge
Kurz innehalten. ROM-Dumps bewegen sich in einer rechtlichen Grauzone, sobald man Spiele aus dem Internet lädt. Uns interessiert etwas anderes: das Auslesen der eigenen, physisch vorhandenen Cartridges. Das ist – sofern man das Original besitzt und der Dump dem eigenen Gebrauch dient – unproblematischer und obendrein viel interessanter, weil man versteht, was da passiert.
Das Ziel: die Rohdaten einer echten Cartridge auslesen und als .gb-Datei speichern. Klingt aufwendig, ist es aber nicht – mit der richtigen Hardware und etwas C#.
Die Hardware: ein Arduino als Cartridge-Reader
Eine Game-Boy-Cartridge ist elektrisch erstaunlich simpel: im Grunde nur ein adressierbares Byte-Array. Über 16 Adressleitungen (A0–A15) gibt man an, welches Byte man will, über 8 Datenleitungen (D0–D7) kommt es zurück. Vier Steuerleitungen – /RD, /WR, /CS und /RESET – koordinieren den Zugriff. Das war es schon.
Als Brücke dient ein Keyestudio Mega 2560 Plus, ein Arduino-kompatibler Mikrocontroller mit ATmega2560. Sein Vorteil gegenüber einem Arduino Uno: genug freie I/O-Pins für 8 Daten-, 16 Adress- und 4 Steuerleitungen gleichzeitig – 28 Pins, direkt mit dem Cartridge-Slot verdrahtet. 220-Ω-Widerstände auf den Datenleitungen und 10-kΩ-Pull-ups auf /RD, /WR, /CS und /RESET schützen Arduino wie Cartridge vor Spannungsspitzen. Welche Adresse wann angefahren wird, entscheidet dabei nicht der Arduino selbst: Er liefert einzelne Bytes auf Zuruf, den Takt gibt C# vor.
Die Firmware: das Protokoll
Der Arduino bekommt eine eigene Firmware, den gb_reader. Sie implementiert ein schlankes, binäres Rahmenprotokoll über die serielle Schnittstelle (500.000 Baud, 8N1): Jeder Frame ist nach dem Schema A5 5A SEQ CMD LEN_LO LEN_HI [Payload] aufgebaut, wobei SEQ verspätete Antworten auf längst abgeschriebene Kommandos erkennt und verwirft. Eine eigene Prüfsumme braucht das Protokoll dabei nicht – das übernimmt USB schon auf Hardwareebene:
Protokoll-Kommandos der Cartridge
| Kommando | Bedeutung |
|---|---|
| PING | Verbindungstest |
| READ | Einzelnes Byte an einer Adresse lesen |
| WRITE | Byte in ein Bankswitching-Register schreiben |
| ARM | Schreibzugriffe freischalten (per Magic-Wert) |
| DISARM | Schreibzugriffe wieder sperren |
Fünf Kommandos, bewusst minimal: Die Firmware kennt nur Adressen, Bytes und Bustiming – nichts von ROM-Größen, Bänken, Mappern oder Headern. Das entscheidet allein der .NET-Host, der Arduino beantwortet nur einzelne READ-Anfragen. Herzstück ist entsprechend die Leseroutine für ein einzelnes Byte. Bei 500.000 Baud zählt jede Mikrosekunde, deshalb greift sie direkt auf die AVR-Ports zu statt auf digitalWrite():
static inline uint8_t busRead(uint16_t addr) {
DATA_DDR = 0x00; // Datenbus zuerst als Eingang
setAddress(addr, csActiveFor(addr)); // /RD noch HIGH, während sich die Adresse setzt
CTRL_PORT &= ~(1 << L_RD); // /RD LOW: Cartridge legt Daten an
ACCESS_NOPS(); // ~500 ns warten, bis der Bus stabil ist
uint8_t value = DATA_PIN; // abtasten
CTRL_PORT |= (1 << L_RD); // /RD HIGH: Cartridge gibt den Bus frei
return value;
}
Eine wichtige Invariante bleibt trotzdem bestehen, auch wenn /WR für Bankswitching gebraucht wird: Schreibzugriffe sind standardmäßig gesperrt und müssen erst per ARM mit einem Magic-Wert freigeschaltet werden – und selbst dann akzeptiert die Firmware nur Schreibzugriffe innerhalb des Mapper-Registerbereichs. Ein eventueller Spielstand im batteriegepufferten RAM bleibt so unangetastet.
Der .NET Dumper: C# spricht mit dem Arduino
Auf der PC-Seite kapselt die Klasse SerialDevice das Rahmenprotokoll: Frames bauen, Sequenznummern verwalten, bei Time-out wiederholen. Die eigentliche Dump-Logik sitzt in Dumper:
using var device = new SerialDevice(port, baud: 500_000, verbose: false);
var result = new Dumper(device, blockSize: 64).Result;
File.WriteAllBytes("dump.gb", result.Rom);
Drei Zeilen. Cartridge einstecken, Programm starten, Kaffee holen. Wenn man zurückkommt, liegt dump.gb auf der Festplatte.
Darunter wird es interessant. Dumper liest zuerst die 80 Header-Bytes und prüft das Nintendo-Logo – schlägt es fehl, bricht der Dump sofort ab –, ermittelt über den Cartridge-Typ den passenden Mapper und iteriert Bank für Bank: Für jede Bank lässt sie den Mapper das Register schreiben und liest das 16-KB-Fenster in Blöcken von 64 Bytes aus. Bei 500.000 Baud dauert ein 32-KB-Dump grob ein bis zwei Sekunden, ein Megabyte-Spiel entsprechend länger – aber wer hat es beim Retro-Basteln schon eilig?
Der Cartridge-Header: Was verrät uns die Cartridge?
Jede Cartridge trägt in den Bytes 0x0100–0x014F einen kleinen Steckbrief: den Cartridge-Header. Er enthält unter anderem den Spieltitel als ASCII-String, den Typ des Memory-Bank-Controllers und die ROM-Größe. Wertet man ihn zuerst aus – die Klasse Header schneidet dazu die 80 Header-Bytes ab 0x0100 heraus –, weiß man schon vor dem vollständigen Dump, wie groß dieser wird.
Der Header liefert auch zwei Prüfsummen mit. Byte 0x014D prüft den Header selbst; wichtiger ist die globale Prüfsumme in 0x014E–0x014F über alle ROM-Bytes. Die Hardware wertet sie nie aus, weshalb sie ausschließlich uns zur Verfügung steht. Stimmt sie nach dem Dump nicht überein, war im Bankswitching etwas faul, und der Dumper meldet das.
Ein charmantes Detail: das Nintendo-Logo. In den Bytes 0x0104–0x0133 muss exakt dessen 48-Byte-Bitmuster stehen. Stimmt es nicht, verweigert der Game Boy den Start – Nintendos cleverer und juristisch wohl durchaus wirkungsvoller Kopierschutz, lange vor Cryptochips.
MBC: Warum manche Spiele größer sind als der Adressraum
Aus dem Leitartikel unter [1] wissen wir: Der Adressbus ist 16 Bit breit, adressiert also maximal 64 KB. „Tetris“ passt mit 32 KB locker hinein. Aber wie bringt man „Pokémon Rot“ mit seinen 1 MB unter? Die Antwort steckt im MBC-Byte des Headers – dem Memory Bank Controller.
Der MBC ist ein kleiner Chip auf der Cartridge-Platine, zwischen Adressbus und ROM. Er betreibt Bankswitching: Der Game Boy schreibt einen Bankindex in den Bereich 0x2000–0x3FFF, woraufhin der MBC einen anderen 16-KB-Abschnitt des ROM in den Bereich 0x4000–0x7FFF einblendet. So passen bis zu 8 MB in einen 16-Bit-Adressraum – elegant.
Für den Dumper heißt das: Die Klasse Mapper bildet dieses Zusammenspiel nach. Sie erkennt am Cartridge-Typ-Byte die Familie – reines ROM, MBC1, MBC2 oder MBC3 decken praktisch jedes Spiel der ursprünglichen Game-Boy-Generation ab – und übersetzt jede Bank-Nummer in die passenden Registerschreibzugriffe. Bei „Tetris“ (ROM-only) bleibt das ein No-Op, bei „Zelda: Link’s Awakening“ (MBC1) landet der Bankindex in einem oder zwei Registern, bei „Pokémon Rot“ (MBC3) genügt ein einziges Register. Ein Chip im Cent-Bereich löst hier ein Problem, das in Software deutlich aufwendiger wäre – Hardware-Cleverness von 1989.
Vom Dump zum Emulator
Liegt die dump.gb erst einmal auf der Festplatte, schließt sich der Kreis. Der Emulator aus dem Leitartikel unter [1] lädt das ROM per File.ReadAllBytesAsync() in ein byte[] und übergibt es der Cpu-Klasse. Wenn man jetzt noch die PPU implementiert, dann erscheint auch schon das Startlogo von „Tetris“, gefolgt vom Titelbildschirm. Oder – nach ein paar Stunden PPU-Debugging – das Intro von „Zelda: Link’s Awakening“. Das ist der Moment, in dem aus einem Softwareprojekt ein Emulator wird: Echter Spielecode läuft auf der eigenen, in C# geschriebenen Hardware.
Fazit: Vom Testprogramm zum echten Spiel
Die richtige Schichtung macht den Unterschied: Man beginnt mit kleinen, kontrollierbaren ASM-Testprogrammen, baut den Emulator Schritt für Schritt und validiert ihn gegen die Referenz-Emulatoren der Community. Steht die Grundlage fest genug, schließt man das echte Spiel an – per selbst gebautem Dumper oder freiem Open-Source-ROM.
Dass man dabei gleichzeitig einen Arduino in der Hand hat, eine serielle Verbindung debuggt, einen kartenhausartigen Stack aus Widerständen auf einem Breadboard aufbaut und zwischendurch den Arm in die Kiste mit den alten Cartridges steckt – das ist kein Nachteil. Das ist der Spaß an der Sache. Batteries not included. Lötkolben schon.
[1] Stefan Fiedler, Batteries not included, dotnetpro 4/2026, Seite 112 ff.
[2] Stefan Fiedler, Thomas Lipper, Eine Welt in vier Graustufen, dotnetpro 5/2026, Seite 126 ff.
- Das Henne-Ei-Problem
- Legale Grundlage: Dump deine eigene Cartridge
- Die Hardware: ein Arduino als Cartridge-Reader
- Die Firmware: das Protokoll
- Der .NET Dumper: C# spricht mit dem Arduino
- Der Cartridge-Header: Was verrät uns die Cartridge?
- MBC: Warum manche Spiele größer sind als der Adressraum
- Vom Dump zum Emulator
- Fazit: Vom Testprogramm zum echten Spiel