RustとWASMでゲームボーイエミュレータを作る — テスト全通過から「実機の音」になるまで

AI

RustでWebAssemblyを触りたくなり、題材としてゲームボーイ(DMG)エミュレータを選んだ。CPU・PPU・APUを自作してブラウザで動かし、最終的に実際のゲームが音付きで完動するところまで到達した。本稿はその開発記である。CPUテストが一発で全通過して拍子抜けした後、本当の戦いが「音」で始まるとは、このときは知らなかった。

構成: 依存ゼロのコアと3層のワークスペース

構成はCargoワークスペースで3層に分けた。

  • core/ — エミュレータ本体。SM83 CPU、PPU、APU、タイマー、MBC1/3/5。依存クレートゼロの純Rust
  • wasm/ — wasm-bindgenの薄いラッパー
  • web/ — canvas描画とAudioWorklet再生のフロントエンド

コアを純Rustに保ったのは、ネイティブのcargo testでテストROMを高速に回すためだ。この判断は後で効いてくる。エミュレータの検証はBlargg氏のテストROM群が業界標準で、CPU命令の網羅テスト(cpu_instrs)は実行結果をシリアルポートに出力する。つまりシリアル出力をフックすれば、画面なしでCIのようにテストできる。

// テストROMの合否はシリアル出力を見るだけ
for _ in 0..2000 {
    gb.run_frame();
    if gb.serial_output().contains("Passed") { return; }
}

CPUはオペコード256個を丸暗記の256行で書くのではなく、ビットパターンで畳んだ。SM83のオペコードは 01_ddd_sss のようにフィールドが規則的で、LD命令64種はこれだけになる。

0x40..=0x7F => {
    let v = self.read_r(bus, op & 7);        // ソース番号 → 値
    self.write_r(bus, (op >> 3) & 7, v);     // 宛先番号 ← 値
}

Rustの整数範囲パターンは網羅性をコンパイラが検査するので、腕が1本欠けるとコンパイルが通らない。エミュレータのような「抜けが致命傷」の分野でこれはありがたい。

結果、Blarggのcpu_instrs全11本とinstr_timing(命令サイクル数の検査)が一発で全通過した。正直、ここが山場だと思っていたので拍子抜けした。

偽バグ: 画面の下からテキストが出てくる

最初の「謎解き」はPPUだった。ブラウザでテストROMを動かすと、テキストが画面の下端に断片的に表示される。上から表示されるはずでは? PPUのスクロール実装を疑い、レジスタをダンプすると SCY=136 という中途半端な値が出てきた。

ここでテストROM付属のアセンブリソースを読んだのが正解だった。Blarggのコンソールは:

console_apply_scroll_:
     ld   a,(console_scroll)
     sub  136
     sta  SCY

つまり SCY = scroll - 136 という端末式スクロール(新しい行が下から出て、古い行が上に流れる)を実装していた。リファレンス画像とも一致し、我がPPUは無罪。「バグに見える挙動」の正体が仕様だったというオチである。エミュレータ開発では「実機が正しい」以外の基準がないので、一次資料(この場合はテストROMのソース)に当たる癖が大事だ。

APUとDCオフセット: 実機には「コンデンサ」がある

音源(APU)は矩形波2ch・波形メモリch・ノイズchの構成で、512Hzのフレームシーケンサが音長・エンベロープ・スイープを駆動する。実装後、無音のはずの場面で出力にRMS 0.02の定常成分が乗っていることに気づいた。

原因はゲームボーイのDAC仕様にある。DACが有効で無音のチャンネルは、デジタル値0がアナログ-1.0にマップされるため、DCオフセットを出し続ける。実機ではアナログ段のACカップリング(コンデンサ)がこれを除去している。エミュレータでも同じことをすればよい。

// 1次ハイパス (fc≈20Hz) = 実機のACカップリング相当
let hl = self.hp_alpha * (self.hp_l.1 + l - self.hp_l.0);

「実機の回路図に対応物がある処理だけを足す」という方針は、この後もずっと道標になった。

「Busが便利すぎる」問題とm-cycle精度化

ここで設計に対する疑問が湧いた。うちのBusは、アドレスのディスパッチも、周辺デバイスへの時間配給も、全部やってくれる。便利すぎないか? 実機にこんな親切な部品はいない。

調べると、これはエミュレータの定石(catch-up方式)だった。CPUが1命令実行して消費サイクル数を返し、その分だけ周辺デバイスを追いつかせる。並列に動くハードウェアを逐次プログラムに翻訳した「折り目」がBusに集まっているのだ。ただしこの方式には限界があって、「命令の何サイクル目でメモリアクセスが起きたか」は再現できない。Blarggのmem_timingはまさにそこを検査するテストで、原理的に通らない。

