1 ポイント 投稿者 GN⁺ 2 시간 전 | 1件のコメント | WhatsAppで共有
  • Fame BoyはF#で実装されたGame Boyエミュレータで、サウンドを含めてデスクトップとWebで動作し、ブラウザでプレイGitHubソースが公開されている
  • エミュレータコアとフロントエンドは、framebufferaudiobufferstepEmulator()getJoypadState(state)だけを共有するように単純化し、stepperがCPU・タイマー・シリアル・APU・PPUを順次実行して単一スレッドでの同期を取っている
  • CPU実装では、F#の判別共用体とmatchを活用して512個のopcodeを58個の命令としてモデル化しており、FromTo型によって即値への書き込みという不正な状態を型レベルで防ぐよう設計されている
  • PPUは実際のGame BoyのピクセルFIFOの代わりにスキャンライン単位のレンダリングを採用し、より高速かつ単純になった一方で、ピクセルキューのタイミングを利用する一部のゲームは正しく動作しない可能性がある
  • Web移植はFableで実現し、8ビット・16ビットのビット演算がJavaScriptの32ビットセマンティクスに従う問題を修正した後、約100KBのJSバンドルで動作し、パフォーマンス最適化とリリースビルドによりデスクトップでは約1000FPSに達した

プロジェクトの背景と目標

  • ソフトウェアエンジニアとして8年以上働いてきたが、コンピュータが実際にどう動くのかを理解できていないと感じ、直接エミュレータを作りながら学ぶことにした
  • 子どもの頃にPokémonをよく遊んでいたため対象をGame Boyに選び、実在するハードウェアでありながら比較的スコープが単純で、個人的なつながりも強かった
  • いきなりGame Boyに入る前に、From NAND to Tetrisを受講して、レジスタ、メモリ、ALUといったコンピュータの基本要素を理解した
  • エミュレータ制作に慣れるため、まずF#でCHIP-8エミュレータのFip-8を実装した
  • 数か月の作業の末、サウンドを含みデスクトップとWebで動作するGame BoyエミュレータFame Boyを完成させた
  • ブラウザでプレイでき、ソースはGitHubで公開されている

エミュレータの構造

  • デスクトップとWebの両方で動作するように、エミュレータコアとフロントエンドの間のインターフェースを単純に保っている
  • フロントエンドとコアの間の中核インターフェースは、2つの配列と2つの関数で構成される
    • framebuffer: 白、明色、暗色、黒を保持する160×144の階調配列
    • audiobuffer: 32768Hzサンプルレートのリングオーディオバッファで、読み取り・書き込みヘッドを持つ
    • stepEmulator(): CPU命令を1つ実行し、消費したサイクル数を返す
    • getJoypadState(state): フロントエンドがジョイパッド状態をエミュレータに渡すコールバックで、通常はフレームごとに1回呼ばれる
  • Fame Boyは実際のGame Boyハードウェアに近い形でモデル化されている
    • CPUは実際のGame BoyのSharp LR35902のように、メモリマップ以外のハードウェアを認識せず、割り込み信号のためにIoControllerのみを利用する
    • CPUはコードベースの中で最もF#らしい部分であり、関数型のドメインモデリングを多く用いている
    • Memory.fsはGame BoyのRAMの大部分を保持し、CPU、IO Controller、カートリッジの間でメモリマップとバスの役割を果たす
    • パフォーマンスのため、Memory.fsはPPUなどとVRAM・OAM RAM配列の参照を共有している
    • IoController.fsは、Memory.fsにロジックが多くなりすぎたため分離されたもので、実際のGame Boyハードウェアに単一のIOコントローラは存在しないが、ハードウェアレジスタ処理を1か所に集約することで各コンポーネントのインターフェースを単純かつ安全にしている
  • Emulator.fsstepper関数がエミュレータ全体を束ねる接着剤の役割を果たし、各コンポーネントのステップ実行関数を組み合わせている
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
  • 実際のハードウェアコンポーネントは中央マスターオシレータを基準に並列動作するが、Fame Boyは単一スレッドであるため、コンポーネントを順次実行する必要がある
  • stepper関数は実行を一元化し、すべてのコンポーネントが同期するようにしている
  • プレイ可能な速度を出すには、1秒あたり正しいサイクル数で実行される必要があり、60FPSでは1フレームあたり約17500 CPUサイクルが必要となる
  • フロントエンドはサウンドが有効な場合はオーディオのサンプリングレートでエミュレータを駆動し、ミュート時はフレームレートで駆動する

