2 puan yazan GN⁺ 2025-07-06 | Henüz yorum yok. | WhatsApp'ta paylaş
  • OCaml’i örnek düzeyinin ötesinde orta ölçekli koda uygulamak için Game Boy emülatörü CAMLBOY geliştirildi; hedef, tarayıcıda çalışması ve akıllı telefonda oynanabilir performans sunmasıydı
  • Uygulama; CPU, timer ve GPU’nun CPU döngülerine yetişmesini sağlayan catch up method, adrese göre okuma/yazma yönlendirmesini üstlenen bus ve 8 bit/16 bit erişim arayüzlerinden oluşuyor
  • CPU’nun test edilebilirliğini artırmak için bus uygulaması functor ile enjekte edildi; komut argümanlarının karıştırılması ise GADT ile 8 bit ve 16 bit tipleri ayrılarak azaltıldı
  • Entegrasyon testleri, test ROM’ları ve ppx_expect birleştirilerek regresyonları yakaladı ve keşif odaklı geliştirmeyi mümkün kıldı; tarayıcı UI’ı js_of_ocaml ve Brr ile uygulandı
  • Chrome profiler ile GPU, timer ve Bigstringaf darboğazları azaltıldıktan sonra js_of_ocaml inline etme kapatılarak PC tarayıcısında 100 FPS’ye, akıllı telefonda 60 FPS’ye ulaşıldı

CAMLBOY’un hedefi ve kapsamı

  • CAMLBOY, OCaml ile yazılmış ve tarayıcıda çalışan bir Game Boy emülatörüdür
  • Demoda çeşitli homebrew ROM’lar yer alıyor; önerilenler arasında Bouncing ball ve Rocket Man Demo gösteriliyor
  • Modern akıllı telefon tarayıcılarında da 60 FPS çalışması hedefleniyor
  • Daha sonra yapılan bir PR ile js_of_ocaml tabanlı WASM çalıştırma da mümkün hale geldi
  • Depo linoscope/CAMLBOY üzerinde yayımlandı

Neden OCaml ile Game Boy emülatörü yapıldı?

  • OCaml’i birkaç ay öğrendikten sonra basit örnekler yazılabiliyordu; ancak orta ve daha büyük ölçekli kod organizasyonu ile gelişmiş özelliklerin pratik kullanımı konusunda sezgi eksikti
  • Game Boy emülatörü, alıştırma projesi için uygun koşullara sahipti
    • Spesifikasyonu net olduğu için neyin uygulanacağı konusunda fazla belirsizlik yok
    • Birkaç gün ya da haftada bitmeyecek kadar karmaşık
    • Birkaç ay içinde tamamlanamayacak kadar aşırı karmaşık değil
    • Game Boy’a dair kişisel nostalji var
  • Uygulama hedeflerinde performanstan önce okunabilirlik ve bakımı kolaylık öne alındı; tarayıcıda çalıştırma ve benchmark karşılaştırmaları da kapsama dahil edildi
    • js_of_ocaml ile JavaScript’e derleyip tarayıcıda çalıştırma
    • Akıllı telefon tarayıcısında oynanabilir FPS’ye ulaşma
    • Benchmark uygulayıp çeşitli OCaml derleyici backend’lerini karşılaştırma

Emülatör yapısı ve ana döngü

  • CAMLBOY’un ana bileşenleri CPU, timer, GPU, bus, cartridge, interrupt controller, serial port, joypad gibi parçalara ayrılır
  • bus, CPU ile çeşitli donanım modülleri arasında adrese göre okuma/yazma yönlendirmesi yapar
    • Örneğin 0xFFFF adresine yazma, kesmeleri etkinleştirmek veya devre dışı bırakmak üzere interrupt controller’a iletilir
    • bus’a bağlı donanım modülleri Addressable_intf.S arayüzünü uygular
    • bus, Word_addressable_intf.S arayüzünü uygular
  • Gerçek donanımda CPU, timer ve GPU aynı clock’u paylaşır; ancak emülatör sıralı bir çalıştırma döngüsü olduğu için ayrıca senkronizasyon gerekir
  • Ana döngü, her modülün ilerleme miktarını catch up method ile eşitler
    • CPU bir komut çalıştırır ve tükettiği cycle sayısını kaydeder
    • timer, CPU’nun tükettiği cycle sayısı kadar ilerletilir
    • GPU da aynı cycle sayısı kadar ilerletilir