そこで思い切ってCPUを書き換えた。命令ごとのサイクル表を廃止し、メモリアクセス1回(1 m-cycle = 4tサイクル)ごとにその場で時間を流す。

fn rd(&mut self, bus: &mut Bus, addr: u16) -> u8 {
    bus.tick(4);      // このアクセスの分だけ、全デバイスの時間が進む
    bus.read(addr)
}

こうするとLD B,C(4サイクル)とLD B,(HL)(8サイクル)の差をどこにも書いていないのに正しくなる。サイクル数が「表で合わせた値」から「構造から導出される値」に変わるからだ。書き換え後、mem_timing/mem_timing-2全6本が一発で通った。合わせてPPU描画中のVRAMロックやOAM DMAのバス専有など、実機のバス競合も入れた。

ゲーム投入。そして音が「キーキー」いう

集大成として実際のゲームを動かした。動く。遊べる。しかし音楽がキーキーいう。

ここからが本稿最大の謎解きである。まず容疑者を並べた。(1)音源エンジンを走らせるCPU側、(2)APUの合成、(3)ブラウザの再生経路。切り分けの武器はAPUレジスタの時系列ダンプだ。

f 240: ch1[freq=1750 vol=14] ch2[freq=1751 vol=10] ...

freq=1750 は 131072/(2048-1750) = 440Hz、つまりA4(ラ)。音源エンジンは完璧に正しい値を書いている。CPU無罪。次にネイティブで60秒間の音声をスキャンしたが、波形は終始健全。APUも無罪。ブラウザ上のWASM出力もネイティブとバイト一致。残るは再生経路だけだ。

AudioWorkletにアンダーラン(バッファ枯渇)カウンタを仕込むと、犯人が映った。先行バッファがほぼゼロのまま綱渡り再生していたのだ。音声の生産(フレーム単位のバースト)と消費(2.7ms刻みの連続)がちょうど釣り合うと、在庫が育たない。数msのジッタが入るたびに無音ギャップが挟まり、高頻度のプチプチが「キーキー」に聞こえる。対策はストリーミング再生の定石で、60ms分溜まるまで再生を始めないプライミングを入れた。

二人目の犯人: エイリアシング

直った、と思ったら「メロディの部分でまだキーキーいう」との報告。この「メロディの部分で」が決定的なヒントだった。音程に追従する高音の偽音 — それは折り返し雑音(エイリアシング)の症状だ。

矩形波は奇数次倍音が無限に伸びる。瞬時値をそのまま採る点サンプリングでは、ナイキスト周波数(24kHz)を超えた倍音が可聴域に折り返す。折り返した成分はメロディと一緒に動くから「メロディでキーキー」になる。スペクトル解析すると12〜24kHz帯に-28〜-37dB relの成分がべったり乗っていた。実機ではスピーカーと出力回路がこの帯域を物理的に落とすので聞こえない。ここでも「実機の回路に対応物を作る」が答えになる。

  1. ボックスカー積分 — サンプルを瞬時値ではなく区間平均にして、間引き前に帯域制限する
  2. 2次ローパス(fc≈10kHz) — 実機の出力経路とスピーカー特性の模倣

効果はスペクトルで実測した。

帯域 対策前 対策後 改善
0-2kHz (音楽) 0.0 dB 0.0 dB ±0
4-6kHz -20.3 -21.5 -1.2 dB
16-18kHz -33.4 -44.2 -10.8 dB
22-24kHz -35.8 -49.9 -14.1 dB

音楽帯域はほぼ無傷のまま、キーキー帯域だけを10dB以上沈めた。これで「完璧に治った」の一言をもらえた。

まとめ

  • Rustの範囲パターンと借用は、エミュレータの「網羅性」と「時間の流れの明示」に驚くほど噛み合う
  • テストROM(Blargg)をネイティブcargo testに組み込むと開発が一気に安定する。現在cpu_instrs/instr_timing/mem_timing系の全18本+ユニットテストが常時グリーン
  • バグの半分は「バグではない」。SCYの端末スクロールもDCオフセットも実機仕様だった。一次資料と実機の回路を基準にすること
  • 音の品質問題は波形とスペクトルを「見る」と一瞬で正体が割れる。耳のデバッグは計測のデバッグである

未実装はCGB対応、PPUのFIFO描画、APUのDIV連動などまだ山ほどある。コードはGitHubで公開している: https://github.com/yoshiharu-ishii/rustboy

コメント

タイトルとURLをコピーしました