NES 基礎知識 - メモリ

何回かに分けて NES に関する基礎知識を書いていこうと思います。簡単なリバースエンジニアリングができるようになる程度の内容を予定しています(ゲーム自作までは扱いません、というか扱えません)。ほとんど NesDevWiki に書いてあることばかりなので、ガチ勢の方にとっては特に目新しい内容はないと思います。ツールは好きなものを使えば良いと思いますが、一応 FCEUX を推奨しておきます。

今回はメモリについて扱います。自機座標やHPなどといった「目に見える値」のアドレスを探すだけならこれを知っていれば十分です。

NES の CPU 論理アドレス空間は16bit, 即ち $0000-$FFFF です。大まかなレイアウトは以下のようになっています(NESは memory-mapped I/O なので、I/O レジスタも含まれます):

アドレス 内容
$0000-$07FF RAM
$0800-$1FFF RAM ミラー
$2000-$2007 PPU I/O レジスタ
$2008-$3FFF PPU I/O レジスタ ミラー
$4000-$4017 APU/OAMDMA/コントローラ I/O レジスタ
$4018-$401F 没機能レジスタ (通常は無効)
$4020-$FFFF カートリッジ側で構成

RAM ($0000-$07FF)

$00-$FF, $0100-$01FF といった 256 バイトの塊を「ページ」と呼びます。

カートリッジ側で拡張 RAM が提供されることもあります。その場合、拡張 RAM はアドレス $4020-$FFFF 内のどこかにマップされます(マッパーによるが、$6000-$7FFF になることが多い)。拡張 RAM はバッテリーバックアップ機能などを除けば通常の RAM と同等に扱えます。この項では内蔵 RAM についてのみ述べます。

なお、電源投入時の RAM の状態は一応不定という建前になっていますが、全ビットが独立してランダムというわけではないようです(参考: TASVideos forum の Nach 氏の post)。ただ初代ファミコンニューファミコンでも違いがあったりするので、乱数などを除けば基本的に RAM の初期値に依存するプログラムは書くべきではありません。

ゲーム起動後に特定の値のパターンを RAM へ書き込んでおき、RESET 割り込みハンドラでそのパターンをチェックすることでハードリセットとソフトリセットの(確率的な)判別ができます。これは商用ゲームでもよく使われているテクニックです。

ゼロページ

$00-$FF は「ゼロページ」と呼ばれ、他のページより短いコードサイズで高速にアクセスできます。準レジスタ的な存在といえます。ゼロページには作業用変数や頻繁にアクセスされる変数が置かれることが多いです。また、間接アクセス用のポインタが置かれることがあります(CPU のアドレッシングモードの関係)。

スタック

$0100-$01FF はスタック領域です(スタックは上位アドレスから下位アドレスへ伸びます)。必ずしも全体がスタックとして使われるわけではなく、一部を他の用途に使っているゲームも多いです。その場合、レイアウトは大抵以下のいずれかになります:

       (a)         (b)
$0100  ----------  ----------
       変数用      スタック用
       ----------  ----------
       スタック用  変数用
$01FF  ----------  ----------

いずれの場合も、スタックがオーバーフロー/アンダーフローした場合は当然何らかのバグが起こり得ます。なお、スタックへの push/pop によりページ境界をまたぐ場合はページ内でループします($0100 -> $01FF, $01FF -> $0100)。

スタックは最大でも 256 バイトしかないので、ほとんどのゲームは専らサブルーチン呼び出し/復帰用にスタックを使っており、明示的な変数の退避などはあまり行っていません。また再帰レベルがあまり深くならないようなプログラムになっていることが多いです。

スプライトバッファ

ほとんどのゲームでは $0200-$07FF 内のいずれかのページをスプライトバッファとして使います。これは PPU の OAM (Object Attribute Memory) と同じ構造になっており、DMA 機能によって OAM へ転送されます*1。スプライトバッファは 64 個のスプライト構造体の配列です。スプライト構造体は以下の通り(詳細は PPU の項で述べます):

struct Sprite{
    U8 y;    // 座標 y (画面上では +1 した位置に表示される)
    U8 tile; // タイル ID (スプライトサイズ (8x8/8x16) によって意味が異なる)
    U8 attr; // 属性 (パレット、前面/背面、水平/垂直反転)
    U8 x;    // 座標 x
};

NES の画面サイズは 256x240 なので、座標 y が 239 以上のスプライトは非表示となります。

スプライトバッファはページ 2 ($0200-$02FF) に置かれることが比較的多いようですが、他のページに置いているゲームもあります。また、稀ですがスプライトバッファを 2 つ用意して交互に切り替えながら使っているゲームもあります(『怒』など)。どのページがスプライトバッファかを調べるには、デバッガで $4014 への書き込みを捕捉し、書き込まれる値を見ます。その値がスプライトバッファページとなります(例えば、2 が書き込まれていれば $0200-$02FF がスプライトバッファ)。

多くのゲームではオブジェクト座標などの情報はスプライトバッファとは別に管理しており、スプライトバッファはあくまで表示専用としています。このようなゲームではメモリアドレスを調べる際にスプライトバッファ領域を最初から除外して考えることができます。ただし、メモリ節約のためにスプライトバッファ内の座標をそのままオブジェクト座標管理用に使っているゲームも存在します(『ギャラクシアン』など)。

オブジェクト

ゲーム内オブジェクトデータは $0200-$07FF のどこかに置かれることが多いです(ゼロページに全部置くのは容量的に厳しい)。オブジェクトデータの管理方法はゲームにより様々ですが、大まかには以下の 2 種類に分けられます:

