Writing a Gameboy Emulator in OCaml

214 Views

February 06, 25

スライド概要

In this talk, I will take you through my journey of writing CAMLBOY, a Game Boy emulator in OCaml. I will share why I decided to start the project, the difficulties I faced, how I overcame them using OCaml's language features, and the lessons I learned along the way.

profile-image

都内在住のエンジニア

シェア

またはPlayer版

埋め込む »CMSなどでJSが使えない場合

関連スライド

各ページのテキスト
1.

Writing a Game Boy Emulator in OCaml Lin Oshitani @linoscope

2.

Have you ever felt like this when learning a new programming language?

3.

● You can write simple program snippets but you don’t know how to write medium/large scale code. ● You have studied advanced language features and have a rough understanding of how they work, but you don’t know how to use them in practice.

4.

● You can write simple program snippets but you don’t know how to write medium/large scale code. ● You have studied advanced language features and have a rough understanding of how they work, but you don’t know how to use them in practice. Exactly how I felt when I started learning

5.

How I felt

6.

Beginner Books Hobby Myself Simple programs

7.

Beginner Intermediary/Pro Books Hobby Myself Simple programs Job Open source

8.

Beginner Intermediary/Pro Books Hobby Myself Simple programs I can't reach the other side by continuing down the current path... Job Open source

9.

Beginner Intermediary/Pro Books Hobby Myself Simple programs Job I can't reach the other side by continuing down the current path... The "Intermediate Valley" Open source

10.

What should I do?

11.

Let’s work on a project to gain practical experience!

12.

Game Boy Emulator!

13.

A Game Boy emulator written in OCaml. Compiled to JavaScript using the js_of_ocaml library. https://github.com/linoscope/CAMLBOY

14.

Implementing CAMLBOY helped me overcome the “Intermediate valley”. This presentation aims to share that experience.

15.

Why Game Boy Emulator?

16.

The specifications are clear, so there's no need to think about what to implement. It’s similar to leetcode. “Test cases pass” = “Game cartridge runs”.

17.

Medium/large scale “Medium/large scale” = Code that is difficult to develop without tests, and therefore needs to be designed in a way that makes writing tests easy”.

18.

Emotional attachment Memory of waiting until my family is sound asleep, then hiding under the covers and playing Pokémon on my Game Boy Light.

19.

Implementation

20.

What is an Emulator, Anyway?

21.

Game Hardware Hardware Machine Code ... VRAM LD A, B CPU JMP 0x1200 ADD A, 0x12 LD A, C Input RAM JMP 0x1201 Output ADD A, 0x12 LD A, 0x12 Cartridge Slot ... https://www.suruga-ya.jp/produ ct/detail/165005345 https://item.fril.jp/76e17f08bf11abdf5d244a3dde1c3079 https://www.copetti.org/writings/consoles/game-boy/

22.

Emulators Hardware Machine Code ... VRAM LD A, B CPU JMP 0x1200 ADD A, 0x12 Input LD A, C RAM JMP 0x1201 Output ADD A, 0x12 LD A, 0x12 Cartridge Slot ... https://www.suruga-ya.jp/produ ct/detail/165005345 https://item.fril.jp/76e17f08bf11abdf5d244a3dde1c3079 https://www.copetti.org/writings/consoles/game-boy/ In pu t Software Output

23.

Emulators Hardware Machine Code ... VRAM LD A, B CPU JMP 0x1200 ADD A, 0x12 Input LD A, C RAM JMP 0x1201 Output ADD A, 0x12 LD A, 0x12 Cartridge Slot ... https://www.suruga-ya.jp/produ ct/detail/165005345 https://item.fril.jp/76e17f08bf11abdf5d244a3dde1c3079 https://www.copetti.org/writings/consoles/game-boy/ In pu t The software “emulates” the hardware Software Output

24.

Architecture

25.

RAM Timer Joypad CPU Bus Registers Cartridge GPU Serial Port

26.

RAM Timer Joypad CPU Bus Registers Cartridge GPU Serial Port

27.

RAM Timer Joypad CPU Bus Registers Cartridge GPU Serial Port

28.

A "mediator" connecting the CPU and IO devices. RAM Timer Joypad CPU Bus Registers Cartridge GPU Serial Port

29.

RAM Timer Joypad CPU Bus Registers read 0x0000~0x3FFF Reading data from the cartridge. Cartridge GPU Serial Port

30.

Reading and writing data to memory RAM Timer Joypad read/write 0xC000~0xCFFF CPU Bus Registers Cartridge GPU Serial Port

31.

Change the timer’s speed. RAM CPU Timer Joypad write 0xFF07 Bus Registers Cartridge GPU Serial Port

32.

Implementing the CPU and Bus

33.

