の続きです。
前回の記事ではCPUと最低限のPPUを実装してnestestの公式命令テストを完走することができました。
今回の記事ではいよいよスーパーマリオブラザーズ(SMB)を動作させてみます。
ROMの入手
さて、動作させるにはまず初めにROMを入手しなければいけません。
手元にSMBのカセットは無かったので中古で入手しました。
カセットの溝にマイナスドライバーを差し込んで力を加えるとカパッと殻が外れるので、あとは基板に実装されているマスクROMを読み込むだけです。
SMBのカセットのマスクROMには特に技術的保護手段が施されていないので、ユニバーサルプログラマなどで普通に読み込むことが可能です。
PRG
とマーキングされている方がプログラムROMで、 CHR
とマーキングされている方がキャラクターROMです。
読み出すROMのサイズやハッシュ値などの情報はNesCartDBというサイトで確認することが可能なので、読み出したデータの整合性確認に使用すると良いでしょう。
SMBの場合は32KBのプログラムROMと8KBのキャラクターROMで、合わせてわずか40KBです。 この40KBの中にゲームのロジックからキャラクターや背景の絵、BGMや効果音などがすべて含まれています。
こうしてROMを入手することができたので、いよいよ実装したエミュレータに読み込ませてみます。
初回起動
実装したファミコンエミュレータで初めてSMBを起動させた時の様子が次の通りです。
まぁ案の定ボロボロです。 オリジナルの画面を知っていれば何となく何が写ってるんだろうなという想像ができる程度でしょうか。
CPUはnestestを完走するぐらいなので問題なく動いていそうですが、Hello, world!ロム程度でしか動作確認をしていないPPUがまともに動作する訳がありません。
ここから先は表面的な理解だけで実装を進めるのは難しいので、本格的にNesdev Wikiのお世話になることになります。
恐らくいきなりNesdev Wikiを見ても内容を理解するのは難しいと思いますが、一度Hello, world!ロムを動かす程度まで実装したうえで見れば割と理解しやすいのではないかと思います。
あとはNesdev Wikiに記載されている仕様と自分の実装とを見比べたうえで、不足している部分を実装したり誤っている部分を修正していくことになります。
まずはPPUの実装を修正してSMBを問題なく描画することができるようになることを目指しました。
PPU実装・修正の過程
細かい実装や修正の内容まで書くと長くなってしまうのでダイジェストで行きます。
それっぽい絵が表示されるようになる
少しPPUの実装を修正するとそれっぽい絵が表示されるようになりました。
空の色が黒いですが、これは自作ファミコンエミュレータあるある現象のようで、VRAMの特定のメモリアドレスのミラーリングを正しく実装できていないとこのように空が黒くなります。
Addresses $3F10/$3F14/$3F18/$3F1C are mirrors of $3F00/$3F04/$3F08/$3F0C. Note that this goes for writing as well as reading. A symptom of not having implemented this correctly in an emulator is the sky being black in Super Mario Bros., which writes the backdrop color through $3F10.
わざわざ↑のようにNesdev Wikiにも書かれるぐらいで、相当多くのファミコンエミュレータ実装者がこの黒い空を拝んでいるようです。
自分も以前からこの現象のことは何となく知っており、それなりに意識して実装していたにもかかわらず間違えました。
空が明るくなる
さて、当該の箇所を修正することで無事に空が明るくなりました。 ところが、全体的に色がおかしいうえに肝心のマリオの姿が見当たりません。
マリオ登場、しかし動きが…
DMAを実装してBGとスプライトのテーブルアドレス選択を修正することでついにマリオが登場しました。 しかし、どうも動きがぎこちないです。
というのも、この時点ではスプライトの描画位置が正確ではなく、ピクセル単位ではなく8*8ピクセルのタイル単位までしか実装できていません。そのため、カクカクとしたぎこちない動きになってしまっています。
また、スクロールの実装も正しくありません。
マリオの血色と動きが良くなるも、右に進むとハング
さらにPPUの実装を修正するとマリオや世界の色が正しく表示されるようになりました。 スプライトの描画もより正確になり、一気にそれらしい見た目になります。
ところが、動画の終盤に見て取れるようにある一定以上ステージを右に進むとハングして操作不能になってしまいます。 この現象は偶発的なものではなく、ステージのある地点まで画面がスクロールすると必ず発生する再現性のある現象です。
このハングが発生した際にCPUが何の処理をしているのかを調べたところ、どうやらひたすらスプライト0ヒットを待ち続けているようでした。
スプライト0ヒットとハングの原因
スプライト0ヒットはファミコンのPPUに存在する機能で、スプライトメモリ(OAM)上の0番目に存在するスプライトを描画する際に、スプライト内の不透明なピクセルがBGの不透明なピクセルと重なった場合にPPUの特定のレジスタのフラグを立てるという機能です。 スプライト0ヒットを使用することによって、プログラムが厳密なCPUクロックを数えなくてもPPU上で画面がどこまで描画されたのかを知ることができます。
ファミコンのPPUは、効率的なスクロール描画を行うために内部で2画面分の描画領域を持っており、PPUのスクロールレジスタの値に応じて描画領域のうちのどの部分を画面に出力するかを決定しています。
一方で、SMBには画面上部にスコア表示があります。 そのため、単純に画面全体をスクロールさせてしまうとスコア部分も一緒にスクロールしてしまいます。 スコアをスプライトとして表示することができれば画面のスクロールと両立させることができますが、ファミコンの仕様上スコアの文字を全てスプライトとして表示させることはできません。*1
そこで、SMBではスコアをBGとして描画したうえで、スコアの表示を終えたラインから画面をスクロールさせるという手法を取っています。*2
図にすると次のような感じです。
画像全体で2画面分の内部描画領域を表しており、赤や緑色の枠は画面の出力範囲です。
- まず、最初の時点ではスクロール値を変化させずにBGにあるスコアを描画させます(赤枠部分)
- スコアの描画が終了したら、スクロール値を適切な値に設定して画面をスクロールさせます(緑枠部分)
- 画面を最後まで描画し終えたら、再びスクロール値を0に戻して次のフレームでのスコア描画に備えます
この、2. のスコアの描画が終了したかどうかの判定に先ほど説明したスプライト0ヒットを使用しています。
SMBのスプライト0はスコア表示のコインアイコンの下部2ピクセルほどの部分です。
ジャンプ等でマリオのスプライトを重ねると、スプライト0だけ前面に描画されるのでスプライト0を簡単に確認することができます。
エミュレータがハングした際、内部では次のような状態になっていました。
スクロール範囲が内部描画領域の半分を超えたため、折り返しを行うためにメインスクリーン*3が変更されます。
これによって、スクロール値0がもはや内部描画領域の左側を指さなくなります。結果としてスプライト0ヒットに必要なスコアのコインアイコンが描画されなくなりスプライト0ヒットも発生しなくなりますが、SMBのプログラムはいつまでも発生することのないスプライト0ヒットを待ち続けるためハングしてしまいます。
さて、ハングしてしまう原因はすぐに分かりましたが、どうしてこのような状態になってしまうのかはすぐに分からず、しばらくの間悩み続けました。
ワークアラウンドとして、エミュレータの実装で毎フレームの描画が始まる前に強制的にメインスクリーンを0に切り替えるようにすることでハングを回避することはできますが、スコア表示がちらつく上にSMB以外のスクロールを使用するゲームで問題が発生すると思われるため、そうしたワークアラウンドによる対処は避けました。
次回予告
今回の記事はここまでです。
次回は、このスプライト0ヒットが発生せずにハングしてしまう問題の解決編やAPU(音声処理ユニット)の実装、Rustの特徴についてなどを書きたいなと思います。
その3を書きました。