CPU実装とF#

  • CHIP-8エミュレータはmutableメンバーなしで純粋に書き、配列もコピーしていたが、Fame Boyでは変更可能な状態を積極的に使っている

  • Game BoyはCHIP-8よりはるかに高速で、16KBを超えるメモリを毎秒数百万回コピーする方式は適切ではない

  • Fame BoyにF#を使った理由は、F#の豊富な型システムがCPU命令のモデリングに適しており、またF#自体が好きだからである

  • ドメインモデリング

    • CPU実装ではGekkio’s Complete Technical Referenceに従い、その文書と同じように命令をグループ化した
    • 当初は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

    • 複数の命令が、オペランドの位置という共通の概念を共有している

      • 命令の直後にあるメモリ上のバイト値を読む immediate
      • CPUレジスタを読み書きする direct
      • HL CPUレジスタが指すメモリ位置を読み書きする indirect
    • 位置の概念を抽出して FromTo 型に分けることで、ロード命令をより簡潔に表現した

    • 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

    • この方法により、CPU命令を512個のopcodeから58個の命令へと削減した

    • ドメインを一般化すると不正な状態を許してしまう危険があるが、型システムで防ぐことができる

    • FromTo の代わりに単一の位置型 Loc を使うと、Load(Loc.Direct D, Loc.Immediate) のように、レジスタの値を即値の位置に保存する不正な命令がコンパイルできてしまう

    • Game Boyハードウェアは即値への書き込みをサポートしていないため、F#の型でドメインを正しくモデル化すれば、不正な状態がシステム内で表現されないことを保証できる

    • 唯一の例外として opcode 0x76 がある

      • opcodeパターンだけを見ると、Load(From.Indirect, To.Indirect) のように、HL位置の8ビット値を同じHL位置にロードする形になる
      • Fame Boyの型はこれを許してしまうが、実際のGame Boyにはこの命令は存在しない
      • 論理的にはNOPで危険ではなく、実際にはopcodeリーダーが 0x76HALT としてデコードするため到達不能である
    • F#の match 文やOptionを使った後で通常の switch 文に戻ると、無骨でミスしやすく感じるので、関数型言語を試してみることを勧めている

  • シンプルに保つ

    • プロジェクトの目標は最高のエミュレータを作ることではなく、コンピュータハードウェアを学ぶことだったため、他のエミュレータのコードは深く見なかった

    • CAMLBOY のソースで次のようなコードを見て、必要なフラグだけを任意の順序で渡せる点が良いと感じた

    • set_flags ~h:false ~z:(!a = zero) ();

    • F#は部分適用をサポートする型システムの都合で、メソッドのオーバーロードやデフォルト引数を避けるため、同じやり方にはできなかった

    • 最初は次のように配列とフラグ型を渡す方式で実装した

    • cpu.setFlags [ Half, false; Zero, a = 0uy ]

    • その後のリファクタリングで、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)

    • 新しい関数は簡単に合成でき、テストもしやすい単純な純粋関数である

    • 以前の実装は、値を判別共用体型へ持ち上げて配列に入れる必要があり、より冗長だった

    • 新しい関数はinlineでヒープ割り当ても不要なため性能も高く、エミュレータのFPSを約10%向上させた

  • テスト

    • 初期のCPU実装は、Tetris ROMを実行しながら未実装のopcodeに到達するたびにその命令を実装していく方式だった
    • match opcode with
    • | 0x00 -> Nop
    • | _ -> failwith "Unimplemented opcode"
    • この方式では技術文書をあちこち行き来する必要があり、繰り返しが退屈だったうえ、命令を正しく実装できたかどうかも分かりにくかった
    • その2つの問題を解決するために、単体テストを導入した
    • 学習のためエミュレータのコード自体は自分で書いたが、テストケースの生成にはAIを活用した
    • 技術文書の仕様をプロンプトに入れ、エミュレータのコードを見せない状態で、仕様ベースのテストを書かせた
    • AIがテストを生成している間に自分は仕様を読み、テストが通るまでロジックを実装するという形で、本当のテスト駆動開発を進めた
    • すでに実装済みだった命令のバグも、テストを通じていくつか発見した
    • テストは定期的に見直して改善しており、学習の妨げになるどころか、面白い部分にエネルギーを使う助けになっている

