Rustでゲームボーイエミュレータを自作した話
ここ2ヶ月ぐらいゲームボーイエミュレータを自作してました。結果として「物量も少なくエミュレータ自作入門にオススメ」と思ったので、時系列で完成までの流れをまとめつつ、エミュレータ開発は楽しいぞということ伝えたいと思います。
全体像を把握する
ゲームボーイのエミュレータを自作した話 · Keichi Takahashi https://t.co/fwJgtohqxT
— mjhd 🐈 (@wait00002) 2021年2月7日
ゲームボーイエミュレータをRustで自作する記事が目に止まり、ちょうどRustの入門題材を探していたので作ることに。
記事で紹介されている動画をみるだけで全体像が把握でき、なんとなく作れそうな雰囲気を感じられます。↓
まずはROMをデコード
今日はROMのデコードまで pic.twitter.com/lx6NZ9ABvS
— mjhd 🐈 (@wait00002) 2021年2月14日
何はともあれ、ROMをメモリに読み込めないことには実行もできないので、 PanDocs: The Cardridge Header を参考に、struct 定義と enum 定義をひたすら写経します。
ROMヘッダの取り込みとチェックサムの検証を実装したらデコード処理は完了なので、あとはMBC(Memory Bank Controller)というカードリッジの内部に埋め込まれているチップを模したプログラムを書きます。
カードリッジタイプのゲームは内部にチップが入っており、特定のアドレスに書き込みを行うことでバンクと言われるデータ領域を切り替えて、大容量なデータを限られたメモリマップに展開します。
この処理を再現するわけですが、まずは一番簡単な RomOnly というMBCを書きました。単純に先程読み込んだROMの配列にアクセスするだけなので5秒ぐらいで書けます楽ちん。
CPUの実装
ゲームボーイエミュレータ、おおよその命令を実装したぞ…タレカツ
— mjhd 🐈 (@wait00002) 2021年3月7日
割り込みとかサイクル数とか特殊な命令の実装はまだ
/ impl cpu instrs · mj-hd/gb@c8b0a0c https://t.co/M7vBXhoMkO
ここがエミュレータ自作の本番です。
CPUの命令サイクルと呼ばれる、
1. フェッチ(PC番地のデータをとってくる)
2. 命令デコード(とってきたデータで処理を分岐する)
3. 命令実行
4. フラグ反映(Z, N, H, Cの4つのフラグを更新する)
5. PC更新(PC += 1)
を行ってくループ文を書きます。
次に Gameboy CPU Manual (PDF) にCPUの命令一覧と処理内容、影響を受けるフラグの一覧が載ってるので、これをひたすらに実装していきます。
だいたい 500 命令あるらしいですが、対象となるレジスタが違うだけで共通の物が多いです。
1. ロード命令(読み込み、書き込み)
2. ジャンプ命令(PCを書き換える系)
3. 加算減算、ビット演算
4. ビットシフト・ローテート
5. スタック操作
6. 特殊系
ぐらいで大まかにまとめられます。
ここで踏んだ罠:
- CBプレフィックス命令に気をつける(0xCB を見つけたら次の 1byte もデコードする)
- `ADD HL, rr` だけハーフキャリーの計算が違う(11bit が対象)
- `ADD SP, n` は16bit命令でも8bitだと思ってキャリー、ハーフキャリーの計算をする
- 16bit 命令は 0x0X ~ 0x3X 系と 0xCX ~ 0xFX 系で、レジスタの規則が違う(0x0X ~ 0x3X は BC, DE, HL, SP、0xCX ~ 0xFX は BC, DE, HL, AF)
PPUの実装(BGだけ)
BGは表示できた!タイル番号が符号付き整数だったとは pic.twitter.com/9Y3MBlKmkK
— mjhd 🐈 (@wait00002) 2021年3月9日
ひたすら黒い画面と睨めっこしてCPUがある程度形になったら、モチベを保つためにも画像処理を書きます。Hello World ROMが動き、画面が表示されることが目標です。
ここで実装するのは PPU(Picture Processing Unit)で、今で言うところのGPUでしょうか。
スプライト、ウィンドウ、スクロール…などPPUには色々な機能がありますが、一旦今必要なのはBGだけなので実装していきます。
GBEDG: The PPU にPPUの挙動が細かく記載されているので読みつつ、特にパレット、カラー、タイル、マップの関係性を紙などに図示すると理解しやすいです。
正常に実装できれば、Github - dusterherz/gb-hello-world に置いてあるROMを今まで書いた処理に食わせれば「Hello World」という文字が表示されるはずです。
ここで踏んだ罠:
- タイルのアドレス計算には二種類のモードがあり、それぞれ「符合なしでベースアドレスは 0x8000」、「符合ありでベースアドレスは 0x9000」
デバッガの実装
ステップ実行できるようになった pic.twitter.com/uRIwcuzeKM
— mjhd 🐈 (@wait00002) 2021年3月20日
ここがキモです。この後の戦いに必須です。以下の機能があると便利です:
- ブレークポイント(指定のPCに到達したら、実行中断してステップ実行)
- トレース(CPUの状態とニーモニックのログを出力できる機能。別のエミュレータと出力フォーマットを合わせておくと diff とってデバッグできる)
- メモリダンプ(特定範囲のメモリを出力する機能)
CPUテストROMの実行
1番テスト通った pic.twitter.com/OOxRn8qXJM
— mjhd 🐈 (@wait00002) 2021年4月10日
ラスボス戦です。辛かったです、地獄かな。
https://github.com/retrio/gb-test-roms に「cpu_instrs」というCPUの各種命令の実行結果をテストしてくれるROMがあります。エラーが起こると、「#1 failed」とどこでテストが落ちたのか教えてくれる便利ROMです。
前回まででBGは実装したので、テスト結果も画面表示で確認できますね。
これがだいたい通ると、急に市販のROMも動くようになる大事な局面なのですが…まあとにかくデバッグがしんどい。
テストROMが複雑で分かりづらいことが原因と思われるので、デバッグで大事なポイントを書いておきます。
1. MBC1を実装する
テストROMは MBC1 です。https://gbdev.gg8.se/wiki/articles/MBC1 を参考にMBC1を実装しましょう。
書き込み仕様で 0x0000-0x1FFF など範囲指定されている部分は、「指定範囲のどこに書き込んでも動作は一緒」です。
2. 信頼できる既成のエミュレータを一つ見つける
ブレークポイント、ステップ実行、トレース、メモリダンプなど一通りの機能が揃っていて、かつ安定しているエミュレータを見つけます。
macOSだと mGBA と言うエミュレータが安定していて良かったです。
ここで自作したトレース機能の出力フォーマットが統一されていると、テキスト diff ツールを使ってside-by-side で動作を検証できるので、抜群にデバッグが楽です。
3. テストROMを逆アセンブルする
テストROMにもソースコードがもちろんあるのですが、マクロを多用していたり一度命令をメモリ上にコピーしていたりして追うのが大変なので、一度逆アセンブルすると吉です。
今回は GitHub - mattcurrie/mgbdis を使用しました。
基本的に 0x4XXX に存在するニーモニックは、実行時に 0xCXXX へコピーされて実行されます。なので、確認したいニーモニックのROM上の位置が 0x41B9 であれば、0xC1B9 へブレークポイントを張れば確認できます。
残り部分の実装
スプライト実装した。色周りバグっててゲームプレイに苦戦するw pic.twitter.com/EwSPQ8pduT
— mjhd 🐈 (@wait00002) 2021年4月12日
スクロールとウィンドウもできた。カービィが動いた…!細かなバグを除けば割とゴールしたかも… pic.twitter.com/D6oFSHAnRX
— mjhd 🐈 (@wait00002) 2021年4月13日
もうここからはずっと楽しい時間です。市販のROMも動き、見た目周りの実装を充実させたり、タイマー、ジョイパッドなどゲームプレイに必要なものを実装していきます。
完成!
RomOnly な Dr. MARIO やテトリス、MBC1 の星のカービィ、マリオランドなどが動くようになります。めでたしめでたし。
ROM吸い出し(番外編)
一旦RomOnlyだけど、CUBIC STYLEさんのラズパイGBA拡張ボードでGBのROM吸い出しできた!
— mjhd 🐈 (@wait00002) 2021年4月1日
- ROMヘッダ定義・検証の修正
- GB用に、アドレスとデータのピン番号を変更。合わせて、INPUT/OUTPUTの設定も適切に変更
- データ読み出し周りの処理を1byteづつに書き換えてく
ぐらいでいけた pic.twitter.com/cRRstJJRKI
やったー、バンク切り替えの必要なMBC1も読めたぞ! pic.twitter.com/Fpq4V7TbIp
— mjhd 🐈 (@wait00002) 2021年4月4日
エミュレータ開発は実際のROMデータがあるとモチベが保てます。カードリッジ自体はブックオフなどで300円ぐらいで買えますが、ROM吸い出し機はアマゾンなどで6000円ぐらい。ちょっと高い。
なので今回は CUBIC STYLE さんが同人ハードとして開発している、「Raspberry PiにつないでGBAのROM読み書きできる拡張ボード」を少しいじり、GBのROMを吸い出しました。
※ 公式にGBへ対応しているわけではないので、自己責任です
読み出しプログラムをいじるだけでGB対応ができちゃいました。
GBAとGBでカードリッジ端子の互換性はあるのですが、ピンアサインが違います。(あと電圧も 3.3V と 5V で違うけど、一応そのまま動く)
GBのピンアサインは Arduinoを使ったgameboyカードリッジのdump が分かりやすく、読んだ結果アドレスが16ピン、データが8ピンで、RDをLowにしたままアドレスを書き込むと、データが流れてくると判明しました。
なので、ROMヘッダを読んで RomOnlyであればただメモリ番地をインクリメントしながらデータを読んでいくだけ、MBC1であればバンク番号をインクリメントしながらROM Sizeまで読み出していくだけですね。
追記: ROM読み出しツールをRustで書きました。興味のある方はつかってみてください。PRも歓迎しています。
今後
今後の仕事としては、実際のカードリッジから直接ゲームを起動する実験をしてみようかと思っています。
ROM吸い出しでカードリッジの低レベルな知識もついたので、MBCの実装を拡張ボードのGPIOとSPIに接続するプログラムを書きました。
(ARM 32bitへのクロスコンパイルが辛く、動かせていないです…動作速度など気になる)
これができればMBCの実装も必要なく、いろんなカードリッジが動かせますね!
エミュレータとしては、今まで 4bit(習作), 8bit(NES), 8bit(GB) と書いてきたので…次は 16bit の何かでしょうか…?GBが楽しかったのでまた何か書きたい…