🎮

Rustでゲームボーイアドバンスエミュレーターを書いた

2022/07/07に公開

以前のゲームボーイエミュレーターに続いて、Rustでゲームボーイアドバンスエミュレーターを書きました。

http://github.com/tanakh/tgba

一通りちゃんと実装したので、大抵はちゃんと動いてくれるはずです。動かなかったり、画面がおかしかったり、音がおかしかったりしたらバグなので、ご報告いただけるとうれしいです。

エミュレーションコアが2つになったので、せっかくなのでエミュレーションコアのインターフェースを抽象化して、マルチエミュレーターにしました。

http://github.com/tanakh/meru

リリースページ からコンパイル済みバイナリをダウンロードできます。

マルチエミュレーター

エミュレーターを作るうえで割と面倒でバカにならないのがフロントエンドUIの作成です。GUIアプリを作るのはそもそも結構大変で、ましてや昨今のアプリケーションはPCだけでなくスマホやWebといったタイプの異なる複数のプラットフォームで動かしたいというモチベーションがあります。特定プラットフォームにべったりで何となく動くように書けばいいやというのと比べると、取れる選択肢も限られてかなり面倒になってきます。そのあたりは前回の記事のGUIにまつわる話の下りをみていただくと参考になるかもしれません。

そういった面倒をエミュレーター作成のたびに行うのも大変だし、ゲーム機っていうのはわりと外から見ると似通っているんですね。画面があって、コントローラーがあって、音が出る。なので、比較的インターフェースとしての抽象化はやりやすいのではないかと思います。

既存のエミュレーターでそういった抽象化が行われているものとして libretro なんかがあります。libretroおよびそのフロントエンドであるところの RetroArch は非常に充実したソフトウェアで、libretroのフロンエンドも色々なものが作られているので、私のエミュレーターもそこに乗っかるという選択肢もありますが、自分でコントロールできるシンプルなものを持っておくのも悪くないかと思い自作することにしました。いずれはlibretroに対応させたバックエンドのラッパーを作るかもしれません。

というわけで、シンプルなインターフェースを実装すれば、GUIメニューからクイックセーブ・ロード、巻き戻し、セーブデータ管理、キーコンフィグなどが勝手に対応されるようなものが出来ました。別のコアを書く機会があればだいぶ楽になりそうです。

ゲームボーイアドバンスのエミュレーション

ここからはせっかくなので読み物として、ゲームボーイアドバンスがどういったハードなのか、エミュレーションを行う上で感じたことなどを書いていこうと思います。

ゲームボーイアドバンスは、ゲームボーイと名はついていますが、音源の一部を除いてCPUからグラフィックの回路まで全くの別物です。ゲームボーイはいわゆる古のアーキテクチャで、現代のコンピューターからはかけ離れたものになっていますが、ゲームボーイアドバンスともなると21世紀のゲーム機なので、もうずいぶん現代のコンピューターに近づい来ています。

具体的なハードの詳細については https://www.problemkaputt.de/gbatek.htm がよくまとまっています。これを一通り読めばそれなりに動作するものは作れると思います。公式のプログラミングマニュアルがベースになっているのか、コーナーケースについてはあんまり書かれておらず、例えばDMAでは転送するワードサイズでアライメントされていないアドレスを渡してはいけないだとか、無効なメモリ領域、たとえばBIOS以外の領域からBIOSにはアクセスできないだとか、そういうのがあるのですが、実際には事故か故意にか、そういうことをやっているソフトも結構あるので、その場合どういう挙動になるのかは何とかして確かめなければなりません。実際作るとそのあたりの知見も結構溜まったりしましたが、そこの話についてはまた別の機会に。

CPU

CPUはARM7TDMI-Sが採用されており、ISAは32ビットARMのARMv4です。ARMのISAはここからv5、v6、v7、そして今も使われている64ビットのARMv8と続いているので、古いと言えば古いですが、32ビットのARMとして一通りの完成を見ていたと言えるCPUです。動作周波数は16.8MHzで、ゲームボーイが4.19MHz、ゲームボーイカラーが8.39MHzなので、クロックだけ見るとゲームボーイから2~4倍にしかなっていないようにも見えますが、ゲームボーイのCPUは全命令4の倍数のクロックがかかるので(バスのウェイトが3に設定されているのかな?)1バイトのシンプルな命令でも最低4クロックかかるのとか、レジスタが8ビットなので大きな数を扱う演算では命令数がかさむのとか(一部の命令は2つのレジスタを組み合わせて16ビットの演算が可能なものありますが)、バスも8ビットなのでメモリとのやり取りが入るコードも時間がかかるなどがあり、数字ほどは速くない印象です。一方のARMはパイプライン化されており、ウェイト0で読める高速メモリか、あるいはプリフェッチされたROMのコードを実行するなら、多くの命令がスループット1で実行できるので、実際の性能は大幅に高くなっていると思われます。