Okuma/yazma arayüzleri ve bus uygulaması

  • 8 bit okuma/yazmayı destekleyen modüller Addressable_intf.S imzasını paylaşır
    • 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 gibi dosyalar include Addressable_intf.S with type t := t biçiminde aynı arayüzü içerir
  • CPU ile bus arasında 16 bit okuma/yazma da gerektiğinden Word_addressable_intf.S, Addressable_intf.S’i içerir ve read_word, write_word ekler
  • bus; GPU, timer, RAM gibi bağlı modülleri alanlar olarak tutar ve adrese göre okuma/yazmayı uygun modüle iletir
    • 0xC000 adresindeki okuma/yazma RAM’e yönlendirilir
    • Tüm bellek haritası için Pandocs Memory Map referans alınır
  • read_word, read_byte’ı iki kez çağırarak 16 bit okumayı uygular; gerçek donanım da 16 bit erişimi iki 8 bit erişimle işler

Register’lar ve CPU test edilebilirliğinin iyileştirilmesi

  • Game Boy CPU’sunda 8 bit register’lar olan A, B, C, D, E, F, H, L bulunur
  • 8 bit register’lar birleştirilerek 16 bit register’lar AF, BC, DE, HL olarak da kullanılır
  • İlk CPU uygulaması registers, bus, pc gibi öğeleri doğrudan tutan bir yapıdaydı; run_instruction içinde fetch, decode, execute yapılıyordu
  • Bu yapı test etmesi zordu
    • bus; GPU, timer, RAM gibi çok sayıda modüle bağımlıydı
    • Birim testlerinde CPU oluşturmak için bus ve bağlı modüllerin hepsini hazırlamak gerekiyordu
    • bus ve tüm bağlı modüller uygulanmadan CPU instance’ı oluşturulamıyordu
  • CPU, bus’ın somut uygulamasını soyutlamak için functor olarak yeniden uygulandı
    • module Make (Bus : Word_addressable_intf.S) biçiminde bus uygulaması enjekte edildi
    • Testlerde tek bir byte array tabanlı Mock_bus ile CPU instantiate edildi
    • Bu değişiklik sayesinde CPU birim testlerinde gerçek bus yerine mock uygulama kullanılabilir hale geldi

Komut kümesi ve GADT kullanımı

  • Game Boy komut kümesinde 8 bit argüman alan komutlar ve 16 bit argüman alan komutlar vardır
    • ADD8 A, 0x12, 8 bit A register’ı ile 8 bit immediate değeri toplar
    • ADD16 AF, 0x1234, 16 bit AF register’ı ile 16 bit immediate değeri toplar
  • İlk deneme, argümanları Immediate8, Immediate16, R, RR gibi variant’larla ifade etme şeklindeydi
  • Variant yaklaşımında read_arg’ın dönüş tipini tek bir tipe sabitlemek zordu
    • R r, uint8 döndürür
    • RR rr, uint16 döndürür
    • Aynı match ifadesi içinde dönüş tipi değişir
  • Argüman tipleri GADT kullanılarak yeniden tanımlandı
    • Immediate8 : uint8 -> uint8 arg
    • Immediate16 : uint16 -> uint16 arg
    • R : Registers.r -> uint8 arg
    • RR : Registers.rr -> uint16 arg
  • Bu yapıda read_arg : type a. a Instruction.arg -> a gibi, dönüş tipi argümanın tipine göre değişir
    • ADD8 yalnızca uint8 arg * uint8 arg alır
    • ADD16 yalnızca uint16 arg * uint16 arg alır
    • 8 bit ve 16 bit komut argümanlarının karıştırılması tip düzeyinde azaltılabilir

Cartridge ve birinci sınıf modüller

  • Game Boy cartridge’i yalnızca basit ROM’dan ibaret değildir; türüne göre ek donanım içerebilir
  • ROM_ONLY tipi cartridge, oyun verisi ve kodunu saklayan yalnızca ROM içerir
    • Örnek olarak Tetris kullanılır
  • MBC3 tipi cartridge, ROM’a ek olarak bağımsız RAM ve timer içerir
    • Örnek olarak Pokémon Red kullanılır
  • Her cartridge tipinin özellikleri farklı olduğundan her biri ayrı modül olarak uygulanır
  • Çalışma zamanında cartridge tipine uygun modülü seçmek için birinci sınıf modüller kullanılır
    • Detect_cartridge.f, ROM byte’larını alıp (module Cartridge_intf.S) döndürecek şekilde tasarlanmıştır

