Saya Membuat Emulator Game Boy dengan F#
(nickkossolapov.github.io)- Fame Boy adalah emulator Game Boy yang diimplementasikan dengan F#, berjalan di desktop dan web termasuk audio, dan tersedia main di browser serta source GitHub
- Core emulator dan frontend disederhanakan agar hanya berbagi
framebuffer,audiobuffer,stepEmulator(), dangetJoypadState(state), sementarasteppermenjalankan CPU, timer, serial, APU, dan PPU secara berurutan untuk menjaga sinkronisasi single-thread - Implementasi CPU memanfaatkan discriminated union dan
matchdi F# untuk memodelkan 512 opcode menjadi 58 instruksi, serta dirancang agar status ilegal seperti menulis ke nilai immediate dicegah di level tipe melalui tipeFromdanTo - PPU memilih rendering per scanline alih-alih pixel FIFO milik Game Boy asli sehingga lebih cepat dan sederhana, tetapi beberapa game yang memanfaatkan timing antrean piksel mungkin tidak berjalan dengan benar
- Porting web diselesaikan dengan Fable, dan setelah memperbaiki masalah operasi bit 8-bit dan 16-bit yang mengikuti semantik 32-bit JavaScript, emulator berjalan dengan bundle JS sekitar 100KB; lewat optimasi performa dan build rilis, performanya mencapai sekitar 1000FPS di desktop
Latar belakang dan tujuan proyek
- Meski sudah bekerja lebih dari 8 tahun sebagai software engineer, penulis merasa belum memahami bagaimana komputer benar-benar bekerja, sehingga memutuskan untuk belajar dengan membuat emulator sendiri
- Karena banyak bermain Pokémon saat kecil, ia memilih Game Boy sebagai target; ini adalah perangkat keras nyata dengan cakupan yang relatif sederhana sekaligus punya ikatan personal yang kuat
- Sebelum langsung masuk ke Game Boy, ia mengikuti From NAND to Tetris untuk memahami elemen dasar komputer seperti register, memori, dan ALU
- Untuk membiasakan diri dengan pembuatan emulator, ia lebih dulu membuat emulator CHIP-8 bernama Fip-8 dengan F#
- Setelah beberapa bulan pengerjaan, ia menyelesaikan emulator Game Boy Fame Boy yang mencakup audio dan berjalan di desktop maupun web
- Emulator ini bisa dimainkan di browser, dan source-nya tersedia di GitHub
Struktur emulator
- Agar bisa berjalan baik di desktop maupun web, antarmuka antara core emulator dan frontend dijaga tetap sederhana
- Antarmuka inti antara frontend dan core terdiri dari dua array dan dua fungsi
framebuffer: array gradasi 160×144 yang berisi putih, terang, gelap, dan hitamaudiobuffer: ring audio buffer dengan sample rate 32768Hz yang memiliki head baca dan tulisstepEmulator(): menjalankan satu instruksi CPU dan mengembalikan jumlah siklus yang dipakaigetJoypadState(state): callback yang dipakai frontend untuk mengirimkan status joypad ke emulator, biasanya dipanggil sekali per frame
- Fame Boy dimodelkan dengan cara yang mirip dengan hardware Game Boy asli
- CPU tidak mengetahui hardware di luar memory map, mirip Sharp LR35902 pada Game Boy asli, dan hanya menggunakan IoController untuk sinyal interupsi
- CPU adalah bagian yang paling terasa F#-nya dalam codebase, dengan banyak penggunaan functional domain modeling
- Memory.fs menyimpan sebagian besar RAM Game Boy dan berperan sebagai memory map sekaligus bus antara CPU, IO Controller, dan cartridge
- Demi performa, Memory.fs berbagi referensi array VRAM dan OAM RAM dengan komponen seperti PPU
- IoController.fs dipisahkan ketika logika di Memory.fs menjadi terlalu banyak; walau tidak ada satu IO controller tunggal pada hardware Game Boy asli, pendekatan ini mengumpulkan penanganan register hardware di satu tempat agar antarmuka tiap komponen tetap sederhana dan aman
- Fungsi
stepperdi Emulator.fs berperan sebagai perekat yang menyatukan seluruh emulator dengan menggabungkan fungsi step dari tiap komponen
let stepper () =
// Execute a single instruction
// Each instruction uses a different amount of cycles
let mCycles = stepCpu cpu io
for _ in 1..mCycles do
stepTimers timer io
stepSerial serial io
// The APU technically runs at 4x CPU-cycles, but can be batched
stepApu apu
let tCycles = mCycles * 4
// The PPU operates at 4x CPU-cycles. The APU should be here too
for _ in 1..tCycles do
stepPpu ppu
// Return cycles taken so the frontend runs the emulator at the right speed
mCycles
- Pada hardware nyata, komponen berjalan paralel berdasarkan master oscillator pusat, tetapi karena Fame Boy bersifat single-thread, komponennya harus dijalankan secara berurutan
- Fungsi
steppermemusatkan eksekusi agar semua komponen tetap sinkron - Untuk mencapai kecepatan yang playable, emulator harus berjalan pada jumlah siklus yang benar per detik, dan pada 60FPS dibutuhkan sekitar 17.500 siklus CPU per frame
- Frontend menjalankan emulator berdasarkan sample rate audio saat suara aktif, dan berdasarkan frame rate saat dalam keadaan mute
Implementasi CPU dan F#
-
Emulator CHIP-8 ditulis secara murni tanpa anggota
mutabledan bahkan menyalin array, tetapi Fame Boy secara aktif menggunakan state yang bisa diubah -
Game Boy jauh lebih cepat daripada CHIP-8, jadi pendekatan menyalin memori lebih dari 16KB jutaan kali per detik tidaklah cocok
-
Alasan memakai F# di Fame Boy adalah karena sistem tipenya yang kaya cocok untuk memodelkan instruksi CPU, dan juga karena penulis memang menyukai F#
-
Domain modeling
- Saat mengimplementasikan CPU, penulis mengikuti Gekkio’s Complete Technical Reference dan mengelompokkan instruksi seperti dalam dokumen tersebut
- Pada awalnya, discriminated union untuk tiap jenis instruksi ditempatkan di Instructions.fs
-
- type LoadInstr = | Load8Immediate of uint8 | Load8Direct of Register | Load8Indirect // ... other load instructions
-
type ArithmeticInstr = | IncrementDirect of uint8 | IncrementIndirect of Register // ... other arithmetic instructions
-
-
Banyak instruksi berbagi konsep umum berupa lokasi operan
immediateyang membaca nilai byte di memori tepat setelah instruksidirectyang membaca dan menulis register CPUindirectyang membaca dan menulis lokasi memori yang ditunjuk oleh register CPU HL
-
Dengan mengekstrak konsep lokasi dan membaginya menjadi tipe
FromdanTo, instruksi load dapat diekspresikan dengan lebih ringkas -
-
type To = | Direct of Register | Indirect
-
type From = | Immediate of uint8 | Direct of Register | Indirect
-
type LoadInstr = | Load of From * To // These form a tuple, like Load<From, To> in C# // ... other instructions
-
-
Dengan pendekatan ini, instruksi CPU berhasil diringkas dari 512 opcode menjadi 58 instruksi
-
Menggeneralisasi domain memang berisiko membiarkan status yang salah, tetapi hal itu bisa dicegah dengan sistem tipe
-
Jika memakai satu tipe lokasi
Localih-alihFromdanTo, instruksi salah sepertiLoad(Loc.Direct D, Loc.Immediate)yang menyimpan nilai register ke lokasi immediate bisa saja lolos kompilasi -
Hardware Game Boy tidak mendukung penulisan ke immediate, jadi dengan memodelkan domain secara benar lewat tipe F#, dapat dijamin bahwa status ilegal tidak akan direpresentasikan di dalam sistem
-
Satu-satunya pengecualian adalah opcode
0x76- Jika hanya melihat pola opcode, ini akan berbentuk seperti
Load(From.Indirect, To.Indirect), yaitu memuat nilai 8-bit dari lokasi HL ke lokasi HL yang sama - Tipe di Fame Boy mengizinkan ini, tetapi pada Game Boy asli instruksi tersebut tidak ada
- Secara logis ini adalah NOP dan tidak berbahaya, dan pada praktiknya tidak bisa dicapai karena pembaca opcode mendekode
0x76sebagaiHALT
- Jika hanya melihat pola opcode, ini akan berbentuk seperti
-
Setelah terbiasa dengan
matchdan Option di F#, kembali keswitchbiasa terasa kikuk dan mudah menimbulkan kesalahan, jadi penulis merekomendasikan untuk mencoba bahasa fungsional
-
-
Menjaganya tetap sederhana
-
Karena tujuan proyek ini bukan membuat emulator terbaik, melainkan mempelajari hardware komputer, penulis tidak terlalu mendalami kode emulator lain
-
Saat melihat kode berikut di source CAMLBOY, penulis menyukai bahwa flag yang diinginkan bisa dikirim secara bebas dalam urutan apa pun
-
-
set_flags ~h:false ~z:(!a = zero) ();
-
-
Karena F# menghindari method overloading dan parameter default berkat sistem tipenya yang mendukung partial application, pendekatan yang sama tidak bisa dibuat
-
Awalnya ini diimplementasikan dengan cara mengirim array dan tipe flag seperti berikut
-
-
cpu.setFlags [ Half, false; Zero, a = 0uy ]
-
-
Setelah refactoring, ini diubah menjadi implementasi berbasis fungsi murni berikut di Cpu/State.fs L81
-
-
module Flags = let inline setZ (v: bool) (f: uint8) = if v then f ||| ZMask else f &&& ~~~ZMask
let inline setH (v: bool) (f: uint8) = // ... the other flag functions and definitions
-
// Other files
-
cpu.Flags <- cpu.Flags |> setH false |> setZ (a = 0uy)
-
-
Fungsi-fungsi baru ini mudah dikombinasikan, mudah diuji, dan merupakan fungsi murni yang sederhana
-
Implementasi sebelumnya lebih bertele-tele karena harus mengangkat nilai ke tipe discriminated union lalu memasukkannya ke array
-
Fungsi baru ini bersifat inline, tidak memerlukan alokasi heap, dan performanya juga lebih baik, meningkatkan FPS emulator sekitar 10%
-
-
Pengujian
- Implementasi CPU awal dibuat dengan menjalankan ROM Tetris lalu mengimplementasikan instruksi setiap kali mencapai opcode yang belum diimplementasikan
-
- match opcode with
- | 0x00 -> Nop
- | _ -> failwith "Unimplemented opcode"
-
- Pendekatan ini mengharuskan bolak-balik secara acak ke dokumen teknis, sehingga pengulangan tersebut terasa membosankan dan juga sulit mengetahui apakah instruksi sudah diimplementasikan dengan benar
- Untuk menyelesaikan dua masalah itu, penulis memperkenalkan unit test
- Demi pembelajaran, kode emulator ditulis sendiri, tetapi AI dimanfaatkan untuk menghasilkan test case
- Spesifikasi dari dokumen teknis dimasukkan ke prompt, lalu AI diminta menulis pengujian berbasis spesifikasi tanpa melihat kode emulator
- Sambil AI menghasilkan pengujian, penulis sendiri membaca spesifikasinya dan mengimplementasikan logika sampai pengujian lolos, sehingga benar-benar menjalankan test-driven development
- Beberapa bug pada instruksi yang sudah diimplementasikan juga ditemukan lewat pengujian
- Pengujian ditinjau dan diperbaiki secara berkala, dan alih-alih mengganggu pembelajaran, justru membantu memusatkan energi pada bagian yang menarik
Komponen setelah CPU
-
PPU
- Game Boy tidak memiliki GPU, melainkan PPU, yaitu picture processing unit
- Banyak tulisan lain tentang pembuatan emulator Game Boy berfokus pada CPU dan hanya membahas PPU dalam beberapa paragraf, tetapi di Fame Boy justru pemahaman PPU memakan waktu lebih lama
- CPU terasa alami berkat pengalaman dengan From NAND to Tetris dan CHIP-8, tetapi PPU lebih mirip pekerjaan mekanis yang mengikuti langkah-langkah untuk menampilkan piksel ke layar
- Pada awalnya, alih-alih mencoba memahami pixel FIFO dan seluruh pipeline PPU sekaligus, pendekatannya dimulai dengan membaca dan mengurai tile serta background map dari memori lalu menampilkannya ke layar
- Dengan cara ini, perilaku CPU bisa terlihat, dan berkat kesederhanaan Tetris, hasilnya tampak hampir seperti game Game Boy sungguhan
- Pendekatan yang dimulai dari tile dan tampilan background ini terus membantu, mulai dari implementasi layar sebenarnya hingga debugging bug detail pada data sprite
- PPU di Fame Boy memiliki banyak ketidakakuratan terhadap hardware
- Game Boy asli menggunakan antrean FIFO seperti monitor CRT untuk meletakkan piksel satu per satu ke layar
- Fame Boy merender seluruh scanline pada awal periode menggambar untuk baris tersebut
- Pendekatan ini lebih cepat, kodenya lebih sederhana, dan semua game yang ingin dimainkan berjalan, jadi tidak terasa perlu berpindah ke antrean piksel
- Game yang memanfaatkan hardware Game Boy sampai batasnya dan menggunakan timing antrean piksel tidak akan berjalan dengan benar di Fame Boy, tetapi kebanyakan game tidak menggunakan hardware seagresif itu sehingga secara umum tampaknya tetap bisa berjalan
-
Joypad
- Selain PPU dan APU, joypad juga dibahas
- Implementasi awalnya sangat mudah dan penulisan test juga sederhana
- Namun, setelah refactor besar, hampir selalu rusak
- Register hardware joypad dibaca dan ditulis oleh CPU maupun game, sehingga interaksinya rumit
- Pada awalnya CPU dibuat menulis status joypad ke register di setiap siklus, tetapi karena manusia tidak mengubah tombol jutaan kali per detik, ini diubah menjadi pembaruan sekali per frame
- Akibatnya, D-pad berhenti berfungsi
- Hardware Game Boy hanya bisa membaca setengah tombol sekaligus, dan game hampir selalu membaca register joypad dua kali atau lebih dalam jeda singkat serta bergantung pada perubahan register di antara dua pembacaan itu
- Register yang di-cache sekali per frame tidak berubah di antara dua pembacaan itu, sehingga setengah tombol tidak berfungsi
- Pada akhirnya, diimplementasikan agar IoController memperbarui register joypad hanya saat CPU membacanya
- Detail terkait bisa dilihat di dokumentasi joypad Pandocs
-
Suara
- Setelah membuat emulator yang berfungsi, saat memainkan versi web terasa bahwa tanpa suara tampilannya terasa kosong, jadi ditambahkan APU, yaitu audio processing unit
- Ditemukan bahwa banyak emulator dijalankan berdasarkan sampling rate audio frontend, bukan framerate
- Awalnya hal ini terasa terbalik, sehingga sempat diteliti dynamic sampling rate dan dicoba implementasi agar emulator dijalankan oleh framerate
- Suara adalah komponen yang secara konsep paling sulit, dan butuh waktu untuk memahami perilaku berbagai register suara dan channel
- Pada bagian ini AI sangat membantu sebagai pengajar, dengan beberapa kali tanya jawab sebelum mulai menulis kode
- Mirip dengan PPU, ada kepuasan besar saat channel diselesaikan satu per satu, dan sambil mendengar musik Tetris menjadi semakin kaya, jadi turut dipahami juga bagaimana musik itu tersusun
- CPU dan PPU berbentuk eksekusi tepat X unit kerja per frame dan nilai X mudah dihitung, tetapi APU memiliki banyak nilai yang harus dipilih dan disetel
- Hanya sampling rate APU yang mudah diputuskan
- APU Game Boy asli bersifat fleksibel, jadi emulator bisa memakai sampling rate apa pun yang diinginkan
- Fame Boy memilih 32768Hz
- Dengan clock CPU 1048576Hz, 32768Hz berarti 1 sampel per 128 siklus CPU, sehingga status APU bisa tersinkron sempurna hanya dengan bilangan bulat
- Karena 128 juga habis dibagi 4, tahap APU bisa diproses per 4 langkah tanpa kehilangan keselarasan dengan instruksi CPU
- Nilai-nilai lain jauh lebih tidak stabil, dan karena bukan sound engineer, penyesuaian harus dilakukan dengan mencoba-coba
- Setiap frontend dan setiap platform punya masalah khas masing-masing
- Di PC suara berjalan baik, tetapi di MacBook terdengar seperti suara air terjun
- Setelah masalah MacBook diperbaiki, versi desktop PC justru tidak bisa berjalan karena race condition
- Upaya untuk menyelesaikannya secara cerdas dengan dynamic sampling rate akhirnya ditinggalkan, dan saat audio dibuat menjadi penggerak emulator, audio menjadi jauh lebih stabil di berbagai perangkat
- Audio adalah bagian yang paling banyak bocor dalam antarmuka antara emulator dan frontend, tetapi sinkronisasi yang tepat tetap diperlukan untuk menghindari suara sumbang
Cara emulator dijalankan
- Perbedaan antara penggerak berbasis audio dan penggerak berbasis frame berkaitan dengan persepsi manusia
- Jika sinyal audio terputus, speaker bergerak besar karena perubahan sinyal yang mendadak dan menimbulkan pop noise
- Jika video terputus, video player melewatkan satu atau dua frame karena data tidak datang tepat waktu, tetapi karena tidak mendorong sesuatu secara fisik, gangguannya terasa lebih ringan
- Di dalam Fame Boy, audio dan video tersinkron sempurna secara desain
- Namun, audio dan video pada komputer yang menjalankannya bersifat independen, dan salah satunya bisa sesekali tertinggal
- Jika audio dan video frontend tidak sinkron, ada dua pilihan
- Sinkronkan audio frontend dan audio emulator, lalu sesekali drop frame
- Sinkronkan video frontend dan frame emulator, lalu sesekali drop audio
- Sisi yang dipilih itulah yang “menggerakkan” emulator, sementara sisi lain dijaga agar tetap sedekat mungkin
- Penggerak berbasis framerate relatif sederhana
let mutable cycles = 0
while (runEmulator) do
cycles <- cycles + targetCyclesPerMs * lastFrameTime
while cycles > 0 do
let cyclesTaken = stepEmulator ()
cycles <- cycles - cyclesTaken
draw ppu.framebuffer
- Penggerak berbasis suara lebih rumit karena cara Raylib dan Web Audio menangani audio berbeda
- Alur umumnya seperti berikut
let tryQueueAudio apu stepEmulator =
if frontend.audioBuffer.hasSpace () then
while apu.writeHead - apu.readHead < samplesNeeded do
stepEmulator ()
frontend.audioBuffer.fill apu.audioBuffer
while (runEmulator) do
tryQueueAudio apu stepEmulator
draw ppu.framebuffer
- Perbedaan utamanya adalah
stepEmulatortidak lagi dikendalikan olehlastFrameTime, melainkan dijalankan sesuai kebutuhan buffer audio frontend samplesNeededharus menghitung berapa kalistepEmulatorperlu dipanggil agar cocok dengan sampling rate yang berbeda-beda dan tetap menghasilkan 60FPS- Buffer audio frontend hanya peduli pada pengisian dirinya sendiri, jadi
stepEmulatorbisa dipanggil terlalu banyak atau terlalu sedikit per frame, dan akibatnyaframebuffermungkin tidak diperbarui tepat waktu - Frontend web bisa mencoba versi berbasis frame dengan menambahkan ?frame-driven ke URL
- Versi berbasis frame terlihat lebih mulus secara visual, tetapi sesekali menimbulkan pop audio
- Frontend web berbasis audio juga beralih ke mode berbasis frame saat tombol mute ditekan karena pop tidak akan terdengar
- Implementasinya belum sempurna, tetapi karena pop audio memberi kesan lebih buruk daripada frame yang tersendat, dan keadaan mute terasa kosong, default frontend web ditetapkan ke mode berbasis audio
- Audio adalah salah satu dari sedikit area di Fame Boy yang masih terasa kurang memuaskan, dan suatu hari ingin dibenahi lagi
Menerbitkannya ke web dengan Fable
- Setelah PPU mulai cukup berfungsi hingga sesuatu mulai terlihat di layar desktop, saya ingin memindahkan Fame Boy ke web
- Setelah membaca dokumentasi Fable, memasang paket, menyiapkan loop utama, dan menambahkan style, semuanya siap dijalankan hanya dalam satu atau dua jam
- Versi Fable yang pertama saya jalankan menampilkan layar yang aneh, dan setelah sedikit debugging, saya mencoba WebAssembly milik Blazor agar tidak terlalu banyak menghabiskan waktu
- Blazor juga mudah dijalankan, dan kali ini benar-benar berfungsi, tetapi hanya sekitar 8FPS sehingga nyaris tidak bisa dimainkan
- Saya tidak yakin apakah ini masalah bawaan Blazor, dan mengikuti panduan performa dari tim .NET pun tidak membantu
- Karena debugging juga tidak nyaman, saya kembali ke Fable untuk memeriksa apa yang salah dalam proses konversi JavaScript
- Fable menempatkan file JS hasil konversi tepat di samping source code, dan ternyata cukup mudah dibaca
- Ini memudahkan saya memahami kode baru dan melakukan debugging di browser developer tools
- Di developer tools, saya menemukan nilai register CPU yang aneh
- Register CPU milik Fame Boy dan Game Boy adalah integer unsigned 8-bit, jadi seharusnya berada dalam rentang 0–255
- Namun, saya melihat nilai seperti
-15565461
- Di dokumentasi Fable, saya menemukan dokumen kompatibilitas numeric types
Operasi bitwise (non-standar) untuk integer 16-bit dan 8-bit menggunakan semantik bitwise JavaScript 32-bit yang mendasarinya. Hasilnya tidak dipotong seperti yang diharapkan, dan operand shift tidak dimasking agar sesuai dengan tipe data.
- Ini persis cocok dengan penjelasan bahwa operasi bit pada integer 16-bit dan 8-bit menggunakan semantik operasi bit 32-bit JavaScript, sehingga hasilnya tidak terpotong seperti yang diharapkan
- Setelah menemukan titik di kode tempat nilai 8-bit seharusnya dipotong dan memperbaiki issue terkait, frontend web pun bekerja dengan benar
- Karena hanya memakai JS tanpa runtime .NET, bundle web-nya sekitar 100KB
- Selain masalah
uint8yang agak unik itu, pengalaman menggunakan Fable cukup menyenangkan, dan saya bisa mempertahankan seluruh source code dalam F#
Peningkatan performa
- Setelah hasil mulai terlihat di layar, saya menambahkan log FPS sederhana ke konsol
- Awalnya sekitar 55–60FPS dalam mode debug, tampaknya karena Raylib berusaha mempertahankan v-sync
- Setelah v-sync dimatikan, naik menjadi sekitar 70FPS, tetapi muncul jitter
- Seiring penambahan fitur, performa kemudian perlahan turun hingga 45FPS, dan mematikan v-sync pun tidak membantu
- Saat menjalankan profiler JetBrains Rider,
mapAddressmuncul sebagai bottleneck yang mencurigakan - Karena hampir semua komponen mengakses memori, saya menyadari biaya akses memori lebih besar dari perkiraan
- Kode yang bermasalah memetakan alamat memori ke discriminated union
MemoryRegion, lalu melakukan baca/tulis berdasarkan hasil itu
type MemoryRegion =
| RomBase of offset: int
// ... others
let mapAddress (addr: int) : MemoryRegion =
match addr with
| a when a < 0x4000 -> RomBase a
// ... others
type DmgMemory(arr: uint8 array) =
// Arrays for romBase etc
member this.read address =
match mapAddress address with
| RomBase i -> romBase[i]
// ... others
member this.write address value =
match mapAddress address with
| RomBase _ -> ()
// ... others
- Saya mencoba memperluas alur yang didapat dari pemodelan domain CPU ke memori juga, dan akibatnya objek
MemoryRegiondibuat serta dipetakan pada setiap operasi baca/tulis memori - Pendekatan ini mengalokasikan ratusan juta objek per detik di heap, sekaligus menambah percabangan yang harus ditangani compiler JIT
- Dengan satu perubahan yang menghapus discriminated union dan fungsi pemetaan lalu menggantinya dengan akses langsung ke array, FPS menjadi dua kali lipat
- Dari benchmark setelahnya, tampaknya sebagian besar peningkatan performa berasal dari optimasi JIT terhadap percabangan dan call site yang lebih terlokalisasi
- Bahkan ketika
MemoryRegiondiubah menjadi struct DU agar dialokasikan di stack, performanya hanya membaik sekitar 15%, dan 85% sisanya berasal dari penghapusan DU serta fungsi pemetaan - Setelah itu juga ada beberapa kasus lain ketika saya berpindah ke struct DU atau memilih pendekatan yang kurang ramah terhadap F#
- Sejak implementasi PPU, optimasi mulai diperlukan, dan saya harus sedikit meninggalkan gaya F# yang idiomatis
- Dengan rutin melihat profiler dan memperbaiki performa sedikit demi sedikit, saya berhasil naik hingga sekitar 120FPS
- Peningkatan FPS terbesar ternyata berasal dari mematikan debug build, dan dalam mode rilis naik hingga sekitar 1000FPS
- Saya terus memantau dan menyetel performa secara berkala sampai akhir
Benchmark
- Saya menganggap hanya melihat angka FPS di konsol bukan cara yang baik untuk mengukur performa, jadi di tengah proyek saya menambahkan proyek BenchmarkDotNet untuk mengukur performa desktop
- Setelah itu saya membuat web benchmarker sederhana berbasis Node.js untuk memperkirakan performa browser web dengan cara serupa
- Benchmark tersebut menggunakan demo ROM berikut untuk menguji skenario yang realistis
- Performa FPS desktop pada PC Windows Ryzen 9 7900 dan MacBook Air M4 adalah sebagai berikut
| CPU | Flag | Roboto | Merken |
|---|---|---|---|
| Ryzen 9 7900 | 1785 | 1943 | 1422 |
| Apple M4 | 1907 | 2508 | 1700 |
- Performa FPS web adalah sebagai berikut
| CPU | Flag | Roboto | Merken |
|---|---|---|---|
| Ryzen 9 7900 | 646 | 883 | 892 |
| Apple M4 | 779 | 976 | 972 |
- Fame Boy bekerja cukup baik di kedua platform
- Di luar dugaan, APU, yaitu suara, memberi dampak lebih besar pada performa emulator dibanding PPU
- Jika PPU dimatikan, performa desktop meningkat sekitar 250FPS, tetapi jika APU dimatikan, meningkat sekitar 500FPS
Penggunaan AI
- Karena saya merasa bahkan dalam proyek pembelajaran pun pengaruh AI tidak bisa sepenuhnya dihindari, saya mencatat cara penggunaan AI secara transparan
- Sepanjang keseluruhan proses, AI terutama digunakan sebagai alat bantu
- meminta code review
- menjadi lawan bicara untuk meninjau ide
- menafsirkan dokumentasi teknis singkat
- Saya berusaha meminimalkan kode yang ditulis AI
- Karena saya ingin membuat hasil yang bisa saya tunjukkan kepada orang lain dengan bangga, saya ingin meninggalkan kode yang saya buat sendiri alih-alih sekadar membagikan prompt
-
PR peningkatan performa
- Di bagian akhir proyek, saya memberikan repositori ke CLI dan memintanya mencari peningkatan performa
- Saya memberinya beberapa ide dan juga membiarkannya mencoba hal lain yang diinginkan, dan pada beberapa benchmark performanya meningkat lebih dari dua kali lipat
- Detailnya ada di PR
- Namun, bug juga ikut masuk dan saya harus menemukannya serta memperbaikinya sendiri
- Salah satu peningkatan performa besar, yaitu “memperbarui STAT hanya saat transisi mode/LY”, merusak sebagian game dan demo yang bergantung pada pembaruan yang lebih sering, dan diperbaiki dengan commit perbaikan
-
“musim dingin timer”
-
Ada jeda besar dalam riwayat Git, dan saya menyebut periode ini sebagai “timer winter”
-
Bukan berarti saya tidak mengerjakan emulator, melainkan saya terhambat oleh bug yang membuat saya tidak bisa melewati layar hak cipta Tetris
-
Saya menghabiskan lebih dari 20 jam untuk debugging, menelusuri Discord emu-dev, membuat tes, dan melemparkan masalah ini ke model AI awal, tetapi tetap tidak terpecahkan
-
Saya berhenti beberapa minggu lalu mencoba Claude Opus, dan dalam beberapa menit ia menemukan masalahnya
-
Masalahnya adalah timer hanya melakukan tick sekali per instruksi, bukan sebanyak jumlah siklus yang dikonsumsi instruksi tersebut
-
-
let stepEmulator () = let cyclesTaken = stepCpu cpu
// Before stepTimers timer memory // only once per instruction
// The fix for _ in 1..cyclesTaken do // cpuCycles can vary between 1 and 6 stepTimers timer memory
-
-
Karena siklus CPU bisa bervariasi dari 1 hingga 6, pada implementasi lama timer rata-rata berjalan 2–3 kali lebih lambat daripada kondisi sebenarnya
-
Layar hak cipta itu sebenarnya hanya tampil lebih lama, dan masalahnya adalah saya tidak pernah mencoba menunggu 1–2 menit
-
Sebagian besar teks artikelnya sendiri ditulis langsung oleh saya
-
Pelajaran yang didapat dan kesimpulan
- Tujuan utamanya adalah mempelajari cara kerja komputer, dan untuk tujuan itu proyek ini merupakan keberhasilan besar
- Pengerjaannya sangat menyenangkan, dan saya begitu larut sampai setelah pulang kerja saya mulai dengan “hari ini satu fitur saja”, lalu terus berulang menjadi “satu bug lagi” sampai pukul 2 pagi
- Saya sempat berpikir untuk mencoba Game Boy Advance juga, tetapi melihat spesifikasinya, peningkatan pemahaman hardware tampak hanya sekitar 20%, sementara upaya yang dibutuhkan terlihat sekitar 3 kali lipat
- Game Boy punya keseimbangan yang baik untuk membantu pembelajaran, dan untuk sementara saya bisa berhenti sampai di sini
- Saya tidak yakin apakah saya menjadi software engineer yang lebih baik, tetapi saya jelas jadi sedikit lebih paham tentang alat yang saya gunakan setiap hari
- Pertanyaan atau komentar bisa dikirim melalui email
1 komentar
Komentar Hacker News
Senang melihat F# di sini! Emulator adalah cara yang bagus untuk mempelajari sebuah bahasa, dan sekilas tampaknya ada keseimbangan yang baik antara F# yang idiomatis dan yang kurang idiomatis sesuai tugasnya
Sebagai perbaikan mudah untuk mengurangi alokasi, bisa menambahkan
[<Struct>]pada discriminated union diInstructions.fs, dan menggunakan kembali nama field agar field internal juga bisa dipakai ulangCatatan kecil saja, tetapi sebagian penanganan register terasa membingungkan. Karena tipenya sudah
byte, melakukana &&& 0xFFuydi setter tampaknya tidak menambah apa pun dibandingmember val A = 0uy with get, set. Mungkin itu jejak perubahan selama pengembanganRegisterada komentar seperti ini: karena register harus memotong nilainya menjadi 8-bit saat ditulis, ia tidak bisa menjadi record type dan memerlukan setterPenjelasannya karena renderer web, dan Fable mengubah
uint8menjadiNumberdi JS sehingga nilainya bisa melewati 8-bit dan pemotongan tidak diterapkanJadi untuk target web, ini tampak seperti kode yang secara konservatif merapikan data karena karakteristik Fable yang melebarkan nilai menjadi JS
NumberAkhirnya ada seseorang yang benar-benar mengerahkan upaya manusia nyata untuk mempelajari sesuatu, bukan “LLM membantu membuat X dalam Y menit”
Jadi sepertinya masih ada sedikit harapan bagi umat manusia
Meski begitu, emulator ini benar-benar keren, dan emulator GBA adalah target yang bagus untuk dicoba sendiri
Sama sekali tidak terasa seperti AGI, lebih seperti mesin plagiarisme yang hiasannya sudah copot
Mungkin suatu hari seseorang di Microsoft menyadarinya dan membunyikan alarm RLHF, jadi GPT memang sudah jauh membaik dan tampaknya cukup berguna juga untuk F#. Developer F# yang tak terlalu berprinsip mungkin sekarang baik-baik saja memakai agen semacam itu
Tetapi yang saya rasakan bukan “masalah plagiarisme sudah teratasi, sekarang mari hasilkan berbagai macam sampah”, melainkan “sekarang kalau ChatGPT menjiplak, itu tidak akan terlihat sejelas dulu”
Demi keuntungan produktivitas, saya tidak mau melempar d100 atau d1000 pada kemungkinan merusak total salah satu nilai inti saya. Saya lebih memilih tetap lambat dan menganggur. Serius, saya sedang mengarah ke bidang pemasangan panel surya dan pengumpulan barang rongsokan
Masalah “mahasiswa tidak mau berpikir” sudah ada jauh lebih lama daripada LLM. Pada 2007 saya mengambil kelas persamaan diferensial parsial tingkat lanjut, dan karena saya benar-benar ingin mempelajari PDE, saya mengerjakan hampir semua tugas; lalu karena secara psikologis lemah dan tidak bisa menolak mahasiswa matematika pemalas yang menyebalkan, hampir semuanya menyalin tugas saya. Hal yang sama terjadi lagi di program matematika pascasarjana. Benar-benar sulit dipercaya. Kalau memang begitu, saya tidak tahu untuk apa mereka ada di program itu
Ah, F#, cinta terbesar saya. Saya berharap orang-orang dari kubu C# melihat ini dan berhenti merusak C# dengan menjadikannya bahasa yang serbabisa tapi setengah matang
Kalau membuat proyek yang memakai C# dan F# bersama, saya tidak mengerti kenapa mereka tidak bisa melihat bahwa berbagai hal yang terus ditambahkan ke C# sebenarnya bisa didapatkan dengan baik dan ergonomis. Interoperabilitasnya juga luar biasa
Anda bisa melangkah cukup jauh dengan memakai F# seperti bahasa fungsional, tetapi pada akhirnya Anda ingin berinteraksi dengan ekosistem .NET, dan pada titik itu Anda mulai menulis dalam gaya hibrida OOP/fungsional yang agak aneh
F# adalah bahasa yang bagus, tetapi rasanya selamanya terjebak dalam bayang-bayang C#. Banyak code library diwarisi dari C# dan .NET, sering kali bukan antarmuka atau library yang dirancang dengan mempertimbangkan F#, dan dokumentasi penggunaan F# secara eksplisit juga sering tidak ada
Masalah yang lebih besar adalah komunitas C# menyukai OOP, jadi jika ingin bekerja dengan gaya functional programming, sering kali library seperti itu perlu dibungkus ulang menjadi bentuk yang lebih “fungsional”
Meski begitu, menurut saya itu jauh lebih baik daripada tidak punya apa-apa sama sekali. Saya juga suka Haskell dan OCaml, tetapi dalam hal ini memang ada perbandingan
Interoperabilitas C# melonggarkan jaminan yang biasanya diandalkan kode F#, terutama immutability. Karena cara pemetaannya ke C#, keterbatasan yang tak terduga juga muncul pada generic
Sangat keren! Saya suka F#, tetapi dari pengalaman menulis interpreter Smalltalk kecil dengan F#, saya memang memastikan bahwa untuk jenis pekerjaan seperti ini, jika dipakai dengan cara yang “sebagaimana dimaksud”, ia bukan monster kecepatan
Misalnya saya biasanya suka struktur data
Map, dan itu struktur immutable yang sangat bagus, cukup untuk kebanyakan penggunaan. Tetapi ketika performa menjadi penting, juga tidak sulit masuk ke loop imperatif yang membosankan dengan hash map biasaJika semuanya dikurung dalam satu fungsi, biasanya kita bisa menghindari rasa bahwa kodenya ditulis terlalu kotor
Mereka bahkan juga serius mengerjakan peningkatan tail call yang bahkan tidak dimanfaatkan compiler C#. Sekitar .NET 9 atau 10, F# juga mendapat atribut yang membuat compiler mengeluarkan error jika ada pemanggilan rekursif yang bukan tail call, sehingga tidak mudah rusak tanpa sengaja
Hanya saja, kalau Anda menebar linked list, sequence, dan tipe immutable ke mana-mana, ya jelas itu bukan Rust
Proyek yang keren! Senang sekali melihat hal seperti ini
Di sisi lain, ini bukan penilaian terhadap penulis atau pekerjaannya, tetapi setelah melihat seperti apa kode F# dalam proyek nyata, saya merasa keinginan saya untuk belajar dan memakai F# boleh dikubur saja
Bagian fungsional murninya indah, tetapi begitu turun ke kode yang lebih imperatif atau mutable, menurut saya tampilannya jadi cukup jelek. Sayangnya, dalam kebanyakan proyek nyata sepertinya kita akhirnya memang harus melakukan itu
Jadi saya tidak tahu apakah saya harus memilih bahasa fungsional lain dan benar-benar mendalaminya, atau fokus menerapkan konsep-konsep fungsional di bahasa yang sudah saya pakai. Bahasa utama saya C#, dan dukungannya untuk paradigma fungsional terus bertambah, jadi opsi kedua cukup mudah
Emulator yang ditulis dengan bahasa fungsional selalu mengesankan. Soalnya biasanya jauh lebih mudah memetakan perangkat keras ke bahasa imperatif. Menyenangkan melihat abstraksi fungsional seperti apa yang diciptakan orang
F# benar-benar bahasa yang menyenangkan, dan ini pekerjaan yang keren!
F# adalah bahasa cinta saya untuk coding yang sama sekali tidak akan pernah bisa saya pakai di pekerjaan nyata. Tidak ada kesempatan memakainya di luar proyek pribadi :(
Tulisan yang menarik dan menyenangkan. Saya suka bagian pemodelan data. Saya sedang sedikit mencoba OCaml, dan pemodelan seperti itu adalah bagian terbaiknya
Mengetahui tentang CAMLBOY juga menarik. Sebagai masukan untuk penulis, sebaiknya lewati tahap penyuntingan AI. Saya rasa saya akan lebih suka tulisan dengan sedikit kesalahan tata bahasa atau ungkapan yang kurang halus daripada tulisan yang agak datar seperti sekarang