RustでWebAssemblyを触りたくなり、題材としてゲームボーイ(DMG)エミュレータを選んだ。CPU・PPU・APUを自作してブラウザで動かし、最終的に実際のゲームが音付きで完動するところまで到達した。本稿はその開発記である。CPUテストが一発で全通過して拍子抜けした後、本当の戦いが「音」で始まるとは、このときは知らなかった。
構成: 依存ゼロのコアと3層のワークスペース
構成はCargoワークスペースで3層に分けた。
core/— エミュレータ本体。SM83 CPU、PPU、APU、タイマー、MBC1/3/5。依存クレートゼロの純Rustwasm/— 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の成分がべったり乗っていた。実機ではスピーカーと出力回路がこの帯域を物理的に落とすので聞こえない。ここでも「実機の回路に対応物を作る」が答えになる。
- ボックスカー積分 — サンプルを瞬時値ではなく区間平均にして、間引き前に帯域制限する
- 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


コメント