だいぶ前から細々とファミコンエミュレータを作っていてそれなりに動くようになっているんだけど、ここらで処理をメモっておく。 特にBG(背景)の処理に関しては結構トリッキーで、実装に戸惑ったことを書いてみる。
画面表示の処理方法
ファミコンのハードは(オーディオとかはひとまず置いておくとして)大まかにCPUという命令を処理する部分と、PPUという画像を処理する部分があって、これらを実装すれば絵が出るようになる。 エミュレータがインタラクティブに動くようにするためには、経過時間を測ってそれに対応するクロック数だけCPUとPPUを動かしてやればよい。
自作エミュレータの処理的にはCPUはクロックに従って動かすけど、PPUはそういうことはしてない。 エミュレート側で画面表示を行うタイミングでPPUのレジスタやVRAMの内容から画面を構築して表示している。
実際にはファミコンでは表示期間中は固定の内容が表示されるというわけではなく画面の途中でBGのスクロール位置などを変更できるので(後述)、水平ラインのどの位置で変更されたかを記録しておいて、それも反映させている。
ファミコン実機の動作を正確に再現したい場合には、PPU側も真面目にクロックによって動作を再現してやる方法もあるらしい。 考えるだけでも処理が重そう、と思うんだけどhnesとか optcarrotでも場合によってはそうしているっぽい。
BGのスクロール
ファミコンには背景が2画面分の広さがあって、位置をずらすことで簡単にスクロールさせることができる。 ゲームの背景として使う場合はこの機能が利用できる。 しかしレイヤーとしては1面しかないという制約があって、それを回避しようとするとちょっとトリッキーになってくる。
スコアなどの情報を画面に表示させる場合それらはスクロールさせたくないが、BGが1面しかないので単純にはそういうことができない。 じゃあどうするかというと、表示期間中にスクロール位置を書き換えて、画面の途中でスクロール位置を変更することで実現している。
例えばスーパーマリオでは画面上部はBGの左上部分を表示、32ライン目以降を横スクロールさせるようになっている。
画面の途中でスクロール位置を変更する場合の、レジスタに書き込まれる値というものがちょっと注意が必要だった。 通常のVブランク中にスクロール位置を設定する場合に画面左上の位置を指定するということと、 「BG面」という名称から、実際にそういう論理的な構造があってスクロールレジスタはその原点位置を指定するもので、PPUがそれに従って画面に反映させているのかと想像していた。 なので画面の途中でスクロール位置を変更した場合、その後に表示される内容はそのスクロール位置+描画している位置のオフセットとなるものだと思っていた。
しかし実際にはそうじゃなくて、PPUはスクロール値としてレジスタに書き込まれた値に対応するネームテーブルのアドレスを直接管理していて、表示期間中はそのアドレスから背景チップのパターンを取り出して描画する、ということを繰り返しているのだった。 画面の途中でスクロール位置を書き換えるとそのアドレスが直接変更されて、それによってスクロール位置が切り替わるという動作になっている。 確かにそうなってるとハードウェアの回路が単純ですみそうだなぁという気はする。
注意点としては、表示期間中にスクロールレジスタを書き換えても反映されるのはX方向だけでY方向は効かない。 Y方向もいじりたい場合には、アクセスするPPUのアドレスを指定するレジスタがスクロールレジスタと共有されていることが利用される。
この辺り、ハードウェア的にどう構成されているのか知らないけどそんなに複雑には作られてないはず、 しかしエミュレートする際には(PPUをクロック動作させてないこともあって)かなりトリッキーになってくる。
スクロール切り替え位置の決定方法
画面の途中でスクロール位置を切り替える場合には、タイミングをみはからってPPUレジスタに書き込む必要がある。 しかしCPUからはPPUの現在の描画位置を直接取得することができないのでどうするか。
PPUの機能で、0番目のスプライトと背景が交差していたらそのラインを処理するときに特定のビットが1になるというものを利用する(スプライト0ヒット)。 CPUでそのビット監視して、1になったらスクロール位置を変更するというふうになっている。
スーパーマリオの場合、上部のコイン表示が実は単なる背景じゃなくスプライト0が重ねて配置してあって、これを使って検知している:
しかし重なっても割り込みが発生するわけじゃないので、実装にはかなり気を使うんじゃないかと思う。 ポーリングで待つ必要があるが多少余裕を持っておかないと、遅れた場合に画面が乱れてしまうのでまずい。 しかし余裕を持ちすぎると非力なCPUパワーの無駄遣いになってしまうので悩ましい。
ROMカセットの種類によってはIRQという機能で割り込みが制御できるので(MMC3など)、そういう問題は回避できる。 (サウンドチップAPUのDMCでもIRQが制御できるぽいが、使用しているゲームはあまりなさそう)。
参考
- PPU scrolling - Nesdev wiki 表示期間や水平帰線期間にPPU内部のアドレス値がどのように更新されるか書いてある
- PPU registers - Nesdev wiki