とはいえ現代のCPUから見るとクロックは2桁も低いので、エミュレーションする分にはナイーブなインタープリタでまだ充分です。普通に考えて、ARMの命令1つを解釈するのにx86で数百クロックも必要ありません。ただ、ARMはRISCというには余りにも複雑な命令になっていて、例えば、全ての命令に実行条件をセットできる条件フィールドが存在していたり、演算命令のオペランドにやたら複雑なものが設定できたり(例えばレジスタをレジスタでシフトするといったもの)、演算命令がその結果にしたがってCPUのフラグをセットするCISC然としたものだったり、果てはレジスタの任意の集合を1命令でメモリの連続した領域に読み書きするといったとんでもないものまであります。しかもそれらのほとんど命令が挙動を微妙に変えられるフラグをたくさん持っていて、ナイーブにエミュレーションすると割とバカにならないコストがかかります。

分岐ディレイスロットやロードディレイスロットといった、古のRISCでは比較的多く採用されていた、現代ではスケーラビリティーに問題があるとされているものはARMにはありません。このことはARMが長きにわたって拡張され続けて発展していった要因の一つかもしれません。エミュレーター的にはこれらがあることでソフトウェア的に難しくなってしまう割り込み時の処理などは素直に実装できます。しかし、その代わりにPCが汎用レジスタの一つとして扱われているというとんでもないものがあります。

PCがあらゆるの命令のオペランドとして指定できるのは便利なこともあるかもしれませんが、エミュレーターを書いて思うのは圧倒的に面倒なことの方が多いということです。せっかく遅延スロットがなくなっているのに、命令からはオペランドのPCを通してパイプラインの進行状況が見えてしまうのです。これはどういうことかというと、通常の命令だとPCオペランドは、その命令+8(16ビット命令のthumbモードなら+4)の値が入っています。その命令のレジスタファイルアクセスまでに後続2つの命令がパイプラインでフェッチされているということですね。ところが、命令のオペランドが複雑な場合、レジスタから読まれるのが1クロック遅れて+12が入っている場合があったります。タイミングチャートを見てPCが各命令でどのタイミングで読まれるのか細心の注意を払う必要があるので単純に面倒です。特定の命令の特定のオペランドにはPCを指定することはできない、などと仕様書に書いてある命令も多くあり、しかしその場合も無効命令にならなかったりするので、実際に実行するとどうなってしまうのかは仕様書には書いてないのでよくわからなかったりします。そういうことをやっているコードも当然あったりするので、実際の挙動の確認が必要になりますが、既にそういう挙動のテストを行うバイナリがいくつか世に存在したので、そういったものをありがたく使わせてもらいました。いずれにせよPCが汎用レジスタになっているのはとんでもなく厄介な仕様です。PCの値を簡単に取得できることは、例えばPC相対でのメモリアクセスで位置独立なコードを書いたりするには大変有用なので、現代のISA、例えばx86-64だったり、RISC-Vだったりではそういったものに限定してPCの値を取得できるようになっています。本当に素晴らしいプラクティスだと思います。

ARMは命令のエンコードも複雑怪奇で、例えばADDとかSUBとかのカテゴリにCMPがあったりするのですが、演算命令にはそれぞれ結果によってフラグをセットするかしないかを制御するフラグがあります。これによってフラグをセットしないCMP命令という組み合わせもできてしまうのですが、しかしそんな命令には何の意味もないので、じゃあそういうビットパターンは無効命令にしよう、っていう話にはならなってなくて、その場合は全く別の命令を割り当てようというのがARM流になっています。しかもその割り当てる別の命令が全く別のエンコードフォーマットになっていたりして、注意深く慎重に見ないと本当に命令空間がオーバーラップしていないのかわからないぐらいです。そんなものがごまんとあるので、これがAdvanced RISCなのかという感じですが、ともかく、デコードの段階でかなり面倒くさく、個々の命令も重量級で、一通りエミュレーションするコードを格だけでも結構骨が折れる作業でした。

メモリ

