@jyane

ファミリーコンピュータのエミュレータ開発での注意点

ファミリーコンピュータのエミュレータ開発は多くの人が行っており、基本的な情報については Google で検索すれば日本語でもおおよその情報を得ることができます。 ここでは日本語の記事ではあまり触れられていない点や開発上の注意点、それだけでは勘違いしてしまう点について解説します。 CPU は PPU と比較して簡単であるため主に PPU を扱います。

バグについて

多くの人が触れていることですが、バグに遭遇した時に自分が想像していたバグの原因と本当のバグの原因がかけ離れていることが多々あります。面倒でなければある程度の機能を持ったデバッガを実装したほうが結果的には良いと思います。 自分が遭遇したバグでは CPU が明らかに参照してはいけないアドレスを参照してしまい落ちるというバグがあり、大量の CPU テストを通したり逆アセンブルなどで落ちる周辺のコードを読んだりしていたのですが、原因は PPU の NMI の処理にありました。 これは CPU にばかり着目していた自分にとってはかなり意外で、1行の修正に数日程度の時間を取られました。

用語について

スプライト(Sprite)

Sprite は背景の対比として前面に表示されるコンテンツを指します。 背景のパターン(形状データ)もスプライトと呼ばれることもありますが、それらはパターンと呼ばれるほうが一般的であり前面に表示される物とは異なる概念です。よって、キャラクタ ROM の内容は Pattern Table と呼ばれます。 NesDev Wiki では Pattern と Sprite はちゃんと別概念として扱われていると思っています。

NES PPU の文脈における Fine X Y, Coarse X Y

当たり前のように使われている言葉ですが、調べても意味が出てきません。また日本語の記事ではスクロールについて詳説しているものがないためか、この言葉を見かけません。 Fine は 8px 未満のスクロール値を示しており、スムースな、細かい、という意味です。 Coarse は 8px (Tile) 毎のスクロール値を示しており、粗い、という意味です。 PPU の Name Table、Attribute Table、Pattern Table の値の取得時は Fine X の値は考慮しない(よく考えれば考慮しなくてもよいことがわかります)ですが、レンダリング時までに Fine X の値を考慮しないと水平スクロールが 8px 毎のスクロールになりカクカクになります。

Strobe

コントローラ(JoyPad)には Strobe という機能があります。On の時コントローラは A ボタンの情報のみを報告します。よく理解していない機能なのですが、ROM 側では利用されているようでこれを実装しないとなぜか操作感が変、という状況になります。

Mapper0 について

解説記事などで扱っている CPU, PPU のアドレス空間は Mapper0 のものです。まるで “これですべて” というように書かれていますが、正確には Mapper のバンクやミラーリングなどによって変わってしまうためそうではありません。 例えば Mapper0 はプログラム ROM の容量が 16KiB の場合は $C000-$FFFF は $8000-$BFFF のミラーになっています。この事実を知らないと nestest.nes 実行時などに想定外の参照が発生しますが、原因の特定にはかなり時間がかかります(何も考えずに mod で配列外参照を回避していない限り、おそらく NesDev Wiki の Mapper0 の記事を読むまでわからない)。 詳しくは NesDev wiki を参照してください。

Table のミラーリングについて

水平方向にミラーならば、垂直方向のスクロールです。垂直方向にミラーならば水平方向のスクロールです。 カートリッジから得られる情報はミラーリングであり、スクロールではありません。

AA は NesDev Wiki より引用。

     (0,0)     (256,0)     (511,0)
       +-----------+-----------+
       |           |           |
       |           |           |
       |   $2000   |   $2400   |
       |           |           |
       |           |           |
(0,240)+-----------+-----------+(511,240)
       |           |           |
       |           |           |
       |   $2800   |   $2C00   |
       |           |           |
       |           |           |
       +-----------+-----------+
     (0,479)   (256,479)   (511,479)

例えば水平ミラーでは $2400 は $2000 のミラー、$2C00 は $2800 のミラーです。横方向のミラーなので縦方向にスクロールできるイメージです。

スクロールについて

エミュレータ開発という点でスクロールを詳細に解説している日本語の記事はない印象です(見つけられていないだけかもしれません)。 とはいえ、内容を深く理解していなくとも NesDev Wiki の擬似コードを現実のコードに落とし込むだけでレンダリング時以外の処理は実装できてしまうため、詳細には解説されていないのかもしれません。 その理由からか他の人の実装もレンダリング時以外はほとんど似たコードになっているようです。NesDev Wiki にもレンダリングの方法までは書かれていません。(細かく見ているとたまにレンダリングの処理まで明らかに他人の実装をそのまま持ってきてるな、というものもありますが……)