The first attempt implementing CPU and Bus module cpu.ml bus.ml type t = { type t = { bus : Bus.t; ... cartridge : Cartridge.t; ram : Ram.t; ... } ... } let x = Bus.read t.bus addr in ... ... let read t addr = (* route the read *) let write t addr data = (* route the write *) ...

34.

The first attempt cpu.ml bus.ml type t = { type t = { bus : Bus.t; ... } cartridge : Cartridge.t; ram : Ram.t; Problem! ... ... } let x = Bus.read t.bus addr in ... ... let read t addr = (* route the read *) let write t addr data = (* route the write *) ...

35.

Problem:CPU→Bus→Cartridge/… Is tightly coupled and cannot be tested separately bus.ml cpu.ml type t = { type t = { bus : Bus.t; ... } Depends on cartridge : Cartridge.t; ram : Ram.t; ... } ... let x = Bus.read t.bus addr in ... let read t addr = ... let write t addr data = ... ... Depends on cartridge .ml ram.ml ...

36.

There was a thing called functors…

37.

There was a thing called functors… Functions that takes a module and returns a module

38.
[beta]
Solution: Use functors to make the CPU rely on the interface of
the bus instead of the implementation.
cpu.ml

bus_intf.ml

module Make (Bus : Bus_intf.S) = struct
type t = {
bus

: Bus.t;

...
}
...
let x = Bus.read t.bus addr in

...
end

module type S = sig

Depends
on

type t
val read

: t -> addr -> uint16

val write

: t -> addr -> uint16 -> unit

...
end

Impl

Impl

bus.ml

mock_bus.ml

39.

In the unit test of the CPU I can insert a mock implementation of the bus test_cpu.ml module Cpu = Cpu.Make(Mock_bus) ...

40.

Using functors allows you to write loosely coupled, testable code that depends on "interfaces" rather than "implementations."

41.

Implementation of CPU in detail

42.

The CPU has 8-bit registers and 16-bit registers. registers.mli (** 8-bit register identifiers *) type r = A | B | C | D | E | F | H | L (** 16-bit registers identifiers *) type rr = AF | BC | DE | HL (** read/write for 8-bit registers *) val read_r : t -> r -> uint8 val write_r : t -> r -> uint8 -> unit (* read/write for 16-bit registers *) val read_rr : t -> rr -> uint16 val write_rr : t -> rr -> uint16 -> unit

43.

The instruction set includes 8-bit instructions and 16-bit instruction # 8-bit version. # Add 0x12 to the contents of the A register (8-bit). ADD8 A, 0x12 # 16-bit version. # Add 0x1234 to the contents of the AF register (16-bit). ADD16 AF, 0x1234 So, how should we go about defining this instruction set?

44.

First approach: Variants (algebraic data types) instruction.ml type arg = | R of Registers.r (* 8-bit Registers *) | RR of Registers.rr (* 16-bit Registers *) | Immediate8 of uint8 (* 8-bit Value *) | Immediate16 of uint16 (* 16-bit Value *) | ... type t = | ADD8 of arg * arg | ADD16 of arg * arg | ...

45.

First approach: Variants (algebraic data types) instruction.ml type arg = | R of Registers.r (* 8-bit Registers *) | RR of Registers.rr (* 16-bit Registers *) | Immediate8 of uint8 (* 8-bit Value *) | Immediate16 of uint16 (* 16-bit Value *) | ... type t = | ADD8 of arg * arg | ADD16 of arg * arg | ... Does Not Work

46.

Why? We get stuck in the execute function! cpu.ml let execute t (inst : Instruction.t) = let read_arg arg = match arg with | R r -> Registers.read_r r | RR rr -> Registers.read_rr rr | ... in match inst with | Add8 (x, y) -> let sum = Uint8.add (read_arg x) (read_arg y) in ... | Add16 (x, y) -> let sum = Uint16.add (read_arg x) (read_arg y) in ...

47.

Why? We get stuck in the execute function! cpu.ml let execute t (inst : Instruction.t) = let read_arg arg = match arg with | R r -> Registers.read_r r Stuck here! | RR rr -> Registers.read_rr rr | ... in match inst with | Add8 (x, y) -> let sum = Uint8.add (read_arg x) (read_arg y) in ... | Add16 (x, y) -> let sum = Uint16.add (read_arg x) (read_arg y) in ...

48.

Problem: The return value differs by constructor you match. Hence you cannot type the function. cpu.ml (* What is the return type??? *) let read_arg (arg : Instruction.arg) -> ??? = match arg with | R r -> (* When R is matched, the return value is uint8 *) Registers.read_r r | RR rr -> (* When RR is matched, the return value is uint16 *) Registers.read_rr rr | ... in

49.