test ROM ve ppx_expect tabanlı entegrasyon testleri

  • test ROM, emülatörün belirli bir işlevini doğrulayan programdır
    • Temel aritmetik komutların davranışını kontrol etme
    • MBC1 cartridge tipi desteğini kontrol etme
  • test ROM’lar, normal oyun ROM’larından farklı olarak başarısız olan işlevin kapsamını bildirir ve bazı temel özellikler eksik olsa bile çalışabildiği için emülatör geliştirmede yararlıdır
  • test ROM’lar genellikle test sonucunu ekrana yazdırır
    • mooneye test ROMs, başarısızlık durumunda register dump ve assertion failure bilgisi gösterir
    • blargg test roms gibi bazı test ROM’ları serial port üzerinden ASCII sonuçları yazdırır
  • Entegrasyon testlerinde ppx_expect kullanılır
    • M.run_test_rom_and_print_framebuffer, ROM’u çalıştırır ve son ekran durumunu ASCII karakterleriyle yazdırır
    • Çıktı dizesi, [%expect{|...|}] içindeki beklenen değerle karşılaştırılır
    • ppx_expect açıklaması için Jane Street yazısı referans alınır
  • Bu test yapısı, büyük kod değişikliklerinde regresyonları yakalar ve keşif odaklı programlama akışını mümkün kılar
    • Yeni özelliği doğrulayan bir test ROM bulunur
    • ppx_expect testi ayarlanır
    • Başarısız çıktı commit edilir
    • Özellik uygulanır
    • Test sonucunun Test OK durumuna dönüp dönmediği kontrol edilir

JavaScript derleme ve tarayıcı UI’ı

  • js_of_ocaml sayesinde JavaScript’e derleme zor olmadı
  • Emülatörün tarayıcıda çalışır hale gelmesi için tek bir commit yeterli oldu
  • Tarayıcı UI’ını uygulamak için Brr kullanıldı
  • Brr, JS nesnelerini OCaml nesneleri yerine OCaml modüllerine eşler
    • js_of_ocaml yerleşik tarayıcı API’si JS nesnelerini OCaml nesnelerine eşlediğinden OCaml’in nesne bilgisini gerektirir
    • Brr kullanımı, OCaml nesne modeline ilişkin yükü azaltır

Performans optimizasyon süreci

  • İlk tarayıcı çalıştırması işlevseldi ama oynamayı zorlaştıracak kadar yavaştı
    • PC tarayıcısında yaklaşık 20 FPS idi
    • Gerçek Game Boy 60 FPS çalıştığından performansın yaklaşık 3 kat iyileştirilmesi gerekiyordu
  • Darboğazlar Chrome profiler ile bulundu
    • GPU zamanın yaklaşık %73’ünü tüketiyordu
    • tile_data.ml %34, oam_table.ml %18, tile_map %8 tüketiyordu
    • timer.ml ve bazı Bigstringaf fonksiyonları da ciddi zaman tüketiyordu
  • Darboğazları gidermek FPS’yi aşamalı olarak artırdı
  • Sonrasında PC tarayıcısında 60 FPS’ye ulaşıldı; ancak akıllı telefonda 20~40 FPS’de kalıyordu
  • Release build’in JS çıktısı dev build’den daha yavaştı; discuss.ocaml.org’un yardımıyla js_of_ocaml’in inline etmesinin JS performans düşüşünün nedeni olduğu anlaşıldı
  • Inline etme devre dışı bırakıldıktan sonra PC’de 100 FPS, akıllı telefonda 60 FPS elde edildi
  • JS performans optimizasyonu native performansı da iyileştirdi; native çalıştırmada yaklaşık 1000 FPS ile çalışıyor

