2 Punkte von GN⁺ 2025-07-06 | 1 Kommentare | Auf WhatsApp teilen
  • CAMLBOY ist ein in OCaml entwickelter Game-Boy-Emulator, der im Browser läuft
  • Das Projekt wurde gewählt, um die Entwicklung mittelgroßer bis größerer Projekte und die Nutzung fortgeschrittener Features in OCaml praktisch zu erlernen
  • Es nutzt verschiedene Eigenschaften der OCaml-Sprache praxisnah, darunter Grundstruktur, Abstraktion, GADT, Funktoren und den Austausch von Modulen zur Laufzeit
  • Es läuft im Browser mit 60 FPS, und es werden Erfahrungen aus Performance-Verbesserung, Bottleneck-Analyse und Optimierung geteilt
  • Es fasst das OCaml-Ökosystem, Testautomatisierung sowie den Einfluss der Emulator-Entwicklung auf die Verbesserung praktischer Fähigkeiten zusammen

Projektüberblick

  • Über mehrere Monate hinweg wurde am CAMLBOY-Projekt gearbeitet, um einen Game-Boy-Emulator in OCaml zu bauen
  • Er ist auf der Demo-Seite ausführbar und enthält verschiedene Homebrew-ROMs
  • Das Repository ist auf GitHub öffentlich verfügbar

Motivation zum Lernen von OCaml und Hintergrund der Projektauswahl

  • Beim Lernen einer neuen Sprache wurde eine Grenze darin gespürt, wie man mittelgroßen/großen Code schreibt und wie fortgeschrittene Features tatsächlich eingesetzt werden
  • Um dieses Problem zu lösen, entstand das Bedürfnis nach praktischer Projekterfahrung, weshalb die Entwicklung eines Game-Boy-Emulators gewählt wurde
  • Gründe
    • Die Spezifikation ist klar, sodass der Implementierungsumfang feststeht
    • Es ist komplex genug, aber in wenigen Monaten abschließbar
    • Die persönliche Motivation ist hoch

Ziele des Emulators

  • Code schreiben, der Lesbarkeit und Wartbarkeit in den Mittelpunkt stellt
  • Mit js_of_ocaml nach JavaScript kompilieren und im Browser ausführen
  • Spielbare FPS auch in mobilen Browsern erreichen
  • Performance-Benchmarks für verschiedene Compiler-Backends implementieren

Ziel des Artikels und wichtigste Inhalte

Ziel dieses Artikels ist es, die Reise beim Bau eines Game-Boy-Emulators mit OCaml zu teilen
Behandelt werden:

  • Ein Überblick über die Game-Boy-Architektur
  • Wie sich testbarer und wiederverwendbarer Code strukturieren lässt
  • Der praktische Einsatz fortgeschrittener OCaml-Features wie Functor, GADT und First-Class-Module
  • Das Finden von Performance-Bottlenecks sowie Erfahrungen mit Optimierung und Verbesserungen
  • Gedanken zu OCaml im Allgemeinen

Gesamtstruktur und zentrale Interfaces

  • Zentrale Hardware-Komponenten wie CPU, Timer und GPU arbeiten nach einem synchronisierten Takt
  • Der Bus ist dafür zuständig, Daten je nach Adresse an die einzelnen Hardware-Module weiterzugeben bzw. darauf zuzugreifen
  • Jedes Hardware-Modul implementiert das Interface Addressable_intf.S
  • Der gesamte Bus folgt dem Interface Word_addressable_intf.S

Funktionsweise der Main Loop

  • Zur Synchronisierung der Hardware führt die Main Loop die folgenden Schritte zyklisch aus
    1. Einen CPU-Befehl ausführen und die verbrauchten Zyklen erfassen
    2. Timer und GPU um dieselbe Anzahl an Zyklen fortschreiten lassen
  • Auf diese Weise wird der Synchronisationszustand der realen Hardware nachgebildet
  • Die Erklärung erfolgt zusammen mit Beispielcode zur Implementierung

Abstraktion für das Lesen/Schreiben von 8-Bit- und 16-Bit-Daten

  • Viele Module implementieren ein Ein-/Ausgabe-Interface für 8-Bit-Daten (Addressable_intf.S)
  • 16-Bit-Lese-/Schreibzugriffe werden über Word_addressable_intf.S erweitert, geerbt und funktional ergänzt
  • Mit Signaturen, Modultypen und include in OCaml wird eine Abstraktionsschicht aufgebaut