CPU以降のコンポーネント

  • PPU

    • Game BoyにはGPUではなくPPU、つまり picture processing unit がある
    • 他のGame Boyエミュレータ制作記事はCPUに集中し、PPUは数段落だけ扱うことが多かったが、Fame BoyではPPUの理解により長い時間がかかった
    • CPUは From NAND to Tetris と CHIP-8 の経験のおかげで自然に感じられたが、PPUはピクセルを画面に載せるための手順に従う機械的な作業に近かった
    • 最初はピクセルFIFOとPPU全体のパイプラインを一度に理解しようとするより、メモリからタイルと背景マップを読み取って解析し、画面に表示する形で始めた
    • この方法でCPUが動作している様子を確認でき、Tetrisの単純さのおかげで、ほとんど実際のGame Boyゲームのように見える結果を確認できた
    • タイルビューと背景ビューから始めたアプローチは、実際の画面実装からスプライトデータの細かなバグのデバッグまで、継続して役に立った
    • Fame BoyのPPUにはハードウェア的な不正確さが大きい
      • 実際のGame BoyはCRTモニターのようにFIFOキューを使い、ピクセルを1つずつ画面に置いていく
      • Fame Boyはそのラインの描画期間の開始時に、スキャンライン全体をレンダリングする
    • この方式はより高速でコードも単純であり、プレイしたかったゲームはすべて動いたため、ピクセルキュー方式へ移す必要を感じなかった
    • Game Boyハードウェアを限界まで活用し、ピクセルキューのタイミングを利用するゲームはFame Boyでは正しく動かないが、ほとんどのゲームはそこまで冒険的なハードウェアの使い方をしないので、概ね動作すると思われる
  • Joypad

    • PPUとAPUのほかに、ジョイパッドも扱った
    • 初期実装はとても簡単で、テストを書くのも容易だった
    • しかし大きなリファクタリングの後は、ほとんどいつも壊れた
    • ジョイパッドのハードウェアレジスタはCPUとゲームの両方が読み書きするため、相互作用が複雑だった
    • 当初はCPUが毎サイクル、ジョイパッドの状態をレジスタに書き込むようにしていたが、人がボタンを1秒間に何百万回も切り替えるわけではないので、フレームごとに1回だけ更新するように変えた
    • その結果、方向パッドが動かなくなった
    • Game Boyハードウェアは一度にボタンの半分しか読めず、ゲームはほぼ常にジョイパッドレジスタを短い間隔で2回以上読み、その2回の読み取りの間にレジスタが変わることに依存している
    • フレームごとに1回キャッシュされたレジスタは、2回の読み取りの間に変わらないため、ボタンの半分が動作しなかった
    • 最終的に、IoController がCPUが読むときにだけジョイパッドレジスタを更新するよう実装した
    • 関連内容は Pandocsのjoypadドキュメントでさらに読める
  • サウンド

    • 動作するエミュレータを作った後、Web版をプレイしていて、サウンドがないと物足りなく感じたため、APU、つまり audio processing unit を追加した
    • 複数のエミュレータがフレームレートではなく、フロントエンドのオーディオサンプリングレートでエミュレータを駆動していることを知った
    • 最初はこれを逆だと感じ、動的サンプリングレートを調べ、フレームレートがエミュレータを駆動するよう実装しようとした
    • サウンドは概念的に最も難しいコンポーネントで、さまざまなサウンドレジスタとチャンネルの動作を理解するのに時間がかかった
    • この部分ではAIが教師役として大いに役立ち、コーディング前に何度も質問と回答をやり取りした
    • PPUと同様に、チャンネルを1つずつ完成させるたびに大きな満足感があり、Tetrisの音楽が次第に豊かになっていく過程を聞きながら、音楽がどう構成されているかも理解できるようになった
    • CPUとPPUはフレームごとに正確にX個の作業を行う形で、Xを簡単に計算できるが、APUには選択して調整すべき値が多かった
    • APUのサンプリングレートだけは簡単に決められた
      • 実際のGame Boy APUは柔軟なので、エミュレータは任意のサンプリングレートを使える
      • Fame Boyは32768Hzを選んだ
      • 1048576HzのCPUクロックでは、32768HzはCPU 128サイクルあたり1サンプルなので、APUの状態を整数だけで完全に同期できる
      • 128は4でも割り切れるため、APUのステップを4つずつまとめて処理してもCPU命令との整合が崩れない
    • 他の値はずっと不安定で、サウンドエンジニアではないので、値を変えながら合わせる必要があった
    • フロントエンドごとに、プラットフォームごとに固有の問題があった
      • PCではサウンドはうまく動いたが、MacBookでは滝の音のように聞こえた
      • MacBookの問題を直したところ、デスクトップPC版は競合状態のせいで実行できなくなった
    • 動的サンプリングレートで賢く解決しようとする試みを諦め、オーディオがエミュレータを駆動するように変えたところ、複数のデバイスでオーディオがずっと安定した
    • オーディオはエミュレータとフロントエンドのインターフェースでもっとも漏れやすい部分だが、不協和音を避けるには正確な同期が必要だ