Benchmark ve karşılaştırma sınırlamaları

  • Emülatörü UI olmadan çalıştıran headless benchmarking mode uygulandı
  • Çeşitli OCaml derleyici backend’lerinde FPS ölçüldü
  • Bu benchmark’ı başka Game Boy emülatörleriyle FPS karşılaştırması yapmak için kullanmak zordur
    • Emülatör performansı, doğruluk ve uygulanan özellik kapsamından büyük ölçüde etkilenir
    • CAMLBOY, APU’yu (Audio Processing Unit) uygulamadığı için APU destekli emülatörlerle FPS karşılaştırması yapmak anlamlı değildir

OCaml kullanım deneyimi

  • OCaml ekosistemi, yaklaşık 6 yıl önceki kullanım deneyimine kıyasla çok gelişti
    • dune sayesinde dosyaları dizine koyunca build sisteminin bunları işlemesine yakın bir deneyim oluştu
    • Merlin ve OCamlformat ile otomatik tamamlama, kod gezintisi ve otomatik formatlamayı devreye almak genel olarak kolaydı
    • setup-ocaml kullanıldığında GitHub Actions’ta build ve test ayarlanabiliyor
  • CAMLBOY uygulamasında performans nedeniyle mutable state yoğun kullanıldı
    • Birçok modülde t -> ... -> unit tipinde fonksiyonlar var; bu, bir mutable state değişikliği anlamına geliyor
    • “Fonksiyonel” olmayan bir uygulama olmasına rağmen OCaml’in avantajlarının kaybedildiği hissedilmedi
  • Tercih edilen noktalar, “fonksiyonel” olmanın kendisinden çok statik tipler, variant’lar, pattern matching, modül sistemi ve iyi tip çıkarımına daha yakındı

OCaml’de rahatsız eden noktalar

  • Ekosistem iyileşmiş olsa da bazı alanlar hâlâ karmaşık veya yetersiz belgelenmiş durumda
    • Bağımlılıkları yeniden üretilebilir şekilde çözme sürecinde resmî opam dokümanlarında net yönlendirme eksikti
    • Gerekli komutları bulmak için setup-ocaml kaynak kodu okundu
    • Bir paketi yerelde “publish” edip ardından yerelde publish edilmiş paketi kurmak zorunda kalma biçimi karmaşık geldi
  • Soyutlamaya bağımlılığın sözdizimsel maliyeti yüksek
    • B’nin C’nin somut uygulamasına değil de C_intf arayüzüne bağımlı olması istenirse B’yi functor’a çevirmek gerekir
    • B functor olunca A artık eskisi gibi B.fooya başvuramaz; Anın da B_intf alan bir functor’a çevrilmesi gerekir
    • Bir modülü functor’a çevirmek, yalnızca o modülün başka modüllere bağımlı olma biçimini değil, başka modüllerin o modüle bağımlı olma biçimini de değiştirir
  • Bu sorun, Camlboy -> Bus -> Cartridge bağımlılık grafiğinde yalnızca Bus -> Cartridge kısmını ayırmaya çalışırken ortaya çıkar
  • OOP’de class B’nin constructor’ı somut class C yerine interface C_intf alacak şekilde değiştirilse bile class B’nin kendi tipi değişmez
    • Ancak OOP’de dynamic dispatch maliyeti vardır
    • OCaml’in OOP özelliklerine aşina olmayan çok kişi olduğundan kodun okuyucu kitlesi sınırlanabilir

Kaynaklar

  • OCaml ile ilgili kaynaklar
    • Learn OCaml Workshop: Jane Street içinde kullanılmış atölye materyali; boşlukları olan OCaml kodu ve testleri doldurarak öğrenme yaklaşımını kullanır
    • Real World OCaml: OCaml temel sözdizimini bilen veya başka fonksiyonel dil deneyimi olan kişilere önerilen, pratik örnek odaklı bir kaynaktır
  • Game Boy ile ilgili kaynaklar
    • The Ultimate Game Boy Talk: Game Boy mimarisini yaklaşık 1 saatte anlatan video
    • gbops: Game Boy komut kümesi tablosu
    • Game Boy CPU Manual: Komut uygulamasında kullanılan CPU kılavuzu; bazı kısımları, özellikle register flag çevresi, hatalıdır
    • Pandocs: GPU, timer gibi donanım modüllerinin davranışı için referans alınan wiki
    • Imran Nazar’s blog: JavaScript ile Game Boy emülatörü uygulama öğreticisi; uygulama kapsamını kabaca kavramak için kullanılmıştır

Henüz yorum yok.

Henüz yorum yok.