Implementierung von Bus, Registern und CPU

  • Bus: übernimmt adressbasiertes Routing zu den einzelnen Hardware-Modulen und verzweigt anhand der Memory-Map
  • Register: bieten Interfaces zum Lesen/Schreiben von 8-Bit- und 16-Bit-Registern
  • CPU: war anfangs stark vom Bus abhängig, wodurch Tests schwierig waren
    • Durch den Einsatz eines Funktors wurde die Abhängigkeit abstrahiert und Mock-Injektion möglich
    • Dadurch wurde das Schreiben von Unit-Tests deutlich einfacher

Darstellung des Instruction Sets mit GADT

  • Der Game Boy besitzt sowohl 8-Bit- als auch 16-Bit-Befehle, daher ist Typsicherheit bei der Definition der Instruktionen nötig
  • Ein einfacher Variant-Ansatz führte zu Problemen mit kollidierenden Rückgabetypen bei komplexem Pattern Matching
  • Mit GADT (Generalized Algebraic Data Type) lassen sich Ein- und Ausgabetypen sicher abgleichen
  • Beim Einsatz von GADT können Argument- und Rückgabetypen jeder Instruktion exakt typinferenzbasiert bestimmt werden
  • So lassen sich komplexe Befehlsmuster und Parameter sicher behandeln

Cartridge und Auswahl von Modulen zur Laufzeit

  • Game-Boy-Cartridges können zusätzlich zu einfachem ROM weitere Hardware wie MBC, Timer usw. enthalten
  • Für jeden Typ müssen eigene Module implementiert und zur Laufzeit passende Module gewählt werden
  • Mit First-Class-Modulen werden Laufzeitwechsel von Modulen und Erweiterbarkeit realisiert

Tests und explorative Entwicklung

  • Nutzung von Test-ROMs und ppx_expect
    • Test-ROMs pro Funktion: prüfen konkrete Bereiche wie arithmetische Operationen oder MBC-Unterstützung
    • Bei Fehlern ist eine klare Diagnose möglich, etwa über Bildschirmausgabe
  • Integrationstests sichern Vertrauen bei umfangreichen Refactorings und beim Hinzufügen neuer Features
  • Es wird ein explorativer Entwicklungsansatz genutzt: mit Test-ROMs wiederholt implementieren und verifizieren

Browser-UI und Performance-Optimierung

  • Mit js_of_ocaml ist ein einfacher JS-Build möglich
  • Mit der Bibliothek Brr lässt sich sicher im OCaml-Stil auf die JavaScript-DOM-API zugreifen
  • Die anfängliche Performance (20 FPS) war niedrig, doch mit dem Chrome-Profiler wurden Bottlenecks in GPU, Timer, Bigstringaf usw. analysiert
  • Für jedes Modul wurden Optimierungs-Commits umgesetzt; durch Deaktivieren ineffizienten Inlinings im JS-Build wurden schließlich 60 FPS (PC/Mobil) erreicht
  • Im nativen Build erreicht die Performance bis zu 1000 FPS

Benchmarks und Hardware-Vergleich

  • Ein headless Benchmark-Modus wurde implementiert, um FPS in jeder Umgebung messen zu können

Emulator-Entwicklung und praktische Fähigkeiten

  • Ähnlich wie beim Competitive Programming wird ein Zyklus aus klarer Spezifikationsinterpretation → Implementierung → Verifikation wiederholt
  • Das ist eine Erfahrung, die bei spezifikationsbasierter Entwicklung und beim Testen praktisch hilft

Aktuelles OCaml-Ökosystem und Fortschritte bei den Tools

  • dune bietet die Erfahrung eines einfachen Build-Systems
  • Mit Merlin und OCamlformat werden Autovervollständigung, Code-Navigation und Formatierung erleichtert
  • Mit setup-ocaml lässt sich das auch leicht in GitHub Actions einsetzen

Gedanken zu funktionalen Sprachen

  • Es wird die Beschreibung funktionaler Sprachen als Minimierung von Side Effects hinterfragt
  • Hinter Abstraktionen verborgener veränderlicher Zustand wird aus Performance-Gründen aktiv genutzt
  • Bevorzugt werden statische Typen, Pattern Matching, Modulsysteme und Typherleitung