エミュレータの駆動方式

  • オーディオ駆動とフレーム駆動の違いは、人間の知覚と関係している
  • オーディオ信号が途切れると、スピーカーが信号の急激な変化によって大きく動き、ポップノイズが発生する
  • 映像が途切れると、データが間に合わずビデオプレーヤーがフレームを1つ2つ飛ばすが、物理的なものを押し出すわけではないため、感覚的にはそれほど気にならない
  • Fame Boyの内部では、オーディオとビデオは設計上完全に同期している
  • しかし実行中のコンピュータのオーディオとビデオは独立しており、どちらか一方が時々遅れることがある
  • フロントエンドのオーディオとビデオがずれた場合、選択肢は2つある
    • フロントエンドのオーディオとエミュレータのオーディオを同期し、ときどきフレームをドロップする
    • フロントエンドのビデオとエミュレータのフレームを同期し、ときどきオーディオをドロップする
  • 選んだ側がエミュレータを「駆動」し、もう一方はできるだけ近く保つ
  • フレームレートベースの駆動は比較的単純だ
let mutable cycles = 0

while (runEmulator) do
    cycles <- cycles + targetCyclesPerMs * lastFrameTime

    while cycles > 0 do
        let cyclesTaken = stepEmulator ()
        cycles <- cycles - cyclesTaken

    draw ppu.framebuffer
  • サウンドベースの駆動は、Raylib と Web Audio でオーディオ処理の仕組みが異なるため、より厄介だ
  • 一般的な流れは次のとおり
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
  • 核心的な違いは、stepEmulator がもはや lastFrameTime で制御されず、フロントエンドのオーディオバッファの必要に応じて駆動される点だ
  • samplesNeeded は、異なるサンプリングレートに合わせつつ60FPSを作れるよう、stepEmulator の呼び出し回数を計算しなければならない
  • フロントエンドのオーディオバッファは自分を満たすことしか気にしないため、フレームごとに stepEmulator を多く呼びすぎたり少なく呼びすぎたりすることがあり、その結果 framebuffer が適時に更新されないことがある
  • Webフロントエンドでは、URLに ?frame-driven を追加するとフレーム駆動版を試せる
  • フレーム駆動版は視覚的にはより滑らかだが、ときどきオーディオのポップが発生する
  • オーディオ駆動のWebフロントエンドも、ミュートボタンが押されるとポップが聞こえなくなるため、フレーム駆動に切り替わる
  • 実装は完璧ではないが、オーディオのポップはフレーム落ちより悪い印象を与え、ミュート状態は物足りなく感じられたため、Webフロントエンドのデフォルトをオーディオ駆動にした
  • オーディオはFame Boyで満足できていない数少ない領域であり、いつかまた手を入れたい部分だ

