Go言語でゲームボーイアドバンスのエミュレータを作った話
最近、GBA(GameBoy Advance)エミュレータのmagiaをGo言語で開発しました。(よかったらスターお願いします!)
この記事では、それに伴って得られた知見やエミュレータ開発に関するノウハウについて書いていこうと思います!
なぜGBAエミュレータを作るのか?
- GBAが好きだから
まず第一にこれです。僕は子供のころGBCとGBAが大好きで親の制限下の中、ずっとやっていました。思い入れがないゲームのエミュレータでないと作ろうという気もあまり起きませんし、作り始めてもモチベーションが続かないと思います。 - 低レイヤの知識のアウトプットになる
あとで述べますが、エミュレータ開発はCPU、メモリなど低レイヤ周りの知識が求められます。 - 成果物で遊べる
低レイヤ周りの趣味開発の成果物というのは、ほとんどの場合、頑張って作っても、作って終わりになるものが多いです。(やる意味は大いにありますが。) しかしゲームのエミュレータなら実際に成果物で思い出のゲームを遊べるので、開発を最後までやるためのモチベーションは保ちやすいかなと思います。
エミュレータ開発に必要な知識
エミュレータ開発には低レイヤ(コンピュータ)の知識が必要になってきます。
なぜならゲームというのは、究極的には、ソフトのプレイに特化した単なるコンピュータだからです。
一般的なコンピュータ同様、CPUやメモリなどがあります。エミュレータというのはそのCPUやメモリなどの動作をソフトウェア上で再現することなので、まずこれらがどのようなものであり、どのように動いているのかを知る必要があります。
そのため低レイヤの知識が必要になります!
GBAの基本仕様
今回はGBAについて取り上げます。(つまりGBASP, GBミクロについては取り上げません。)
項目 | 概要 |
---|---|
CPU | ARM7TDMI 32bit RISC CPU, 16.78MHz, 32bit opcodes |
RAM | 32KB(CPU内部) + 256KB(CPU外部) |
VRAM | 96KB |
ROM | 最大32MB |
ディスプレイ | 240x160 pixels |
サウンド | アナログ4チャネル+デジタル2チャネル |
割り込み | あり |
CPUはなんとARMのCPUを採用しています。(そのおかげでLLVMのターゲットに指定できる)
ゲームボーイとの違い
CPU
最大の違いはCPUです。ゲームボーイ(以降、GB)ではZ80を独自カスタムしたLR35902
というCPUを使っていますが、このCPUは8bitで動作するCPUです。つまり命令のサイズが8bit(1byte)しかありません!
しかも、LR35902
は掛け算命令など現代のCPUでサポートしている基本的な命令をサポートしていません。
これに比べてGBAに搭載されているARM7TDMIは32bit CPUです。つまり命令のサイズが4byteとGBの命令の4倍の大きさになります! このおかげでCPUの命令セットがさまざまな命令をサポートできるようになりました。(まあ後述の理由で実質16bit CPUですが...)
またクロック数もGBの4MHzから16MHzに伸びました。
BIOS
GBのBIOSはチェックサムによるソフトの読み込みチェック+エントリーポイントへのジャンプくらいの機能しかないですが、GBAははるかに高機能になっています!
GBAのBIOSには、割り算や圧縮、メモリコピーなど汎用的な機能が書き込まれているので、GBAのソフト開発者はこれらの命令を自前で開発する必要はありません!
これらはシステムコールとして呼び出すことができます。
互換性
GBAはGBと互換性を持っていて、GBAでGBのゲームをプレイすることが可能です。
基本仕様の表では書いていませんが、実はGBとの互換を保つため、GBAにはGBのCPULR35902
も搭載されています。GBAのゲームを遊ぶときはARMのCPUだけを、GBのゲームを遊ぶときはLR35902
だけを使います。(まさかこんな方法で互換性を保っているとは思いませんでしたw)
描画機能
GBはVRAM領域に 8x8 ピクセル単位のタイルデータの集合(タイルセット)をあらかじめ用意しておき、画面描画時には、タイルセットの中からタイルデータを指定して画面に並べていくという描画形式でした。(タイルモード)
タイルセット↓
GBAではこれに加えて、ビットマップモードというメモリ領域に格納された色データがそのまま対応する画面上のピクセルに反映されるという描画モードもサポートしています。
タイルモードもGBと違って画面がレイヤーを持つことができるようになりGBでは表現できなかった画面も表現可能になりました!
実装過程
THUMBモード
まずはエミュレータというかコンピュータの核となるCPUから作っていきました。ここでGBAのARM7TDMIのTHUMBモードについて説明する必要があります。
GBAに搭載されているARM7TDMIというCPUには2つのモードがあります。ARMモードとTHUMBモードです。
ARMモードは前述の通り、32bitで動作するCPUなのですが、THUMBモードは16bitで動作します。当然解釈する命令幅も16bitになります。これは先程のGBAとGBのCPUのように、どちらか片方選んで、動作中はずっと一方のみを使うというわけではなく、実行中に簡単に切り替え可能です。
もちろん、ARMモードで実行する32bit命令のほうが、命令が持てるbit数の分、表現の幅が広いです。
例えば、スタックにpushする命令は、ARMモードならSP(スタックポインタ)をインクリメントするか、デクリメントするか、そのタイミングはスタックにpushする前か後か、などを命令で指定可能ですが、THUMBモードのpush命令は、pushした後にスタックをデクリメントするということしかできません。
ならずっとARMモードでいいのでは?と疑問に思うかもしれませんが、ここで問題になってくるのが、カートリッジ(ゲームカセット)から命令を読み取って実行する場合です。
カートリッジはバス幅が16bitしかありません。つまりCPUが一度にカートリッジから取ってこれる命令の長さは16bitまでなのです。ARMモードの命令は32bitなのでカートリッジの中のARMモードの命令を実行しようとするとCPUは2回に分けてカートリッジにアクセスすることになってしまい、非常に低速です。
また当時はカートリッジの容量もカツカツなので、THUMBモードでARMモードの命令と同じ結果が得られるならTHUMBモードで書いたほうが当然容量の節約にもなります。
なので、GBAはほとんどTHUMBモードで動作します...(だから実質16bitCPU)
ただ、BIOSの命令などはARMモードで動作するので、エミュレータを作る場合は、このARMモードとTHUMBモードの両方をエミュレートしてあげる必要があります。
こんな感じで命令のデコードから作っていきました。
パイプライン
GBAのCPUにはパイプラインが実装されていて、CPUの実行中に次の命令のフェッチが行われています。
このため、現在の実行中の命令のアドレスとPC(プログラムカウンタ)の値がパイプラインによるフェッチの分ずれています。
このせいで、PCに対して相対的なジャンプ命令とかでジャンプ先のアドレスがズレることが多発してかなり苦労しました...
Hello world
そんなこんなで苦労しましたが、なんとかCPUの実装を終えました。
次に取り組んだのが画面描画です。
前述のようにGBAにはタイルモードとビットマップモードがありますが、タイルモードはGBとほとんど同じだったのでGBエミュ開発の経験から、割とあっさり実装できました。
この時点でHello world ROM(Hello worldを表示するだけの一番単純なソフト)を動かす準備ができました。(Hello world ROMではビットマップモードのほうは使わないのでタイルモードの実装だけで大丈夫です。)
動かしてみたところ、、、無事動きました!
ちなみに、画面描画(エミュレートのほうではなく実際にPCにウィンドウを表示する方)や60fpsを保つために、Hoshiさんのebitenを使っています。ebitenはいいぞ!
その後、ビットマップモードのほうも実装しましたが、こちらはメモリと画面上のピクセルが1:1に対応しているというシンプルな構造だったため、こちらも簡単に実装できました。(draw.go)
ロックマンエグゼ6
ここまでで、エミュレータの基盤(CPU、メモリ、画面描画)ができて、サンプルROM(Hello worldなどの単純なROM)の動作に成功しました!
今度は通常のROM(今回は吸い出したロックマンエグゼ6グレイガ)を動かすことを目標に、DMA転送やタイマー、キー入力などを実装していきました。
DMA転送は、GBの場合はVRAM以外を転送先にすることはできませんでしたが、GBAではVRAM以外にも転送することができ、デジタルサウンドの再生にも利用されています。
Goの実装の話から言えば、タイマーは当初、GBAのCPUやメモリのあるgba
パッケージとは独立のtimer
パッケージで実装していたのですが、timer
からGBAのメモリを操作するDMA転送のトリガーを引きたい時が出てきて、その際にgba
のパッケージと循環参照になってしまうため同一パッケージにしたという苦労話があります。
このように、必要な機能を実装していき、機能が一通り揃ったところでロックマンエグゼ6を動かしてみたところ、タイトル画面の描画に成功しました!
バグ取り
ほとんどの機能はできたので、残すはROMを動かしていく過程で見つけたバグを取っていく作業になります。
bitが1つ正しくないだけで致命的な結果になるのがエミュレータ開発です。 また実際の原因箇所と、現れる結果が乖離していることも多く、バグの特定は想像以上に厳しいです。
下の画像は、シフト命令でキャリーの更新処理にバグがあった結果生まれたエミュレーションの不具合です。みんながそっぽを向いちゃいました。(背景が水色なのは別のバグです)
正直、このバグの修正には苦労しましたが、シュールなバグだったので割と楽しめました。このようなシュールなバグもエミュ開発の醍醐味だと思います。
このようにバグ修正は大変なので、エミュレータを作る際にはデバッグ機能(メモリのダンプやブレークポイント)を実装することを強くお勧めします。
他に遭遇したエミュレータならではのバグは、実機のGBAでは方向キーの←
と→
が構造上同時に押せないようになっているのですが、エミュレータでもし←
と→
を同時に押してしまえるようにすると、実際に押したときにクラッシュしてしまうゲームがいくつかありました。(エグゼ3など)
なので、←
が押されたときにクリアされるbitと→
が押されたときにクリアされるbitの両方が同時にがクリアされないように気をつけましょう...(GBAのキー入力に反応するレジスタは、押されていない時に対応するbitがセットされ、押された時にbitがクリアされます)
ひとまず完成
ゲームの動作に致命的な影響を及ぼすバグが消えたところで、とりあえず一旦完成ということにしました!(もちろんまだまだ開発は続けていきます)
Twitterで完成を告知したところ、さまざまな人から反応をもらえて嬉しかったです!
技術周り
技術選定
今回はGo言語でエミュレータを実装しました。
エミュレータ実装ではC言語やPythonが比較的メジャー(なはず)ですが、今回Go言語を選んだのは、
- 自分が慣れてる
- 静的型付け+暗黙の型変換がないことによるバグ防止
- アプリのロジックに集中できる (Cと比べて。ヒープの管理などをしなくていいし、標準ライブラリも充実)
- 高速 (Pythonと比べて。60fpsを保つという性質上速度がエミュレータでは求められます)
また上でも述べましたが画面描画や60fpsの維持、さらにキーボードの入力のハンドリングにはebitenという2Dゲームエンジンを使いました。 ebitenは開発が積極的に進められていて、APIも直感的で2Dゲームエンジンならこれが一強だと思います。
ebitenはいいぞ!
wasm
今回のエミュレータはGo言語で実装されています。つまりwasmにコンパイルしてWeb上で動かすことが可能です。つまり、これができればブラウザ上でGBAのゲームが遊べるようになります。ワクワクしてきませんか?
wasmによるwebアプリ化は今後実装予定です!
最適化
今回実装したGBAエミュレータは最適化はまだ全然していません。
実際に広く使われているエミュレータにはパフォーマンスのためのさまざまな最適化が施されています。
例えば、GBAの場合、BIOSの命令(割り算、arctan、圧縮など)はBIOSの命令列をエミュレートしたCPUで実行することでエミュレートしますが、mGBAなどの有名なエミュレータでは、処理の結果を、返り値を格納するレジスタに直接書き込んでしまい、エミュレートしたことにしています。
このようなマシン(今回ならCPUとBIOS)はエミュレートしないが、得られる結果をエミュレートするエミュレート形式をHLE(High level emulation)と言います。
他にも、Nintendo SwitchのエミュレータであるRyujinXは、ソフトのARMCPUの命令を、エミュレータを動かすCPU(x86)の命令に実行時に変換を行い、同じコードを実行する際には変換したx86のコードをそのまま動かすJITの導入によってパフォーマンスをあげています。(参考記事)
終わりに
サクサクっと書いたので、雑な記事になってしまいましたが、ここまで読んでくださって本当にありがとうございました。
もし、エミュレータ開発に興味が出たなら是非やってみてください! 楽しいよ!おいで!
Discussion