Unbequemlichkeiten und die Kosten abstrahierter Abhängigkeiten

  • Die Standardisierung der Abhängigkeitsverwaltung ist noch komplex und unzureichend erklärt (z. B. opam)
  • Wenn durch eine Modul-Funktor-Struktur Abstraktion hinzugefügt wird, muss oft sogar die gesamte Struktur der Abhängigkeitsschichten angepasst werden
  • Anders als in OOP muss bei der Einführung von Abstraktion auch die Art verändert werden, wie übergeordnete abhängige Module geschrieben werden

Empfohlene Lernmaterialien

Fazit

  • Durch das CAMLBOY-Projekt wurden fortgeschrittene OCaml-Features sowie Tests, Abstraktion und Browser-Kompatibilität praxisnah erlebt
  • Die Vorteile und Grenzen aus der Weiterentwicklung des Ökosystems und aus realer Entwicklungserfahrung wurden klar erkannt
  • Die Entwicklung eines Emulators hilft ganz praktisch dabei, die Fähigkeiten von Entwicklerinnen und Entwicklern auf mittlerem Niveau und darüber hinaus zu verbessern

1 Kommentare

 
GN⁺ 2025-07-06
Hacker-News-Kommentare
  • Ich frage mich, ob jemand überzeugend sagen würde, dass sich eine bestimmte Programmiersprache besser zum Schreiben von Emulatoren, virtuellen Maschinen oder Bytecode-Interpretern eignet. „Besser“ meint hier nicht Performance oder weniger Implementierungsfehler, sondern eher, dass die Umsetzung und das Erkunden intuitiver sind, man mehr lernt und die Implementierungserfahrung selbst lohnender und unterhaltsamer ist. Erlang hat zum Beispiel im Bereich verteilter Systeme ein klares Ziel, und das Domänenwissen dieses Bereichs passt zur Sprachgestaltung, sodass man beim Einsatz ein tiefes Verständnis sowohl für verteilte Systeme als auch für Erlang selbst gewinnt. Ich frage mich, ob es in ähnlicher Weise eine Sprache gibt, deren Ziel es ist, „Maschinenverhalten in Code auszudrücken“

    • Ich möchte betonen, dass Systemprogrammiersprachen wie C, C++, Rust und Zig für mich persönlich die „befriedigendste“ Wahl sind. In diesen Sprachen entsprechen Datentypen (z. B. uint8) unmittelbar Bytes im Speicher, und Operationen wie memcpy sind direkt mit Blit-Operationen vergleichbar. Man muss sich kaum damit herumschlagen, wie in einer Sprache wie JavaScript den Typ Number für bitweise Operationen als Byte zu missbrauchen. Wenn man einen Emulator in JavaScript baut, stößt man auf solche Probleme sofort. Natürlich lässt sich letztlich alles ähnlich umsetzen, solange die Sprache Grafikausgabe und genügend Speicher unterstützt, und am meisten Freude hat man am Ende wohl mit der Sprache, mit der man selbst am vertrautesten ist

    • Haskell ist hervorragend für DSLs und die Datentransformationen, die man für Compiler braucht. OCaml, Lisp und moderne Sprachen mit Pattern Matching und ADTs sind ebenfalls gut geeignet. Modernes C++ kann mit variant-Typen und Ähnlichem Vergleichbares versuchen, aber nicht besonders elegant. Wenn man tatsächlich Spiele im Emulator laufen lassen will, sind C oder C++ die Standardwahl. Rust ginge vermutlich auch ganz okay, aber bei Low-Level-Speichermanipulationen bin ich mir nicht sicher

    • Ich vertrete die Ansicht, dass es keine Sprache gibt, die sich speziell besser zum Erstellen von Emulatoren, virtuellen Maschinen oder Bytecode-Interpretern eignet. Wenn man Arrays hat (konstanter Zugriff auf beliebige Indizes) und bitweise Operationen, ist die Implementierung sehr einfach. Solange man noch kein JIT in Betracht zieht, unterstützen auch funktionale Sprachen Arrays und bitweise Operationen

    • Ich würde sml, insbesondere den MLTon-Dialekt, empfehlen. Es teilt fast alle Gründe, aus denen OCaml gut ist, aber persönlich halte ich es unter den ML-Sprachen für die bessere, vollständigere Lösung. Das Einzige, was ich aus OCaml vermisse, sind applicative functors, aber das ist eher nur ein etwas anderer Modulaufbau und kein großer Unterschied

    • Wenn es im Browser vor allem um Spaß und Experimente geht, ist Elm ebenfalls eine gute Option. Für ein ähnliches Projekt lohnt sich ein Blick auf elmboy

  • Dieser Beitrag ist nicht nur wegen OCaml großartig, sondern auch, weil er den Implementierungsprozess eines Game-Boy-Emulators gehaltvoll zusammenfasst. Wirklich tolles Material. Vielen Dank an den Autor. Außerdem trage ich schon lange die Idee mit mir herum, dass es für die Embedded-Entwicklungsbildung nützlich wäre, wenn man im Browser einen Assembler-Editor und dazu Assembler, Linker und Loader in einer einzigen SPA bündeln würde, damit jeder leicht Gameboy-Homebrew-Entwicklung ausprobieren kann

    • Das Projekt rgbds-live ist dieser Idee ähnlich und hat RGBDS eingebaut. rgbds-live
  • Ich frage mich, ob jemand nach einem Tutorial zur Sound-Implementierung in einem Game-Boy-Emulator sucht. Die meisten Tutorials erklären den Sound nicht, und selbst wenn man versucht, es direkt zu implementieren, war es anhand der vorhandenen Materialien schwer zu verstehen und umzusetzen

    • Es ist zwar kein offizielles Tutorial, aber ich teile ein Folienset mit zwei Seiten, das die von mir implementierte Vorgehensweise zusammenfasst: Folien Der Game-Boy-Sound hat vier Kanäle, und jeder Kanal gibt bei jedem Tick einen Wert zwischen 0 und 15 aus. Der Emulator muss diese addieren (arithmetischer Mittelwert), auf den Bereich 0–255 skalieren und in den Soundpuffer ausgeben. Entsprechend der Tick-Rate (4,19 MHz) und der Soundausgabe (22 kHz usw.) muss ungefähr alle 190 Ticks ein Wert ausgegeben werden. Die Eigenschaften der einzelnen Kanäle sind in diesem Material gut zusammengefasst. Kanal 1 und 2 sind Rechteckwellen (wiederholtes 0/15), Kanal 3 ist eine beliebige Wellenform (Lesen aus dem Speicher), Kanal 4 ist Rauschen auf LFSR-Basis. Ein Blick in Beispielcode SoundModeX.java lohnt sich

    • Dieses Material ist ebenfalls ziemlich gut

    • Dieses YouTube-Video ist auch sehenswert

  • Mein Eindruck: ein wirklich toller Artikel und ein cooles Projekt

  • Mir ist aufgefallen, dass die Demo viel zu schnell läuft. Das Kontrollkästchen für Throttle scheint kaum Wirkung zu haben. Eher wirkt es sogar langsamer, wenn man es deaktiviert. Mit aktiviertem Throttle sind es 240 fps, ohne 180 fps. Wenn Throttle aktiviert ist, fühlt sich eine Sekunde im echten Emulator wie etwa vier Sekunden an. Vermutlich hängt das mit der Bildwiederholrate des Monitors von 240 Hz zusammen

    • Vermutlich wird nur requestAnimationFrame() aufgerufen und die Berechnung von deltaTime fehlt
  • Ich finde, das ist ein wirklich wunderschöner Artikel. Danke, dass du so etwas teilst. Ich habe jetzt Lust bekommen, selbst einen Game-Boy-Emulator in Rust zu bauen, und habe den Blogbeitrag als große Inspiration gespeichert

  • Ein wirklich schönes Beispiel für den Einsatz von functors und GADTs. Ich würde das gern mit einem CHIP-8- oder NES-Emulator vergleichen, und es wäre auch interessant, CAMLBOY mit ocaml-wasm nach WASM zu portieren

    • Es gibt den neuen WASM-Backend von js_of_ocaml (wasm_of_ocaml), daher sollte man CAMLBOY wahrscheinlich schon jetzt in WASM ausführen können