FableでWebに載せる

  • PPUがある程度動作し、デスクトップ画面に何かが表示され始めたあと、Fame BoyをWebへ移植しようとした
  • Fable のドキュメントを見てパッケージをインストールし、メインループを設定してスタイルを追加し、1〜2時間で実行準備を終えた
  • 最初に動かしたFable版では画面表示がおかしく、少しデバッグしたあと、時間をかけすぎないために Blazor の WebAssembly を試した
  • Blazorも起動自体は簡単で、今回は実際に動作したが、約8FPS程度しか出ず、ほとんどプレイ不能だった
  • これがBlazor自体の問題なのかははっきりせず、.NETチームの性能ガイドも試したが効果はなかった
  • デバッグのしづらさもあり、再びFableに戻って、JavaScriptへの変換過程で何が問題だったのかを確認した
  • Fableは変換後のJSファイルをソースコードのすぐ横に置いてくれ、実際かなり読みやすかった
  • そのおかげで新しいコードを理解しやすく、ブラウザの開発者ツールでのデバッグもしやすかった
  • 開発者ツールでCPUレジスタの値がおかしいことに気づいた
    • Fame BoyとGame BoyのCPUレジスタは8ビット符号なし整数なので、範囲は0〜255であるべき
    • ところが -15565461 のような値が見えていた
  • Fableのドキュメントで numeric types 互換性ドキュメント を見つけた

(non-standard) Bitwise operations for 16 bit and 8 bit integers use the underlying JavaScript 32 bit bitwise semantics. Results are not truncated as expected, and shift operands are not masked to fit the data type.

  • 16ビットおよび8ビット整数のビット演算がJavaScriptの32ビットのビット演算セマンティクスを使い、結果が期待どおりに切り詰められないという説明と一致していた
  • コード中で8ビット値が切り詰められるべき箇所を見つけて 関連する問題を修正 すると、Webフロントエンドは正しく動作するようになった
  • .NETランタイムなしでJSだけを使うため、Webバンドルは約100KBだった
  • この特殊な uint8 問題を除けば、Fableの利用体験はかなり快適で、すべてのソースコードをF#のまま維持できた

性能改善

  • 画面に結果が表示され始めたあと、簡単なFPSのコンソールログを追加した
  • 当初はデバッグモードで約55〜60FPSで、Raylibがv-syncを維持しようとしていた影響と見られた
  • v-syncを切ると約70FPSまで上がったが、ジッターが発生した
  • その後、機能が追加されるにつれて性能は徐々に低下し、45FPSに達し、v-syncを切っても効果はなかった
  • JetBrains Riderのプロファイラを実行すると、mapAddress が怪しいボトルネックとして現れた
  • ほぼすべてのコンポーネントがメモリにアクセスするため、メモリアクセスのコストが予想以上に大きいことが分かった
  • 問題のコードは、メモリアドレスを判別共用体の MemoryRegion にマッピングしてから読み書きする方式だった
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
  • CPUのドメインモデリングで得た流れをメモリにも拡張しようとした結果、すべてのメモリ読み書きのたびに MemoryRegion オブジェクトが生成され、マッピングされていた
  • この方式では毎秒数百万個のオブジェクトがヒープに割り当てられ、JITコンパイラが処理すべき分岐も増えていた
  • 判別共用体とマッピング関数を取り除き、配列へ直接アクセスするように変更した 一度の修正 でFPSは2倍になった
  • その後のベンチマークでは、性能改善の大半は分岐と局所化された呼び出し箇所に対するJIT最適化によるものと見られた
  • MemoryRegion を struct DU にしてスタック割り当てにしても性能改善は約15%にとどまり、残りの85%はDUとマッピング関数の削除によるものだった
  • その後も、struct DUへ移したり、F#らしくないアプローチを選んだ箇所がさらにあった
  • PPU実装の時点から最適化が必要になり、ある程度は慣用的なF#を諦める必要があった
  • プロファイラを定期的に見ながら性能を少しずつ改善し、約120FPSまで引き上げた
  • 最大のFPS改善はデバッグビルドを切ったことで、リリースモードでは約1000FPSまで伸びた
  • 最後まで性能を定期的に監視し、調整し続けた