CPUが32ビットになったことに伴って、アドレス空間も16ビットから32ビットの広大な空間になりました。ROMもバンク切り替えなしに32MBの領域にリニアにマップされています。これだけですごく近代的になった印象を受けます。バスも32ビットになり、一度のアクセスで32ビットのデータを読み書きできます。コンポーネントによっては16ビットや8ビットでつながっているものもあり、そこは基本的にはバスのデータサイズビットにより制御されて、CPUが自動的に複数回ロードストアしたりの制御を行うようです。難しいのは、データサイズビットをコンポーネントによっては無視するものがあるらしく、例えば16ビットのIOレジスタに対して8ビットアクセスを行うとどうなるかなどはゲームボーイアドバンスの個々のレジスタの実装によるはずですが、Webに転がってる資料では足りていない部分も多く、完璧に再現できているか自信のない所があります。

RAMはウェイトサイクル0でアクセスできる高速メモリが32KB、ウェイトサイクル2でアクセスできる低速メモリが256KBのトータル288KBで、ゲームボーイの8KBから実に36倍の容量になっています。VRAMは96KBで、こちらはゲームボーイの8KBから12倍です。メモリがホイホイと数十倍になる時代だったんですね。

グラフィック

グラフィックもゲームボーイ・ゲームボーイカラーから大幅に強化された部分です。ゲームボーイは4色のタイルマップをスクロールして表示できるBG面が1枚と8x8 or 8x16のスプライトが40個(同一ラインには10個まで)が表示できるだけのものでした。それがBGが4枚になり、スプライトは128個になり、さらにこれらに様々なエフェクトを掛けられるようになりました。

スーパーファミコンに近いと言われたりもしていますが、さすがにだいぶ後に出ているだけあってゲームボーイアドバンスが全面的に高性能になっています。タイルマップのBG面が4枚あるのは同じですが、全てで16色が使えたり、BG面が2枚に減りはしますが256色パレットが使えたりします。またスーパーファミコンでは拡大縮小回転ができるのは1枚だけですが、ゲームボーイアドバンスでは2枚回転できます。16ビットカラーのフレームバッファを直接表示することもできるので、完全にソフトウェアでレンダリングするようなコードも書きやすくなっています。スプライトは128個あって、最大64x64のサイズでこれら全てを拡大縮小回転できます(ただし、同一ラインにフルに128個表示できるのは横幅8で回転なしの時だけですが)。これらを好きなプライオリティーで表示できて、さらにモザイク、クリッピング、アルファブレンドの効果を掛けられます。2D専用機としてはかなりの高性能になっています。

これもナイーブにエミュレーションしようとすると結構重たい処理になります。とはいえ、解像度が240x160しかないので、ナイーブに書いても現代のCPUなら60FPSに間に合うぐらいの速度にはなりました。古いマシンやwasmにしても間に合うようにするには多少は速度を意識したコードにしなければならないかもしれません。

サウンド

サウンドはゲームボーイから引き継がれたほとんど唯一の部分で、ゲームボーイとほぼ同じの矩形波x2、波形メモリ、ノイズの4チャンネルに加えて、2チャンネルのPCMストリーミングが追加されました。ストリーミングができるので、理屈としてはどんな音楽も鳴らせるはずですが、実際のゲームでは結構ガビガビなのは皆さんご存知の通りです。量子化ビット数が8ビットなのと、サンプリングレートが(実用的には)最大32KHzなのが一つの理由でしょう。CPUで音の波形を生成しなければならないという都合上、現実的には15KHzから20KHzぐらいで再生しているゲームが多いようでした。音楽をクリアにしようとしたり、エフェクトを掛けようとしたりすると音楽にCPUリソースを割かなければならないし、他の部分にリソースを割けば音楽がガビガビになるし・・・というトレードオフが、ゲームボーイアドバンスの「らしさ」の一つでもあったように思います。

なんでPCMチャンネルがあるのにゲームボーイ互換のピコピコ音源を載せてるんだ?というのも、CPUリソースの消費なしに4チャンネルも別の音を鳴らせると考えると、これもトレードオフとして貢献しているんだなあと理解できます。

なお、ゲームボーイアドバンス追加分のエミュレーションに関しては、PCMをそのまま出力するだけなので簡単でした。

ゲームカートリッジ

ゲームカートリッジのROM部分に関しては、メモリ空間にリニアに展開されるようになったので、バンク切り替えのためのカスタムチップなどがなくなり、ずいぶん簡単になりました(とはいえゲームボーイは任天堂が提供するいくつかの標準のメモリコントローラー以外を採用しているものが少なく、数多のチップが存在したファミコンやスーパーファミコンよりはずいぶん楽ですが)。