I remember something called GADT…

50.

Solution: Use GADTs (Generalized Algebraic Data Types) instruction.ml type arg = | R : Registers.r | RR : Registers.rr -> uint16 arg | Immediate8 : uint8 | Immediate16 : uint16 -> uint8 -> uint8 arg arg -> uint16 arg | ... type t =... (* type t is same as variant case. *)

51.

Solution: Use GADTs (Generalized Algebraic Data Types) instruction.ml type arg = | R : Registers.r | RR : Registers.rr -> uint16 arg | Immediate8 : uint8 | Immediate16 : uint16 -> uint8 -> uint8 arg arg -> uint16 arg | ... type t =... (* type t is same as variant case. *) What does this mean ?

52.

Left-hand of ->: Corresponds to the part after of in variants. The value you get when you match with the constructor. | R : Registers.r -> uint8 arg | R of Registers.r let read_arg = function | R r -> .. (* type of r is Registers.r *) | RR rr -> .. (* type of rr is Registers.rr *) ...

53.

| R : Registers.r -> uint8 arg | R of Registers.r let read_arg = function | R How*)about the r -> .. (* r の型は Registers.r right-hand side? Nothing equivalent in the variant there. | RR rr -> .. (* rr の型は Registers.rr *) ...

54.

Right-hand side of ->: The value you return when you match with the constructor. | R : Registers.r -> uint8 arg | RR : Registers.rr -> uint16 arg let read_arg = function | R r -> Registers.read_r r (* return uint8 *) | RR rr -> Registers.read_rr r (* return uint16 *) ...

55.

Variants can parametrize the type of values we get in the match statement. GATDs can also parameterize the type of the value we return in the match statement.

56.

cpu.ml let execute t (inst : Instruction.t) = let read_arg : type a. a Instruction.arg -> a = fun arg -> | R r -> Registers.read_r r | RR rr -> Registers.read_rr rr | ... in match inst with | Add8 (x, y) -> let sum = Uint8.add (read_arg x) (read_arg y) in ... | Add16 (x, y) -> let sum = Uint16.add (read_arg x) (read_arg y) in

57.

Summary

58.

Implement something Problem Study with books and documents Hmm, I remember there was.. Solution Truly understand the concept Remember what I studied.

59.

Implement something Problem Study with books and documents Understand not only for language feature, but also for the test framework, build system, and libraries in similar way! Hmm, I remember there was.. Solution Truly understand the concept Remember what I studied.

60.

Conclusion

61.

Beginner Intermediary/Pro Books Hobby Simple programs Job Myself The "Intermediate Valley" Open source

62.

Beginner Intermediary/Pro Books Hobby Myself Simple programs Job How to overcome the valley? The "Intermediate Valley" Open source

63.

Beginner Intermediary/Pro Books Hobby Write medium-scale project, Simple programs repeat cycle of Job problem->solution. Myself The "Intermediate Valley" Open source

64.

For me, that “medium-scale project” was CAMLBOY

65.

Other Projects?

66.

The Three Recommended Criteria 1. Moderately Complex ○ Certain “problems” (testability, maintainability, etc.) only arise once you reach a certain level of complexity. ○ Complex enough so you are NOT be confident to pull it off. You only grow by doing things you feel uncomfortable doing. 2. Clear Specifications ○ Tough to figure out what to write and how to write it at the same time. ○ Having a clear goal makes things easier. E.g., making Pokémon run on your own emulator 3. Fun! ○ Easily spend hours “test-playing”.

67.

Examples ● ● ● ● Game emulators (Game Boy, NES, Chip-8, …) Compilers for (subsets of) existing languages. Operating systems. Network Protocol (TCP/IP, etc)

68.

Epilogue

69.

After Writing CAMLBOY ● I landed a job for writing OCaml professionally, and CAMLBOY played a big part in being hired. ● So extra benefit of doing personal projects is getting a job :)

70.

Links ● Blog Article: ○ ● CAMLBOY Repository: ○ ● https://linoscope.github.io/writing-a-game-boy-emulator-in-ocaml/ https://github.com/linoscope/CAMLBOY My Twitter (If you have any questions or feedback, please reach out here!) ○ https://twitter.com/linoscope

71.

Implementation Timeline

72.

Timeline (from the Git log) ● 2021/8/15: Start implementing instruction set ● 2021/9/19: Finished implementing the instruction set and started on the GPU. ● 2021/10/5: Successfully rendered Tetris’s startup screen; began implementing the Joypad and Cartridge. ● 2021/10/23: Pokémon runs. ● 2021/10/24: Compiled to JavaScript. It runs in the browser, but at a very slow speed. Began optimization efforts. ● 2021/11/14: Achieved a speed fast enough to run in smartphone browsers.