スクロールを実装する前に NES 研究室の Hello World をスクロールなしで動作させておいたほうがいいと思います。最初は各 Table の役割や計算の方法などわからない点が多く、スクロールを用いた方法で背景画像を描くのはPPU にかなりの完成度を求められます。

エミュレータ開発としては、おそらく1フレーム (または 1 scanline) ごとなど一定の塊でレンダリングする方法と、PPU の挙動を正確にエミュレートし各ピクセルごとにレンダリングする方法の2種類があります。 正確なエミューレーションの実装としてよくある最初の勘違いは、おそらく VRAM アドレス(PPU の v レジスタ)が PPU が今レンダリングしているピクセル、および Fetch しているデータに合わせて変化していることを認識していない点です。 PPU は現在の VRAM アドレスを変更しながら各 Tile の情報を取得・レンダリングしています(なので、VBlank のフラグが立っていない時に VRAM アドレスの値を変更すると画面の破壊が起きてしまうわけです)。 また、同時に VRAM アドレスを変更しながらスクロールの処理も行っています。

NesDev Wiki には下記の画像があり、PPU が各 Cycle で何を行っているか記されています。

nesdev wiki img

PPU の 8px ごとの Fetch Cycles で取得している Tile のデータは現在処理しているピクセルより 2 Tile 前のものを取得しており、それらはレンダリング時のために PPU 内部に保存しておく必要があります。 Fine X を考慮すると現在のピクセルのレンダリング時に、今までに取得した 2 Tile 分のデータが必要になります。逆に言えばレンダリング時には 2 Tile 分のデータが必要であるため、PPU は現在のピクセルより 2 Tile 前のデータを取得してます。

また、Attribute Table の値は取得時にその象限(数学の象限とは値など異なりますが、便宜上象限と呼ばれるようです)を求めて保存しておいたほうが楽です。逆にレンダリング時にこれを行うと、1、2 Tile 前の象限を求めたり、Tile またぎを考慮する必要があり、処理がかなり複雑になります。 自分はこれに気づかず、レンダリング時にかなり複雑な処理をした挙げ句あきらめて、Attribute Table の値の取得時に事前に計算しておくことにしました。

Attribute Table 取得時の象限は v レジスタの Coarse X, Y の偶奇から下記で求まり、シフト部分はちょうど象限の値の表現を 2 倍したものであるため、シフト後さらに &3 すれば現在の象限の Attrubute の値(Palette の番号)が求まります。 fetch_attribute_tile_byte の処理でこれを行うと、それはもう fetch_palette ではないかとも思うのですが上記画像と名前を合わせておく風潮があるようです。

attribute_table_byte >> ((ppu.v >> 4) & 4) | (ppu.v & 2)) & 3

これを 4 倍し、Tile の値を OR すれば Palette のアドレスとなります。

0x3F00 | (palette << 2) | tile_value

現在の背景・スプライトのピクセルの値の求め方

High Tile (Pattern) の値は Low Tile (Pattern) の値より 1 bit 上の値を示しており、単純な足し算で求めるのは誤りです。ずっと単純な足し算と思っていて混乱しました。

high_tile_value = high_tile_byte << (7 - x)
low_tile_value = low_tile_byte << (7 - x)
tile_value = high_tile_value << 1 | low_tile_value

上の Palette の求め方と合わせると、下記のように色が決定できます。

0x3F00 | (palette << 2) | high_tile_value << 1 | low_tile_value

NMI について

NMI は 1 Frame あたり1回しか発生しません。ただし、NMI の出力フラグを意図的に複数回変更することで 1 Frame で 1 回以上発生させることも可能です。各 ROM は NMI のジャンプを利用してメインループを 1 フレーム毎に実行することを実現しているようです。 NMI 発生後、CPU は割り込まれる前の命令から実行します。

スーパーマリオブラザーズについて

スーパーマリオブラザーズは最初に動作させる商用 ROM としては難しいと言われています。確かにスクロールが必要な点、Sprite0Hit の実装が必要な点を考慮すれば、PPU 周りがほぼ完全にエミューレートできていないと動作しません。 スーパーマリオブラザーズでは背景の画面上部コインの裏に(おそらく)1UP の Sprite が隠されており、この Sprite0Hit が発生しないとタイトル画面で見かけ上の処理が停止します。

じゃあどれが最初に最適なんだという話になってしまいますが、Mapper0 でスクロールがないという意味でドンキーコングが薦められているようです。ファミリーコンピュータのローンチタイトルでもあり高度なことはしていないだろう、という理由もあるかもしれません。残念ながら筆者はカセットを持ってないので詳細は知りません。

このエントリーをはてなブックマークに追加