2 poin oleh GN⁺ 2025-07-06 | Belum ada komentar. | Bagikan ke WhatsApp
  • 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_expect untuk menangkap regresi dan memungkinkan implementasi eksploratif, sementara UI browser dibuat dengan js_of_ocaml dan Brr
  • Setelah mengurangi bottleneck GPU, timer, dan Bigstringaf dengan Chrome profiler, lalu mematikan inlining js_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 ball serta Rocket Man Demo disebut sebagai rekomendasi
  • Targetnya adalah berjalan pada 60 FPS bahkan di browser smartphone modern
  • Kemudian, melalui PR, eksekusi WASM berbasis js_of_ocaml juga 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 0xFFFF diteruskan 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
  • 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.S
    • read_byte : t -> uint16 -> uint8
    • write_byte : t -> addr:uint16 -> data:uint8 -> unit
    • accepts : t -> uint16 -> bool
  • ram.mli, gpu.mli, joypad.mli, timer.mli, dan lainnya menyertakan antarmuka yang sama dalam bentuk include Addressable_intf.S with type t := t
  • Karena baca/tulis 16-bit juga diperlukan antara CPU dan bus, Word_addressable_intf.S menyertakan Addressable_intf.S dan menambahkan read_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 0xC000 dirutekan ke RAM
    • Peta memori lengkap mengacu pada Pandocs Memory Map
  • read_word mengimplementasikan pembacaan 16-bit dengan memanggil read_byte dua 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 di run_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_bus berbasis satu byte array
    • Perubahan ini memungkinkan penggunaan implementasi mock alih-alih bus nyata dalam unit test CPU

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, 0x12 menambahkan register A 8-bit dan immediate value 8-bit
    • ADD16 AF, 0x1234 menambahkan register AF 16-bit dan immediate value 16-bit
  • Percobaan pertama menggunakan variant seperti Immediate8, Immediate16, R, RR untuk merepresentasikan argumen
  • Dengan pendekatan variant, sulit menentukan satu tipe return untuk read_arg
    • R r mengembalikan uint8
    • RR rr mengembalikan uint16
    • Tipe return berbeda dalam ekspresi match yang sama
  • GADT digunakan untuk mendefinisikan ulang tipe argumen
    • Immediate8 : uint8 -> uint8 arg
    • Immediate16 : uint16 -> uint16 arg
    • R : Registers.r -> uint8 arg
    • RR : Registers.rr -> uint16 arg
  • Dalam struktur ini, tipe return berubah sesuai tipe argumen seperti read_arg : type a. a Instruction.arg -> a
    • ADD8 hanya menerima uint8 arg * uint8 arg
    • ADD16 hanya menerima uint16 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_ONLY hanya berisi ROM yang menyimpan data dan kode game
    • Tetris digunakan sebagai contoh
  • Cartridge tipe MBC3 berisi 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.f dirancang 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_framebuffer menjalankan ROM dan mencetak status layar akhir sebagai karakter ASCII
    • String output dibandingkan dengan nilai yang diharapkan di dalam [%expect{|...|}]
    • Penjelasan ppx_expect dapat 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_ocaml memetakan objek JS ke objek OCaml, sehingga membutuhkan pengetahuan tentang object OCaml
    • Penggunaan Brr mengurangi beban untuk memahami model object OCaml

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.ml mengonsumsi 34%, oam_table.ml 18%, dan tile_map 8%
    • timer.ml dan beberapa fungsi Bigstringaf juga mengonsumsi banyak waktu
  • Penghilangan bottleneck menaikkan FPS secara bertahap
  • 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_ocaml diidentifikasi sebagai penyebab penurunan performa JS
  • 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
  • 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 B bergantung pada interface C_intf, bukan implementasi konkret C, B harus diubah menjadi functor
    • Ketika B menjadi functor, A tidak bisa lagi merujuk B.foo seperti sebelumnya, sehingga A juga harus diubah menjadi functor yang menerima B_intf
    • Mengubah modul menjadi functor tidak hanya mengubah cara modul itu bergantung pada modul lain, tetapi juga cara modul lain bergantung pada modul tersebut
  • Masalah ini muncul ketika mencoba memisahkan hanya bagian Bus -> Cartridge dalam dependency graph Camlboy -> Bus -> Cartridge
  • Dalam OOP, meski constructor class B diubah agar menerima interface C_intf alih-alih class konkret C, tipe class B sendiri 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.

Belum ada komentar.