1つのオブジェクトが (state, x, y) で表されるとする。

(a) オブジェクト構造体がそのまま配列化されている
| obj[0].state | obj[0].x | obj[0].y | obj[1].state | obj[1].x | obj[1].y | ...

(b) オブジェクト構造体のメンバごとに配列化されている
| obj[0].state | obj[1].state | ... | obj[0].x | obj[1].x | ... | obj[0].y | obj[0].y | ...

場合によっては自機データのみゼロページに置いたり、ボス専用の属性を別個に管理していたりなどの変形もありえます。

アクションゲームなどの場合、座標がピクセル単位だと動きが粗くなりすぎるので、固定小数点方式のサブピクセルを持っていることが多いです。これは RAM Search を使うか、メモリビューアを眺めていればアドレスの見当がつくことも多いですが、CPU の項で述べる加減算命令を知っていればデバッガを使って探すこともできます。

ミラー領域 ($0800-$1FFF, $2008-$3FFF)

「ミラー」とは、「アドレスは違うが実体は同じ」といった意味です。RAM については:

  • $0800 は $0000 と同じ
  • $0801 は $0001 と同じ
  • $0FFF は $07FF と同じ
  • $1000 は $0000 と同じ

PPU I/O レジスタについては:

  • $2008 は $2000 と同じ
  • $2009 は $2001 と同じ
  • $200F は $2007 と同じ
  • $2010 は $2000 と同じ

といった感じです。ただし、商用ゲームではこれらのミラー領域は基本的に使わないはず(少なくとも私は見たことがない)なので、これを意識する必要があるのは CPU が暴走するようなバグ技を使う場合くらいだと思います。

カートリッジ ($4020-$FFFF)

この領域はカートリッジ側で自由に使えるので、構成はゲームにより様々です。ただしある程度のテンプレ的なものはあり、拡張音源や割り込み機能などを含めたその構成を「マッパー」と呼びます。

どのマッパーであっても $FFFA-$FFFF には共通の役割があり、「割り込みベクタ」と呼ばれます。ここには CPU に対する 3 種類の割り込みを処理するハンドラのアドレスが格納されています:

アドレス 内容
$FFFA NMI 割り込みハンドラのアドレス
$FFFC RESET 割り込みハンドラのアドレス
$FFFE IRQ 割り込みハンドラのアドレス

iNES 形式 (*.nes) の ROM の場合、iNES ヘッダ にマッパー番号が書き込まれています。また、商用ゲームについては NesCartDB にカートリッジデータベースがあります。

マッパー 0

最も基本的なマッパーはマッパー 0 (NROM) で、ファミコン初期のゲームは大体これです。マッパー 0 のメモリマップは以下のようになります:

アドレス 内容
$4020-$7FFF (なし)
$8000-$BFFF PRG-ROM
$C000-$FFFF PRG-ROM

ただし、PRG-ROM が 16KB の場合、ミラーされて $8000-$BFFF と $C000-$FFFF の内容が同じになります。また、『ギャラクシアン』は PRG-ROM が 8KB しかなく、この場合 $8000-$9FFF, $A000-$BFFF, $C000-$DFFF, $E000-$FFFF の内容が全て同じになります。ミラーされたもののうちどれが「本体」かは割り込みベクタを見ればわかります。

後述するバンク切り替え機能がないため、PRG-ROM は 32KB が上限です。

$8000-$FFFF は ROM であり、$4020-$7FFF にはそもそも何も割り当てられていないので、これらの領域に対する書き込みは単に無視されます。

その他のマッパー

大抵のマッパーはマッパー 0 に倣って $8000-$FFFF を PRG-ROM に割り当てています。また、拡張 RAM がある場合は $6000-$7FFF に割り当てられることが多いです。

マッパーによっては「バンク切り替え」という機能があり、これによって $8000-$FFFF 内の一部に割り当てられた PRG-ROM の実体(これを「バンク」と呼ぶ)を動作中に差し替えることができます。これにより 32KB より大きい PRG-ROM を扱えます。ただし、最上位アドレス($FFFA-$FFFF を含む領域)に割り当てられたバンクは基本的に固定です(少なくとも私が見た範囲では)*2。この固定バンクには汎用性の高い重要なルーチンが多く含まれる傾向があります。例えば乱数生成器などは大体固定バンクに置かれていると考えていいでしょう。

例えば、マッパー 1 で拡張 RAM を持つ『ウィザードリィ』のメモリマップは以下のようになっています:

アドレス 内容
$6000-$7FFF 拡張 RAM (バッテリーバックアップ対応)
$8000-$BFFF PRG-ROM (可変)
$C000-$FFFF PRG-ROM (固定)

バンク切り替えの単位はマッパーにより 16KB だったり 8KB だったりまちまちです。同じマッパーでもゲームにより違いがあったりします。

バンク切り替えやその他拡張音源などの機能は、$4020-$FFFF 内に用意された I/O レジスタを読み書きすることでアクセスできます。このレジスタ構成はマッパーごとに異なります。ほとんどのマッパーは NesDevWiki に解説があるので、必要に応じて参照してください。

*1:DMA を使わずソフトウェアで転送することも可能だが、その場合ループを展開しても約 4 倍の時間がかかるため、まず行われない。スプライト数が極端に少ないゲームならありえるかもしれないが…

*2:ユーザがいつリセットボタンを押すかわからないため、割り込みベクタを含む領域を差し替えると対応するバンク全てに割り込みベクタとハンドラを書かなければならないためだと思われる