1 poin oleh GN⁺ 2 jam lalu | 1 komentar | Bagikan ke WhatsApp
  • 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(), dan getJoypadState(state), sementara stepper menjalankan CPU, timer, serial, APU, dan PPU secara berurutan untuk menjaga sinkronisasi single-thread
  • Implementasi CPU memanfaatkan discriminated union dan match di F# untuk memodelkan 512 opcode menjadi 58 instruksi, serta dirancang agar status ilegal seperti menulis ke nilai immediate dicegah di level tipe melalui tipe From dan To
  • 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 hitam
    • audiobuffer: ring audio buffer dengan sample rate 32768Hz yang memiliki head baca dan tulis
    • stepEmulator(): menjalankan satu instruksi CPU dan mengembalikan jumlah siklus yang dipakai
    • getJoypadState(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 stepper di 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 stepper memusatkan 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 mutable dan 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

      • immediate yang membaca nilai byte di memori tepat setelah instruksi
      • direct yang membaca dan menulis register CPU
      • indirect yang membaca dan menulis lokasi memori yang ditunjuk oleh register CPU HL
    • Dengan mengekstrak konsep lokasi dan membaginya menjadi tipe From dan To, 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 Loc alih-alih From dan To, instruksi salah seperti Load(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 0x76 sebagai HALT
    • Setelah terbiasa dengan match dan Option di F#, kembali ke switch biasa 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 stepEmulator tidak lagi dikendalikan oleh lastFrameTime, melainkan dijalankan sesuai kebutuhan buffer audio frontend
  • samplesNeeded harus menghitung berapa kali stepEmulator perlu dipanggil agar cocok dengan sampling rate yang berbeda-beda dan tetap menghasilkan 60FPS
  • Buffer audio frontend hanya peduli pada pengisian dirinya sendiri, jadi stepEmulator bisa dipanggil terlalu banyak atau terlalu sedikit per frame, dan akibatnya framebuffer mungkin 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 uint8 yang 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, mapAddress muncul 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 MemoryRegion dibuat 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 MemoryRegion diubah 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
    • Flag: loop pendek tanpa suara
    • Roboto: demo berdurasi lebih dari 1 menit dengan banyak efek visual dan suara
    • Merken: mirip dengan Roboto, tetapi menggunakan ROM memory banking untuk menguji memori
  • 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

 
GN⁺ 2 jam lalu
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 di Instructions.fs, dan menggunakan kembali nama field agar field internal juga bisa dipakai ulang
    Catatan kecil saja, tetapi sebagian penanganan register terasa membingungkan. Karena tipenya sudah byte, melakukan a &&& 0xFFuy di setter tampaknya tidak menambah apa pun dibanding member val A = 0uy with get, set. Mungkin itu jejak perubahan selama pengembangan

    • Di source Register ada komentar seperti ini: karena register harus memotong nilainya menjadi 8-bit saat ditulis, ia tidak bisa menjadi record type dan memerlukan setter
      Penjelasannya karena renderer web, dan Fable mengubah uint8 menjadi Number di JS sehingga nilainya bisa melewati 8-bit dan pemotongan tidak diterapkan
      Jadi untuk target web, ini tampak seperti kode yang secara konservatif merapikan data karena karakteristik Fable yang melebarkan nilai menjadi JS Number
    • Hal ini memang dibahas pada bagian artikel tentang porting ke Fable. Disebutkan juga bahwa Blazor sempat dicoba
  • Akhirnya 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

    • Cara seperti itu akan selalu ada. Bahkan pada 2026 masih ada orang yang membuat sesuatu dengan perkakas tangan, jadi mari sebut ini coding kerajinan tangan
    • Menurutku, harapan pada umat manusia seharusnya sudah ditinggalkan sejak runtuhnya Uni Soviet
      Meski begitu, emulator ini benar-benar keren, dan emulator GBA adalah target yang bagus untuk dicoba sendiri
    • Sebagai orang yang sudah lama hidup sebagai developer F# dan juga lama mengalami perundungan di dunia akademik STEM, saya tidak memakai LLM. Alasan besarnya adalah karena ChatGPT-3.5 terlalu jelas terlihat seperti hasil copas dari repositori GitHub F#
      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

    • Hanya saja, jika datang dari dunia OCaml, agak disayangkan karena F# terasa sedikit terjebak dalam bayang-bayang C#
      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

    • Memindahkan cara penggunaan library dari C# ke F# itu pekerjaan yang cukup mekanis, jadi saya kurang yakin dokumentasi terpisah benar-benar diperlukan
      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
    • Memang benar ada kecanggungan tertentu karena interaksi keduanya, tetapi menurut saya masalah yang lebih besar bukan apakah library tertentu harus dipetakan agar cocok untuk F#, melainkan memahami dengan baik aturan interoperabilitas dan bentuk output internal yang dihasilkan
      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

    • Di F#, saya melihat performa lebih baik jika ditulis dengan gaya yang sangat imperatif, bahkan nyaris bodoh, tetapi efek sampingnya dikurung di dalam fungsi. Dengan begitu, fungsinya pada dasarnya tetap “murni” sambil tetap mendapat kecepatan yang lumayan
      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 biasa
      Jika semuanya dikurung dalam satu fungsi, biasanya kita bisa menghindari rasa bahwa kodenya ditulis terlalu kotor
    • Saya penasaran kapan interpreter itu ditulis. Seluruh ekosistem .NET mengalami peningkatan performa besar dalam beberapa tahun terakhir, jadi terutama bagi orang yang terakhir memakainya pada era Framework, perbedaannya sangat terasa
      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
    • Jika berhati-hati memilih fitur yang dipakai dan kapan memakainya, F# juga bisa sangat cepat. Gunakan paradigma fungsional saat diinginkan, dan bila perlu pakai kode imperatif level rendah di hot loop
      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

    • Penasaran apakah Anda sudah melihat kodenya. F# punya variabel mutable dan array, dan proyek ini juga memakainya misalnya untuk memori
  • 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