TL;DR
- PHPで動くファミコンエミュレータを作った
php-terminal-nes-emulator - 画面描画は点字を使って文字出力
- コントローラは標準入力からfread()
経緯
2016年の2月にPHPで動くゲームボーイのエミュレータ、php-terminal-gameboy-emulator に衝撃を受けて、その実装の解説を勉強会やカンファレンスでトークしたりSoftware Design誌に書いたりしました。(*1)
カンファレンスでのトークでは時間の都合もあって全体のごく一部しか話が出来ないのですが、Software Design誌では誌面をたっぷり頂いてCPU、メモリアクセス、画面表示とphp-terminal-gameboy-emulator のほぼ全域を解説出来たので満足し、その熱は落ち着いていました。
そんな中、9月に開催されたbuilderscon tokyo 2018 に「ファミコンエミュレータの創り方」というトークが採択され、これは聴かないと、と聴きに行きました。
そしてそこで受けた衝撃と言ったら。ゲームボーイとファミコン、PHPとRust / WebAssemblyという違いがあるにもかかわらずスルスル入ってくる解説内容と、見覚えのあるコード群に大興奮でした。
エミュレータのコードには特長があって、特にCPUはおそらくどんなCPUのエミュレータであっても「PC(プログラムカウンタ)が指すメモリから命令を取り出す」「命令を愚直にプログラム言語で書き下す」「メモリの読み書きは対象のアドレスによってROMだったりRAMだったりI/Oだったりするので巨大なswitch-caseになる」という内容なので言語やハードウェアに関わらず似た感じになります。
考えてみれば当たり前なのですがエミュレータのコードを1つしか読んだことない状態ではその当たり前に気付いておらず、「ファミコンエミュレータの創り方」を聴いてそれを発見しました。
builderscon tokyo 2018の懇親会などでbokuwebさんからいろいろ話を聴いてその場では割と満足していたのですが、その後、「ファミコンエミュレータの創り方」で紹介されていたrustynesやその前作のflownesのコードを読んでいたらどんどん作ってみたい欲が高まり、遂に手を付けてしまいました。
*1
PHPで学ぶコンピュータアーキテクチャ
(第100回 PHP勉強会@東京)
諸君、私はゲームボーイが大好きだ。PHP & ゲームボーイ。
(YAP(achimon)C::Asia Hachioji 2016 mid in Shinagawa)
PHPで学ぶコンピュータアーキテクチャ
(PHPカンファレンス福岡 2016)
PHPで学ぶコンピュータアーキテクチャ
(PHPカンファレンス関西 2016)
Software Design誌連載
「PHPで学ぶコンピュータアーキテクチャ」 2018年6月号 7月号 8月号
開発方針
PHPでファミコンエミュレータを作ってみよう、とは思ったものの所詮はPHP、画面表示はターミナルなのでfpsは望めない、音も出せない、キー入力は標準入力なので同時押しが出来ない、という三重苦なので「とりあえず動けばOK」という方針にすることにして、すでに動いている実装を横に置いて翻訳しながら写経風に進めることにしました。
最初はbokuwebさんのrustynesを写経していたのですが、Rustの「今風」な言語仕様がPHPにはややしんどく、開始してすぐに同じくbokuwebさんのJavaScript実装 flownes に写経のターゲットを変更しました。この実装はPHPerには読みやすく、今後「写経」しようと思ったPHPer諸氏にもお勧めです。(今回作ったphp-terminal-nes-emulatorも読みやすいと思いますが!)
作り始めてからしばらくは文字通りコードを見ながら写経していたのですが、JavaScriptとPHPは言語仕様がかなり似ており、最終的には「コピペして整形」みたいな感じで作っていました。(そのおかげで「PHPとJavaScriptで構文は同じだが挙動が違う」みたいなところでハマったりしたのですがこれは後述。)
デバッグ
エミュレータって少なくともCPUとビデオ出力の両方が完成しないと正しく動いているかが見た目上はわかりません。なので、まずはCPUとPPU(Picture Processing Unit)を一気に実装して「ファミコンエミュレータの創り方」でも紹介されていたテスト用のROMを起動してみました。
こういうものは一発で動かないのは当然な訳で、いくつかのエラーに遭遇しました。
PCがメモリの範囲外を指している
最初のエラーは起動直後にCPUがおかしなアドレスのプログラムを実行しているというものでした。CPUはPC(プログラムカウンタ)が指しているメモリアドレスから命令を取得して実行しますが、その初期値が正しく設定されていませんでした。
PCの初期化はflownesでは /src/cpu/index.js 内、reset() メソッドで実行しています。
this.registers.PC = this.read(0xFFFC, "Word") || 0x8000;
それを以下の様に「写経」しました。
$this->registers->pc = $this->read(0xFFFC, "Word") || 0x8000;
これ、JavaScript版と同じ動作をしないんですね。そのためにPC がおかしな場所を指していました。
JavaScriptのコードを見て「左辺が失敗したら右辺のパターンだな」と思い、「ああ、はいはい!」とか言ってこんな風に書き直しました。
$this->registers->pc = $this->read(0xFFFC, "Word") or 0x8000;
頭の中には fopen() or die(); のパターンがあったので反射的に書き換えてうまく動いたのですが、 fopen() or die(); のパターンは「die() が評価(=実行)されるか否かが左辺に依存する」だけなので今回のケースでは正しい修正ではありませんでした。
JavaScriptの実装では this.read(0xFFFC, “Word”) が値を返せばその値、そうでなければ 0x8000 をPCに設定、なのですが、上記のPHPのコードでは「PHPの || は = より優先されるが or は = が優先される」という差によって $this->read(0xFFFC, “Word”) の値に関わらずその値を使う様になっています。
これで動いてしまったので追求していないのですが、0x8000 はプログラムROM(ゲームカートリッジのプログラム部分)の先頭なので、「左辺が値を返さないケースが実機にもあり、その場合 0x8000 がPCの初期値として使用される」 or 「一般的なプログラミングの例外ケースとしてとりえあず無難なアドレスを設定している」のどちらかでしょう。
存在しないCPU命令を実行している
正しい位置からCPUが動き出したのですが、すぐに次のエラーに遭遇しました。次のエラーは存在しないCPU命令を実行している、というものでした。
どうやってデバッグしたものかと一瞬途方に暮れたのですが、よく考えたら自分は手元にほぼ同じ設計の動く実装を持っている訳で、CPUを動作させてPCの値をログに出力してその推移を比較してみました。
以下はそのイメージです。
flownes | PHP | ||
---|---|---|---|
PC | 命令 | PC | 命令 |
0xC004 | 78 | 0xC004 | 78 |
0xC005 | D8 | 0xC005 | D8 |
0xC006 | A2 | 0xC006 | A2 |
0xC008 | 9A | 0xC007 | XX |
0xC009 | AD |
flownes では0xC006 の $A2 命令実行後に2つ先のアドレス(0xC008)にPCが移動しているのに対して、PHP版では1つ先の 0xC007 に移動しています。そして 0xC007 の $XX を実行しようとして未定義命令としてエラーになっていました。
理由が分かればあとは簡単。$A2 命令はImmediateモードの LDX 命令です。LDX命令はレジスタXに値を格納する命令ですが、ImmediateモードでのLDX命令は命令の次の1バイトの値をレジスタXに格納してPCを1バイト後ろに移動します。ところがこの時のPHP版はモードの判定に失敗し、非ImmediateモードでLDX命令を実行していました。そのためPCが1バイト後ろに移動せず、$A2 の次のアドレスの(本来はレジスタXに格納したかった)値を命令として実行してしまい、未定義命令になっていました。
ここまでの修正が完了し、CPUが正常に動作する様になりました。
画面表示
CPUが動く様になったのでそろそろ画面を表示してみたいよね、ということで最初は現在の画面の様子をpngファイルに書き出すコードを書きました。
最終的にはphp-terminal-gameboy-emulator同様に点字でターミナルに描画するつもりなのですが点字って画面を二値化して描画するので細かいところが正しく描画出来ているかわからなく、まずはpngファイルに書き出す様にしてみました。
「ファミコンエミュレータの創り方」でも紹介されていた動作テスト用ROMを実行してみたらなんと一発動作。
テスト実行しても全部OK。素晴らしい。ここらへんのショートカット具合は写経ならでは。
これが動くなら、ということで別のROMも動かしてみたら…
やったぜ!!ということで当初の目的の点字での描画も実装しました。こちらはphp-terminal-gameboy-emulatorを参考に。
先にも書きましたが、これは各ピクセルを二値化して点字で横2ピクセル×縦4ピクセルを表現しています。マリオ部分を拡大するとこんな。
今回使っている点字は、横2×縦4つの点で文字を表現するのですが、これを文字コードにすると0x2800 + 下図のビット表現の数字が文字コードになる様になっています。
① ④
② ⑤
③ ⑥
⑦ ⑧
例えば下の例は、①②③⑥の位置に点があるので、00100111 = 0xE4 ということで、0x2800 + 0xE4 = 0x28E4 の文字コードになります。
⠧
手軽に試すには、HTMLで ⣤ を表示してみると良いでしょう。
点字には横2×縦3の点字もあるそうなのですが、情報密度が高い方ということで横2×縦4の点字を使用しました。(横16×縦16の点字が欲しい…)
二値化のしきい値はRGBの各要素の平均が128以上なら1、128未満なら0としています。このしきい値に深い意味は無く、いくつか試してスーパーマリオブラザーズのタイトル画面が一番しっくりくる値、ということで128に設定しています。(php-terminal-gameboy-emulatorだと元が4色ということもあってか、点字でもそれなりに見られるのですが、NESは若干厳しいですね。)
ROMのダンプ
エミュレータを動作させるにはROMが必要です。世の中には上記のテスト用ROM含めて多くのフリーのROMが存在しています。エミューレータを作ってみたい方、動作させてみたい方はまずはそういったROMで試すと良いと思います。
長谷川もしばらくテストROMを使っていました。ですが、動きそうな希望が見えてきたら欲が出て実際のファミコンのROMを動作させてみたくなりました。
最初は自分でRaspberry Piでも使ってROMダンプ装置を作ろうかと思っていたのですが、少し調べたら世の中にはファミコン用のROMダンプ装置が市販されており、Amazonでも購入できることが判明。作るのも面白そうだったのですが、ファミコンのROMには「マッパ」という仕組みがあり読み出し装置を作るのにもそれなりに苦労させられるだろうな、と思い、スーパーマリオブラザーズのカートリッジと一緒に購入してみました。
こんなスタイルで使用します。右下はUSB-Bです。
無事ダンプし、冒頭の様にスーパーマリオブラザーズを動作させることができました。
ちなみに、このカートリッジ、Amazonで箱・説明書なしの商品を「コンディション: 可」ということで購入したのですが手元に届いて裏を見てみたら…
「記名!懐かしい!いやー懐かしい。書いてた書いてた!」と盛り上がりました。(今も書いてるのかな…。中古に売るために書いてないのかな。そもそもDL販売か?)
パフォーマンス
冒頭のスクリーンショットは実はLate 2017のMacBook Pro 15inchのPHP7で動作させたものを3倍速にしています。
そう、遅いんです。余りに遅くてショックで、どうせ遅いのは画面描画だろう、と画面描画をしないオプションを作ってみたのですがそれでも最大9fpsとか。
前述のとおり、今回の実装はほぼflownesと同じで、flownesはdiv + cssのレンダリングでも10fps出ているということで単に処理系のパフォーマンス差なのかなあ…と。認めたくないのでグレートPHPerの誰か速くして!!
まとめ
という訳でPHPで動くファミコンエミュレータを作ったよ、というお話でした。
エミュレータのコードって処理系なので、Webアプリともフレームワークとも違う形をしています。そんなエミュレータのコードを読んだり書いたりデバッグしたりするのはとても面白いです。(最近思いついたのですが、エミュレータのコードって、ハードウェアの仕様が表現されている訳でHardware Specification as Code ですね。)
エミュレータのコードなんて、難しそうだな、って思いますよね。長谷川も最初はそうでした。でもちょっと見てみたらエミュレータのコードを表すワードとして一番適切なのは「愚直」ということがわかりました。PHPを読める方は是非一度php-terminal-nes-emulatorのコードを読んでみてください。
php-terminal-nes-emulatorのコードを読むときは、副読本としてファミコンの仕様を見ながらだとより楽しめると思います。
最初はCPUとメモリアクセス周りを読むのをお勧めします。ノイマン型コンピュータのCPUがどう動いているのかを理解できて面白いと思います。
Happy hacking!!