ベンチマーク

  • コンソールのFPS値だけを見るのは良い性能測定方法ではないと考え、プロジェクトの途中でデスクトップ性能を測るために BenchmarkDotNetプロジェクト を追加した
  • その後、Node.jsを使う簡単な Webベンチマーカー を作り、Webブラウザでの性能も同様に見積もれるようにした
  • ベンチマークでは実用的なシナリオを試すため、次のデモROMを使用した
    • Flag: サウンドなしの短いループ
    • Roboto: 多くの視覚効果とサウンドを使う1分超の長時間実行デモ
    • Merken: Robotoに似ているが、メモリバンキングROMを使ってメモリをテストする
  • Ryzen 9 7900搭載Windows PCとM4 MacBook AirでのデスクトップFPS性能は次のとおり
CPU Flag Roboto Merken
Ryzen 9 7900 1785 1943 1422
Apple M4 1907 2508 1700
  • WebでのFPS性能は次のとおり
CPU Flag Roboto Merken
Ryzen 9 7900 646 883 892
Apple M4 779 976 972
  • Fame Boyは両プラットフォームで十分良好に動作した
  • 予想に反して、APU、つまりサウンドのほうがPPUよりもエミュレータ性能への影響が大きかった
  • PPUを無効にするとデスクトップ性能は約250FPS向上するが、APUを無効にすると約500FPS向上する

AIの使用

  • 学習プロジェクトでもAIの影響を完全に避けることはできないと考え、AIの使い方を透明に記録した
  • 全体の過程でAIは主に補助ツールとして使用した
    • コードレビューの依頼
    • アイデアを検討する対話相手
    • 簡潔な技術文書の解釈
  • AIが書いたコードはできるだけ減らそうとした
  • 人に見せて誇れる成果物を作りたかったので、プロンプトだけを共有する形ではなく、自分で作ったコードとして残そうとした
  • パフォーマンス改善PR

    • プロジェクト終盤でCLIにリポジトリを渡し、パフォーマンス改善点を探させた
    • いくつかのアイデアを出させ、そのほかに試したいこともやらせたところ、一部のベンチマークでは性能が2倍以上向上した
    • 詳細はPRにある
    • ただしバグも入り込み、自分で見つけて直さなければならなかった
    • 大きな性能改善の1つだった「mode/LY切り替え時にのみSTATを更新」は、より頻繁な更新に依存する一部のゲームやデモを壊してしまい、修正コミットで直した
  • 「タイマーの冬」

    • Git履歴には大きな空白があり、この期間を「timer winter」と呼んでいる

    • エミュレータ作業をしていなかったのではなく、Tetrisの著作権画面を突破できないバグに足止めされていた

    • 20時間以上デバッグし、emu-dev Discordを検索し、テストを作り、初期のAIモデルにも問題を投げたが解決しなかった

    • 数週間休んでからClaude Opusを試したところ、数分で問題を見つけた

    • 問題は、タイマーが命令ごとに1回しかtickせず、命令が消費したサイクル数のぶんtickしていなかったことだった

    • 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

    • CPUサイクルは1から6まで変わりうるため、従来の実装ではタイマーが平均して実際より2〜3倍遅く動作していた

    • 著作権画面は単に長く表示されていただけで、1〜2分待ってみなかったことが問題だった

    • 本文自体はほとんど自分で書いた

学んだことと結論

  • 主な目標はコンピュータがどのように動くかを学ぶことで、その目標においては大きな成功だった
  • 作業はとても楽しく、退勤後に「今日は機能を1つだけ」と始めたのに、午前2時まで「あと1つだけバグを直そう」を繰り返すほど没頭した
  • Game Boy Advanceも試してみようかと思ったが、仕様を見るとハードウェア理解の増加は20%ほどなのに対し、必要な労力は3倍くらいに見えた
  • Game Boyは学習を助けるバランスが良く、しばらくはここで止めておけそうだ
  • より良いソフトウェアエンジニアになれたかは分からないが、毎日使うツールについて少し深く理解できるようになったのは確かだ
  • 質問や意見はメールで送ることができる