バックアップメモリに関しては面白い部分です。大きく分けて4つあって、1つは従来からゲームカートリッジのバックアップに用いられてきたSRAM+ボタン電池です。これは電池が切れたらセーブデータが消えてしまうという欠点はあるものの、普通のメモリと同じように1バイト単位でランダムに自由に読み書きができるので、使い勝手は最高だと言えます。容量も64KBとそれなりにあります。電池も実際のデバイスでは20年近くたってもまだ生きていたりするので、実はかなり優秀なんじゃないかと思ってしまいます。

2つ目はEEPROMです。EEPROMは昔からある不揮発性メモリで、電池なしでデータを保存できます。それ以外のスペックはSRAMと比べると全面的に厳しく、シリアルバスで接続されていて、そこに64ビット単位でデータを読み書きするコマンドを送らなければならずそもそもの書き込み速度も大変遅い、書き換え回数に制限がある、データ容量が大変小さい(512バイト or 8KB)といったものになっています。

3つ目はNOR型フラッシュメモリです。これも不揮発性メモリで、電池なしでデータを保存できます。フラッシュメモリはNAND型もあって、こちらは現在ではSSDやSDカードで使われている不揮発性メモリの覇者みたいになっていますね。NOR型フラッシュメモリは大きな特徴として、バイト単位でのランダムで高速な読み出しが可能というのがあります。メモリ空間に直接マップされているので、簡単に速くデータを読み出すことができます。半面、書き込みには大きな制約がかかっていて、ビットレベルで1から0への書き込みしかできません。なので書き込みの際は一旦1にクリアしてから書き込む必要がありますが、クリアは細かい単位では出来ず、4KBのセクター単位でまとめてクリアする必要があります。書き換え回数にも制限があります。容量は大きく、64KBと128KBのものがあるので、EEPROMでは容量が小さすぎるようなソフトではこちらを使うことになるでしょうか。

4つめはFRAM(FeRAM、強誘電体メモリ)です。これも不揮発性メモリです。不揮発性メモリですが、バイト単位でランダムに読み書きできるので、ソフトから見るとSRAMと全く同じように扱えます。チップレベルで端子の互換性もあったようです。容量もSRAMと同じく64KBあります。書き換え回数もEEPROMやフラッシュより多いらしいです。すごい。全部これを使えばいいんじゃないかという気もしてきますが、どうも価格が高いというのがあったみたいですね。凄そうに見えるけど、微細化が難しいというのと価格が高いという点があって、不揮発性メモリの主役をフラッシュメモリに譲ることになったようです。

という感じで四者四様、今やゲーム機で使うバックアップメモリなんてNANDフラッシュ一択だという感じですが、それぞれメリットとデメリットがあるデバイスの中からゲームにあったものを選んで使っていた、まさに過渡期と言った感じで興味深い所でした。

エミュレーションの観点から言うと、それぞれのデバイスのエミュレーションは難しくはありません。種類も多いわけではないのでコードもそんなに多くはなりません。問題は、各ゲームがどのタイプのバックアップメモリを採用しているのかがROMのヘッダに書かれていないというところです。任天堂の標準ライブラリを利用している場合(?)、各デバイスに対応したバイト列がバイナリに埋め込まれているので、それらの文字列を検出することで特定することができるようですが、実際にはそれで検出できなかったり1つに絞れなかったり、容量が判別できなかったりするものもあるので、より高精度にするにはカートリッジ領域へのアクセスパターンから認識する方が良いようでが、面倒なのでやっていません。データベースがあるみたいなので、それを参照するようにすれば楽になるかもしれません。

あとがき

というわけで、ハードのこととかエミュレーターを書いているときに思ってきたことを長々と書いてきましたが、コンピューターの進化の歴史をおさらいできたようで私自信も結構勉強になりました。こう見てみるとRISC-Vは本当に過去の反省を踏まえた素晴らしい命令セットですね。Rustを用いたのも前回に引き続き良かった点で、昔C++でエミュレーターを書いていた時に比べてバグが直っていく速度が尋常じゃなく速かったです。そもそものバグも比較にならないぐらい少なかった印象です。開発環境のおかげなのか、自分自身の能力が上がったのかはわかりませんが、昔よりその辺の能力が伸びてるとはあんまり思えないので、たぶんやっぱり前者だと思います。なんだかわからないけどメモリの内容がおかしいみたいな最悪なバグが絶対にないというのが特に大きかったです。デバッグがやりやすすぎて、デバッグをやりやすくするための機能を実装するモチベがほとんどなかったのが逆に残念なぐらいです。

というわけで興味がある方はぜひ動かしてみてください。バグ報告やコントリビューションもお待ちしております。

Discussion