Membuat Emulator Game Boy dengan OCaml (2022)
(linoscope.github.io)- Untuk menerapkan OCaml melampaui tingkat contoh ke kode berskala menengah, dibuat emulator Game Boy CAMLBOY, dengan target dapat berjalan di browser dan punya performa yang memungkinkan dimainkan di smartphone
- Implementasinya terdiri dari catch up method yang membuat CPU, timer, dan GPU mengejar sesuai siklus CPU, bus yang menangani routing baca/tulis berdasarkan alamat, serta antarmuka akses 8-bit dan 16-bit
- Untuk meningkatkan testability CPU, implementasi bus disuntikkan sebagai functor, dan kebingungan argumen instruksi dikurangi dengan memisahkan tipe 8-bit dan 16-bit menggunakan GADT
- Integration test menggabungkan test ROM dan
ppx_expectuntuk menangkap regresi dan memungkinkan implementasi eksploratif, sementara UI browser dibuat denganjs_of_ocamldanBrr - Setelah mengurangi bottleneck GPU, timer, dan
Bigstringafdengan Chrome profiler, lalu mematikan inliningjs_of_ocaml, performa mencapai 100 FPS di browser PC dan 60 FPS di smartphone
Tujuan dan cakupan CAMLBOY
- CAMLBOY adalah emulator Game Boy yang ditulis dalam OCaml dan berjalan di browser
- Demo menyertakan beberapa ROM homebrew, dan
Bouncing ballsertaRocket Man Demodisebut sebagai rekomendasi - Targetnya adalah berjalan pada 60 FPS bahkan di browser smartphone modern
- Kemudian, melalui PR, eksekusi WASM berbasis
js_of_ocamljuga menjadi memungkinkan - Repositorinya tersedia di linoscope/CAMLBOY
Mengapa membuat emulator Game Boy dengan OCaml
- Setelah mempelajari OCaml selama beberapa bulan, penulis sudah bisa membuat contoh sederhana, tetapi masih kurang merasakan penggunaan praktis untuk struktur kode berukuran menengah atau lebih besar dan fitur tingkat lanjut
- Emulator Game Boy memenuhi syarat yang cocok sebagai proyek latihan
- Spesifikasinya jelas sehingga tidak banyak ruang untuk bingung menentukan apa yang perlu diimplementasikan
- Cukup kompleks sehingga tidak selesai hanya dalam beberapa hari atau minggu
- Tidak terlalu kompleks sampai tidak bisa diselesaikan dalam beberapa bulan
- Ada kenangan pribadi terhadap Game Boy
- Sasaran implementasi menempatkan keterbacaan dan maintainability sebelum performa, serta mencakup eksekusi di browser dan perbandingan benchmark
- Dikompilasi ke JavaScript dengan js_of_ocaml agar berjalan di browser
- Mencapai FPS yang bisa dimainkan di browser smartphone
- Mengimplementasikan benchmark dan membandingkan beberapa backend compiler OCaml
Struktur emulator dan main loop
- Komponen utama CAMLBOY terbagi menjadi CPU, timer, GPU, bus, cartridge, interrupt controller, serial port, joypad, dan lain-lain
- bus merutekan baca/tulis antara CPU dan berbagai modul hardware berdasarkan alamat
- Misalnya, penulisan ke alamat
0xFFFFditeruskan ke interrupt controller untuk mengaktifkan atau menonaktifkan interrupt - Modul hardware yang terhubung ke bus mengimplementasikan antarmuka
Addressable_intf.S - bus mengimplementasikan antarmuka
Word_addressable_intf.S
- Misalnya, penulisan ke alamat
- Pada hardware nyata, CPU, timer, dan GPU berbagi clock yang sama, tetapi emulator adalah loop eksekusi sekuensial sehingga membutuhkan sinkronisasi terpisah
- Main loop menyesuaikan progres tiap modul dengan catch up method
- CPU menjalankan satu instruksi dan mencatat jumlah siklus yang dikonsumsi
- timer dijalankan sebanyak jumlah siklus yang dikonsumsi CPU
- GPU juga dijalankan sebanyak jumlah siklus yang sama
Antarmuka baca/tulis dan implementasi bus
- Modul-modul yang mendukung baca/tulis 8-bit berbagi signature
Addressable_intf.Sread_byte : t -> uint16 -> uint8write_byte : t -> addr:uint16 -> data:uint8 -> unitaccepts : t -> uint16 -> bool
ram.mli,gpu.mli,joypad.mli,timer.mli, dan lainnya menyertakan antarmuka yang sama dalam bentukinclude Addressable_intf.S with type t := t- Karena baca/tulis 16-bit juga diperlukan antara CPU dan bus,
Word_addressable_intf.SmenyertakanAddressable_intf.Sdan menambahkanread_word,write_word - bus memiliki modul-modul terhubung seperti GPU, timer, dan RAM sebagai field, lalu meneruskan baca/tulis ke modul yang sesuai berdasarkan alamat
- Baca/tulis alamat
0xC000dirutekan ke RAM - Peta memori lengkap mengacu pada Pandocs Memory Map
- Baca/tulis alamat
read_wordmengimplementasikan pembacaan 16-bit dengan memanggilread_bytedua kali, dan hardware nyata juga menangani akses 16-bit sebagai dua akses 8-bit
Register dan peningkatan testability CPU
- CPU Game Boy memiliki register 8-bit
A,B,C,D,E,F,H,L - Register 8-bit dapat digabung dan juga digunakan sebagai register 16-bit
AF,BC,DE,HL - Implementasi CPU awal memiliki
registers,bus,pc, dan lainnya secara langsung, serta melakukan fetch, decode, execute dirun_instruction - Struktur ini sulit diuji
- bus bergantung pada banyak modul seperti GPU, timer, dan RAM
- Untuk membuat CPU dalam unit test, bus dan semua modul yang terhubung harus disiapkan
- Sebelum bus dan semua modul terhubung diimplementasikan, instance CPU tidak bisa dibuat
- CPU diimplementasikan ulang sebagai functor untuk mengabstraksikan implementasi konkret bus
- Implementasi bus disuntikkan dalam bentuk
module Make (Bus : Word_addressable_intf.S) - Dalam test, CPU diinstansiasi dengan
Mock_busberbasis satu byte array - Perubahan ini memungkinkan penggunaan implementasi mock alih-alih bus nyata dalam unit test CPU
- Implementasi bus disuntikkan dalam bentuk
Instruction set dan penggunaan GADT
- Instruction set Game Boy memiliki instruksi yang menerima argumen 8-bit dan instruksi yang menerima argumen 16-bit
ADD8 A, 0x12menambahkan registerA8-bit dan immediate value 8-bitADD16 AF, 0x1234menambahkan registerAF16-bit dan immediate value 16-bit
- Percobaan pertama menggunakan variant seperti
Immediate8,Immediate16,R,RRuntuk merepresentasikan argumen - Dengan pendekatan variant, sulit menentukan satu tipe return untuk
read_argR rmengembalikanuint8RR rrmengembalikanuint16- Tipe return berbeda dalam ekspresi match yang sama
- GADT digunakan untuk mendefinisikan ulang tipe argumen
Immediate8 : uint8 -> uint8 argImmediate16 : uint16 -> uint16 argR : Registers.r -> uint8 argRR : Registers.rr -> uint16 arg
- Dalam struktur ini, tipe return berubah sesuai tipe argumen seperti
read_arg : type a. a Instruction.arg -> aADD8hanya menerimauint8 arg * uint8 argADD16hanya menerimauint16 arg * uint16 arg- Kebingungan antara argumen instruksi 8-bit dan 16-bit dapat dikurangi di level tipe
Cartridge dan first-class module
- Cartridge Game Boy bukan hanya ROM sederhana; bergantung pada jenisnya, cartridge dapat berisi hardware tambahan
- Cartridge tipe
ROM_ONLYhanya berisi ROM yang menyimpan data dan kode game- Tetris digunakan sebagai contoh
- Cartridge tipe
MBC3berisi RAM mandiri dan timer selain ROM- Pokémon Red digunakan sebagai contoh
- Karena tiap tipe cartridge memiliki fungsi berbeda, masing-masing diimplementasikan sebagai modul terpisah
- Untuk memilih modul yang sesuai dengan tipe cartridge saat runtime, digunakan first-class module
Detect_cartridge.fdirancang untuk menerima byte ROM dan mengembalikan(module Cartridge_intf.S)
Test ROM dan integration test berbasis ppx_expect
- test ROM adalah program yang memverifikasi fitur tertentu dari emulator
- Memeriksa operasi instruksi aritmetika dasar
- Memeriksa dukungan untuk cartridge tipe
MBC1
- Berbeda dari ROM game biasa, test ROM memberi tahu cakupan fitur yang gagal, dan tetap bisa berjalan meski beberapa fungsi inti belum ada, sehingga berguna untuk pengembangan emulator
- test ROM biasanya menampilkan hasil test di layar
- mooneye test ROMs menampilkan dump register dan informasi assertion failure saat gagal
- Ada juga test ROM seperti blargg test roms yang mengeluarkan hasil ASCII melalui serial port
- Integration test menggunakan ppx_expect
M.run_test_rom_and_print_framebuffermenjalankan ROM dan mencetak status layar akhir sebagai karakter ASCII- String output dibandingkan dengan nilai yang diharapkan di dalam
[%expect{|...|}] - Penjelasan
ppx_expectdapat dilihat di tulisan Jane Street
- Konfigurasi test ini menangkap regresi meski ada perubahan kode besar, dan memungkinkan alur exploratory programming
- Mencari test ROM yang memverifikasi fitur baru
- Menyiapkan test
ppx_expect - Meng-commit output yang gagal
- Mengimplementasikan fitur
- Memastikan hasil test berubah menjadi status
Test OK
Kompilasi JavaScript dan UI browser
- Berkat js_of_ocaml, kompilasi ke JavaScript tidak sulit
- Untuk membuat emulator berjalan di browser, diperlukan satu commit
- Brr digunakan untuk implementasi UI browser
- Brr memetakan objek JS ke modul OCaml, bukan ke objek OCaml
- API browser bawaan
js_of_ocamlmemetakan objek JS ke objek OCaml, sehingga membutuhkan pengetahuan tentang object OCaml - Penggunaan Brr mengurangi beban untuk memahami model object OCaml
- API browser bawaan
Proses optimasi performa
- Eksekusi awal di browser berhasil berjalan, tetapi sangat lambat sampai sulit dimainkan
- Sekitar 20 FPS di browser PC
- Karena Game Boy nyata berjalan pada 60 FPS, performa perlu ditingkatkan sekitar 3 kali lipat
- Bottleneck dicari dengan Chrome profiler
- GPU mengonsumsi sekitar 73% waktu
tile_data.mlmengonsumsi 34%,oam_table.ml18%, dantile_map8%timer.mldan beberapa fungsiBigstringafjuga mengonsumsi banyak waktu
- Penghilangan bottleneck menaikkan FPS secara bertahap
- Optimasi
oam_table.ml: 14 FPS → 24 FPS - Optimasi
tile_data.ml: 24 FPS → 35 FPS - Optimasi
timer.ml: 35 FPS → 40 FPS - Optimasi
tile_map.ml: 40 FPS → 50 FPS - Menggunakan
Bigstringaf.unsafe_getalih-alihBigstringaf.get: 50 FPS → 60 FPS
- Optimasi
- Setelah itu, 60 FPS tercapai di browser PC, tetapi di smartphone masih bertahan di 20–40 FPS
- Output JS dari release build lebih lambat daripada dev build, dan dengan bantuan discuss.ocaml.org, inlining
js_of_ocamldiidentifikasi sebagai penyebab penurunan performa JS- Diskusi terkait ada di tulisan discuss.ocaml.org
- Dalam pembaruan 12 Januari 2022, dampak negatif tersebut ditangani di ocsigen/js_of_ocaml#1220
- Setelah menonaktifkan inlining, performa mencapai 100 FPS di PC dan 60 FPS di smartphone
- Optimasi performa JS juga meningkatkan performa native, dan eksekusi native berjalan sekitar 1000 FPS
Benchmark dan batasan perbandingan
- Diimplementasikan headless benchmarking mode yang menjalankan emulator tanpa UI
- FPS diukur pada beberapa backend compiler OCaml
- Benchmark ini sulit digunakan untuk membandingkan FPS dengan emulator Game Boy lain
- Performa emulator sangat dipengaruhi oleh akurasi dan cakupan fitur yang diimplementasikan
- Karena CAMLBOY tidak mengimplementasikan APU(Audio Processing Unit), perbandingan FPS dengan emulator yang mendukung APU tidak bermakna
Pengalaman menggunakan OCaml
- Ekosistem OCaml jauh membaik dibanding sekitar 6 tahun sebelumnya ketika penulis pernah menggunakannya
- Berkat dune, pengalamannya mendekati memasukkan file ke direktori lalu build system menanganinya
- Dengan Merlin dan OCamlformat, autocomplete, penelusuran kode, dan auto-format umumnya mudah diterapkan
- Dengan setup-ocaml, build dan test dapat dikonfigurasi di GitHub Actions
- Implementasi CAMLBOY banyak menggunakan mutable state karena alasan performa
- Banyak modul memiliki fungsi bertipe
t -> ... -> unit, yang berarti perubahan pada suatu mutable state - Meski implementasinya tidak “functional”, penulis tidak merasa kehilangan keunggulan OCaml
- Banyak modul memiliki fungsi bertipe
- Titik yang lebih disukai bukan “functional” itu sendiri, melainkan static type, variant, pattern matching, module system, dan type inference yang baik
Hal-hal yang terasa tidak nyaman di OCaml
- Ekosistem sudah membaik, tetapi beberapa area masih kompleks atau kurang dokumentasi
- Dalam proses menyelesaikan dependency secara reproducible, dokumentasi opam resmi kurang memberi panduan yang jelas
- Penulis membaca source setup-ocaml untuk menemukan perintah yang diperlukan
- Cara harus “publish” package secara lokal lalu menginstal package yang dipublish lokal tersebut terasa rumit
- Biaya sintaksis untuk bergantung pada abstraksi tinggi
- Agar
Bbergantung pada interfaceC_intf, bukan implementasi konkretC,Bharus diubah menjadi functor - Ketika
Bmenjadi functor,Atidak bisa lagi merujukB.fooseperti sebelumnya, sehinggaAjuga harus diubah menjadi functor yang menerimaB_intf - Mengubah modul menjadi functor tidak hanya mengubah cara modul itu bergantung pada modul lain, tetapi juga cara modul lain bergantung pada modul tersebut
- Agar
- Masalah ini muncul ketika mencoba memisahkan hanya bagian
Bus -> Cartridgedalam dependency graphCamlboy -> Bus -> Cartridge - Dalam OOP, meski constructor class
Bdiubah agar menerima interfaceC_intfalih-alih class konkretC, tipe classBsendiri tidak berubah- Namun OOP memiliki biaya dynamic dispatch
- Fitur OOP OCaml tidak akrab bagi banyak orang, sehingga dapat membatasi pembaca kode
Referensi
- Materi terkait OCaml
- Learn OCaml Workshop: materi workshop yang digunakan secara internal di Jane Street, dengan metode belajar mengisi kode OCaml berlubang dan test
- Real World OCaml: materi berbasis contoh praktis yang direkomendasikan bagi orang yang memahami sintaks dasar OCaml atau punya pengalaman dengan bahasa functional lain
- Materi terkait Game Boy
- The Ultimate Game Boy Talk: video yang menjelaskan struktur Game Boy dalam sekitar 1 jam
- gbops: tabel instruction set Game Boy
- Game Boy CPU Manual: manual CPU yang digunakan untuk implementasi instruksi, beberapa bagian khususnya di sekitar register flag tidak akurat
- Pandocs: wiki yang dijadikan referensi untuk perilaku modul hardware seperti GPU dan timer
- Imran Nazar’s blog: tutorial implementasi emulator Game Boy dengan JavaScript, digunakan untuk memahami cakupan implementasi secara garis besar
Belum ada komentar.