1件のコメント

 
GN⁺ 2 시간 전
Hacker News のコメント
  • ここで F# を見ると嬉しくなる! エミュレータは言語を学ぶのに良い方法だし、ぱっと見では作業ごとに慣用的な F# とあまり慣用的でない F# をうまく使い分けているように思える。
    割り当てを減らす簡単な改善点としては、Instructions.fs の判別共用体に [<Struct>] を付けて、フィールド名を再利用して内部フィールドを再利用できるようにすることがある。
    細かい指摘だけど、一部のレジスタ処理は少し紛らわしい。すでに byte 型なので、setter で a &&& 0xFFuy をしても member val A = 0uy with get, set に比べて追加されるものはないように見える。おそらく開発中に変わった名残だと思う。

    • Register のソースにはこういうコメントがある。レジスタは書き込み時に値を 8 ビットに切り詰める必要があるので、レコード型にはできず setter が必要だというもの。
      これは Web レンダラのためで、Fable が JS 上で uint8Number に変換し、8 ビットを超えてしまう可能性があり、切り詰めを適用しないからだという説明。
      なので、Web 向けでは Fable の都合で JS Number に広がることを踏まえ、保守的にデータを整えるコードに見える。
    • 実際、記事の Fable への移植 の部分でこの話が触れられている。Blazor も試したそうだ。
  • ついに誰かが 何かを学ぶために実際の人間の努力 を注ぎ込んだのであって、「LLM が X を Y 分で作るのを手伝った」ではないのが良い。
    それでも人類にはまだ少し希望があるように思える。

    • そういうやり方はこれからも残るはず。2026 年になっても手道具で何かを作る人はいるのだから、これを 手工芸コーディング と呼ぼう。
    • 人類への希望なんて、ソ連が崩壊した頃にはもう捨てておくべきだったと思う。
      それでもエミュレータは本当にすばらしいし、GBA エミュレータ は自分で挑戦してみるのに良い題材だ。
    • 長年 F# 開発者をしていて、STEM の学術界での嫌がらせも長く経験してきた立場からすると、LLM は使っていない。大きな理由は、ChatGPT-3.5 が F# の GitHub リポジトリからコピペした感じをあまりにも露骨に出していたから。
      AGI らしさはまったく感じられず、飾りを剥がした剽窃マシンのように見えた。
      そのうち Microsoft の誰かが気づいて RLHF 警報を鳴らしたのだろうし、そのおかげで GPT はかなり良くなって、F# でもそこそこ使えるように見える。原則にこだわらない F# 開発者なら、最近のエージェントでうまくやれているのかもしれない。
      でも自分には、「剽窃問題を解決したから、さあガラクタを生成しよう」ではなく、「これで ChatGPT が剽窃しても、もう露骨には見えなくなるんだな」としか感じられなかった。
      生産性向上のために、自分の中核的な価値観の一つを完全に損なう確率を d100 や d1000 で振りたくはない。遅くても無職でもそのままでいるつもりだ。本気で、太陽光発電の設置や廃品回収の仕事に入ろうとしている。
      「学生が考えたがらない」という問題は LLM よりずっと昔からある。2007 年に上級の偏微分方程式の授業を取ったとき、本気で PDE を学ぼうとしていた自分が宿題をほとんど解き、気が弱くて意地悪な怠け者の数学専攻学生たちを断れず、ほぼ全員が自分の宿題を写していった。大学院の数学課程でもまた同じことがあった。本当に信じがたい。そんななら、そもそもなぜその課程にいるのか分からない。
  • ああ、F#、我が最大の愛。C# 側の人たちには、C# を何でもできるけれど中途半端な言語としてこれ以上壊し続けるのではなく、これを見てほしい。
    C# と F# を一緒に使うプロジェクトを作れば、C# に追加され続けているものを、実際にちゃんと動いて人間工学的にも良い形で手に入れられるのに、なぜそれが分からないのか不思議だ。相互運用性もすばらしい。

    • ただ、OCaml の世界から来ると、F# は C# の影に少し閉じ込められているように感じられて残念。
      F# を関数型言語らしく使ってもかなり先まで行けるけれど、結局は .NET エコシステムと相互運用したくなり、その瞬間に妙なオブジェクト指向/関数型のハイブリッドなスタイルでコーディングすることになる。
  • F# は良い言語だけれど、永遠に C# の影に閉じ込められている感じがある。ライブラリコードのかなりの部分が C# と .NET 由来で、F# を念頭に置いて設計されたインターフェイスやライブラリではないことが多く、F# 向けの使い方ドキュメントも明示的には存在しないことがよくある。

    • C# から F# へライブラリの使い方を移すのはかなり機械的な作業なので、別個のドキュメントが本当に必要かはよく分からない。
      より大きな問題は、C# コミュニティがオブジェクト指向を好むため、関数型プログラミングのやり方で作業したいなら、こうしたライブラリをもっと「関数型」な形にラップしなければならないことが多い点だ。
      それでも、何もないよりはずっと良いと思う。Haskell や OCaml も好きだけれど、その点では比較になる。
    • 両者の相互作用によってある程度ぎこちなさが出るのは確かだけれど、特定のライブラリが F# にうまくマッピングされるべきだというより、相互運用ルール と生成される内部出力の形をよく理解することのほうが大きい問題だと思う。
      C# との相互運用性は、F# コードが一般に依存している保証、とりわけ不変性を緩めてしまう。C# へマッピングされる都合で、ジェネリクスにも意外な制限が現れる。
  • 本当にすごい! F# は好きだけれど、小さな Smalltalk インタプリタ を F# で書いた経験からすると、この種の作業で意図されたやり方をすると、まさに速度の怪物というわけではないのは確かだ。

    • F# では、ばかみたいに命令的に書いても、副作用を関数の中に閉じ込めておけば性能が良くなるのを見てきた。そうすれば関数は事実上「純粋」に保ちつつ、そこそこの速度を得られる。
      たとえば普段は Map データ構造が好きで、かなり優れた不変構造だから多くの用途には十分だ。でも性能が重要になってくると、普通のハッシュマップを使った地味な命令型ループに入るのも難しくない。
      すべてを一つの関数の中に閉じ込めれば、たいていはあまりに汚く書いてしまったという感じを避けられる。
    • そのインタプリタを書いたのがいつなのか気になる。.NET エコシステム全体 はここ数年でとてつもない速度改善を経験していて、特に Framework 時代に最後に使った人なら差は大きい。
      C# コンパイラですら活用していない末尾呼び出し改善にも力を入れてきた。.NET 9 か 10 あたりでは、F# に末尾呼び出しではない再帰呼び出しがあるとコンパイラエラーにできる機能まで追加されて、うっかり壊さないようにしてくれる。
    • どの機能をいつ使うかに注意すれば、F# も非常に高速になり得る。使いたいときには関数型パラダイムを使い、必要ならホットループでは低レベルの命令型コードを書けばいい。
      ただし、連結リストやシーケンスや不変データ型をあちこちで使っていたら、Rust には決してならない。
  • すてきなプロジェクトだ! こういうのを見るのは本当にうれしい。
    一方で、これは作者や作業そのものへの評価ではないのだけれど、実際のプロジェクトで F# のコードがどう見えるのかを見た結果、F# を学んで使ってみたい気持ち はもう手放してもいいかなと感じた。
    純粋関数型の部分は美しいけれど、より命令的だったり可変なコードに降りていくと、かなり見た目が悪いと感じる。不幸なことに、実際のプロジェクトでは結局そうしなければならないことが多そうだ。
    だから、別の関数型言語を選んで飛び込むべきなのか、それとも今すでに使っている言語に関数型の概念を適用することに集中すべきなのか分からない。主言語は C# で、関数型パラダイムのサポートも増え続けているので、後者はかなりやりやすい。

  • 関数型言語で書かれたエミュレータはいつも印象的だ。ハードウェアを命令型言語に写像するほうが普通はずっと簡単だから。人々がどんな 関数型抽象化 を生み出すのかを見るのは楽しい。

    • コードを見たのか気になる。F# には可変変数や配列があるし、このプロジェクトでも例えば メモリ にそれを使っている。
  • F# は本当に楽しい言語だし、すばらしい仕事だ!

  • F# は、自分が実務では決して使えないコーディング上の恋の言語だ。個人プロジェクト以外では使う機会がない :(

  • 興味深くて楽しい記事だった。データモデリング の部分が良かった。OCaml を少し触っているところだけれど、そういうモデリングがいちばん良い部分だ。
    CAMLBOY を知れたのも面白かった。作者へのフィードバックをするとしたら、AI 編集の段階は飛ばしたほうがいいと思う。今のように少し平板な文章より、文法の誤りや洗練されていない表現があっても、そのほうが好ましかっただろう。