NES 基礎知識 - PPU
今回は PPU について扱います…が、正直難しいのでゲーム内のロジックを調べたいだけなら完全に理解する必要はないと思います(私もきちんと理解してません)。完全に理解したければ NesDevWiki の PPU の項目などを参照。
NTSC を前提とします。PAL などはよくわからないので扱えません。
まず、PPU と CPU (と、APU)は並列に動作しています。3 PPU サイクル = 1 CPU サイクルです。
NES の解像度は 256*240 *1ですが、内部的には 341*262 とみなすことができ、1 PPU サイクルで 1 dot 処理されます。そして、341*262 = 89342 PPU サイクルが 1 フレームとなります(厳密には偶奇フレームで 1 PPU サイクル変動します)。
341 dot の水平線 1 本を「スキャンライン」または単に「ライン」と呼びます。各ラインは以下のように分類されます:
- line 0-239: visible scanline と呼ばれます。この期間内に 256*240 の描画が行われます。描画と干渉するので、CPU は PPU メモリにアクセスすべきではありません(描画オフでない限り)。
- line 240: post-render scanline と呼ばれます。この期間は PPU はアイドル状態です。
- line 241-260: vertical blanking line と呼ばれます(いわゆる VBLANK)。line 241 で VBLANK フラグが立ち、NMI 割り込みが発生します。CPU による PPU メモリへのアクセスはこの期間に行うべきです。サイクル計算すると 341*20 = 6820 PPU サイクルなので、換算すると約 2273 CPU サイクルの猶予があるということになります。
- line 261: pre-render scanline と呼ばれます。ここで VBLANK フラグが下ろされます。描画は行いませんが、line 0 の描画準備を行うため、CPU は PPU メモリにアクセスすべきではありません(描画オフでない限り)。
各ラインの各ドットで何が起こるのかについては、NesDevWiki の PPU rendering の項目や Ntsc timing.png の図を参照。
エミュレータの場合、フレーム境界は line 240 あたりに設定するのが妥当と思われます(入力処理の関係)。FCEUX もそうなっています。
PPU の描画要素は BG とスプライトに分けられます。BG は 8*8 px のタイルを敷き詰めた画像で、スクロールはできますが、タイル境界という制約があります。スプライトは 8*8 px または 8*16 px の画像で、画面上の好きな位置に表示できますが、最大 64 個しか持てず、さらに横方向の最大表示数は 8 個までという制約があります。大抵のエミュレータでは BG / スプライトの描画を個別にオン/オフできるようになっています。
メモリマップ
PPU アドレス空間は 14bit, 即ち $0000-$3FFF です。レイアウトは以下の通り:
アドレス | 内容 |
---|---|
$0000-$0FFF | パターンテーブル 0 |
$1000-$1FFF | パターンテーブル 1 |
$2000-$23FF | ネームテーブル 0 |
$2400-$27FF | ネームテーブル 1 |
$2800-$2BFF | ネームテーブル 2 |
$2C00-$2FFF | ネームテーブル 3 |
$3000-$3EFF | 通常は $2000-$2EFF ミラー |
$3F00-$3F1F | パレット |
$3F20-$3FFF | $3F00-$3F1F ミラー |
$0000-$3EFF はカートリッジ側で自由に構成できます。ただし $3000-$3EFF は PPU の描画には使われないため、通常は $2000-$2EFF のミラーとなっています。よって、カートリッジ側では普通 $0000-$2FFF を構成します。
パターンテーブルは BG / スプライトのタイルパターンを格納します。ここは通常 CHR-ROM または CHR-RAM であり、マッパーによってはバンク切り替えで動的に内容が差し替えられます。
ネームテーブルは BG を構成するバッファです。ここは多くのマッパーでは NES の内蔵 VRAM (2KB) を利用するようになっていますが、カートリッジ側で用意した VRAM をマップすることも可能です。
パターンテーブル
パターンテーブルは $0000-$0FFF, $1000-$1FFF の 2 つあります。
各パターンテーブルは 8*8 px のタイルパターンを 256 個格納しています。タイルパターンは 2bpp なので、1 つのタイルパターンは 16 バイトであり、2 つのプレーンから構成されます。1 つ目のプレーンは色の bit0, 2 つ目のプレーンは bit1 を表します。1 つのタイルを例示します:
Bit Planes Pixel Pattern $0xx0=$41 01000001 $0xx1=$C2 11000010 $0xx2=$44 01000100 $0xx3=$48 01001000 $0xx4=$10 00010000 $0xx5=$20 00100000 .1.....3 $0xx6=$40 01000000 11....3. $0xx7=$80 10000000 ===== .1...3.. .1..3... $0xx8=$01 00000001 ===== ...3.22. $0xx9=$02 00000010 ..3....2 $0xxA=$04 00000100 .3....2. $0xxB=$08 00001000 3....222 $0xxC=$16 00010110 $0xxD=$21 00100001 $0xxE=$42 01000010 $0xxF=$87 10000111 ※https://wiki.nesdev.com/w/index.php/PPU_pattern_tables より転載
マッパーによってはここに CHR-RAM が割り当てられていたり、バンク切り替え機能があったりします。
BG
BG は 4 つのネームテーブルによって構成されます。各ネームテーブルは 32*30 = 960 個のタイル ID、および 64 バイトの属性テーブルを格納しています。1 タイルは 8*8 px なので、1 つのネームテーブルで 256*240 px を表現できます。
各ネームテーブルと PPU アドレスとの対応関係を図示します(上下/左右はループしています):
(0,0) (256,0) (511,0) +-----------+-----------+ | | | | | | | $2000 | $2400 | | | | | | | (0,240)+-----------+-----------+(511,240) | | | | | | | $2800 | $2C00 | | | | | | | +-----------+-----------+ (0,479) (256,479) (511,479) ※https://wiki.nesdev.com/w/index.php/PPU_nametables より転載
ただし、NES の内蔵 VRAM を利用する場合、容量が 2KB しかないため、実際には 2 つのネームテーブルしか使えず、残り 2 つはミラーとなります。多くのマッパーでは以下のいずれかのミラーリングを選択します:
- Horizontal mirroring - $2000 == $2400, $2800 == $2C00 となる。縦スクロールのゲームは多分これ。
- Vertical mirroring - $2000 == $2800, $2400 == $2C00 となる。横スクロールのゲームは多分これ。
しかし、このアドレス領域はカートリッジ側で(内蔵 VRAM を利用するかどうかによらず)自由に構成できるので、他の構成もありえます。詳細は NesDevWiki の Mirroring の項目などを参照。
属性テーブルは 2*2 タイル (16*16 px) 単位でパレットを指定するものです。詳細は NesDevWiki の PPU attribute tables の項目を参照。
PPU のスクロール機能により、上に示した 512*480 の画像の中から 256*240 の範囲を BG として表示できます。また、描画中にスクロールオフセットを変更することで部分的なスクロールも可能です(いわゆるラスタスクロール。ただし横ラスタ限定)。
スプライト
スプライトは PPU 内部の OAM (Object Attribute Memory, 256 バイト) で管理されます。OAM はスプライト構造体 64 個の配列です。スプライト構造体は 4 バイトで、内容は以下の通り:
struct Sprite{ U8 y; U8 tile; U8 attr; U8 x; };
Sprite.y
座標 y です。ただしスプライトは 1 ライン遅れて表示されるため、実際には +1 された位置に表示されます。よって、y=0 の位置にスプライトを表示することはできず、画面上端からはみ出して表示することもできません。NES の解像度は縦 240 ラインなので、この値を 239 以上にするとスプライトは表示されません(プログラム上では大抵この方法で非表示にしています)。
Sprite.tile
タイル ID 指定です。スプライトサイズによって値の意味が異なります。
スプライトサイズ 8*8 の場合、タイル ID そのものです(パターンテーブルはレジスタ $2000 で選択します)。
スプライトサイズ 8*16 の場合、値の意味は以下のようになります(レジスタ $2000 によるパターンテーブル選択は無視されます):
bit 76543210 TTTTTTTP P: パターンテーブル選択。0:$0000, 1:$1000 T: スプライト上半分のタイル ID を 2*T とし、下半分を 2*T+1 とする
Sprite.attr
bit 76543210 VHP...CC V: 垂直反転 H: 水平反転 P: 優先度 (0:前面, 1:背面) C: パレット
Sprite.x
座標 x です。画面左端からはみ出して表示することはできません。ただし、レジスタ $2001 で画面左端クリッピングを設定することで擬似的に同じ効果が得られます。
OAM への書き込みには大抵レジスタ $4014 による DMA 転送が用いられます。
0 番スプライトはタイミング制御に使うことができます(いわゆる「0 爆弾」)。BG 描画中に 0 番スプライトと衝突が起こると内部でフラグが立ち、レジスタ $2002 を通じてこれを検出できます。詳細は NesDevWiki の PPU OAM#Sprite_zero_hits などを参照。
パレット
パレットは PPU 内部の専用 RAM で管理されており、PPU アドレス $3F00-$3F1F にマップされています。レイアウトは以下の通り:
アドレス | 内容 |
---|---|
$3F00 | 背景色 |
$3F01-$3F03 | BG パレット 0 |
$3F04 | 空き領域 |
$3F05-$3F07 | BG パレット 1 |
$3F08 | 空き領域 |
$3F09-$3F0B | BG パレット 2 |
$3F0C | 空き領域 |
$3F0D-$3F0F | BG パレット 3 |
$3F10 | $3F00 ミラー |
$3F11-$3F13 | スプライトパレット 0 |
$3F14 | $3F04 ミラー |
$3F15-$3F17 | スプライトパレット 1 |
$3F18 | $3F08 ミラー |
$3F19-$3F1B | スプライトパレット 2 |
$3F1C | $3F0C ミラー |
$3F1D-$3F1F | スプライトパレット 3 |
$3F10 が $3F00 のミラーであることには注意が必要です。配列コピーで PPU $3F00-$3F1F を埋めるようなコードはよくありますが、このとき $3F10 に $3F00 と違う色を書き込むと背景色が変わってしまうことになります。
パレットには NES の色 ID が格納されます。色 ID は 6bit で、明度 2bit / 色相 4bit の形式です:
bit 76543210 ..VVHHHH V: 明度 H: 色相 ※上位 2bit は書き込んでも無効で、読み取りは常に 0 を返す
具体的な色見本は NesDevWiki の PPU palettes#2C02 などを参照。なお、色 ID 0x0D は TV によっては問題が起こるため、使うべきではないそうです。
I/O レジスタ
$2000 - PPU_CTRL (write)
bit 76543210 VPHBSINN V: VBLANK 開始時に NMI 割り込みを発生 (0:off, 1:on) P: PPU マスター/スレーブ (コードを読む分には気にする必要なし) H: スプライトサイズ (0:8*8, 1:8*16) B: BG パターンテーブル (0:$0000, 1:$1000) S: スプライトパターンテーブル (0:$0000, 1:$1000) I: PPU アドレスインクリメント (0:+1, 1:+32) - VRAM 上で +1 は横方向、+32 は縦方向 N: ネームテーブル (0:$2000, 1:$2400, 2:$2800, 3:$2C00)
PPU 内部の VBLANK フラグが立っている状態で NMI を許可すると直ちに NMI 割り込みが発生します。これは既に NMI 割り込みが発生していても同様なので注意が必要です。例えば以下のようなシナリオが考えられます:
- NMI 割り込みが発生
- NMI ハンドラ先頭で NMI を禁止
- VBLANK フラグが下ろされる前に NMI ハンドラの処理本体が終了
- NMI ハンドラから抜ける前に NMI を許可 <- ここで NMI が再生成される
NMI を許可する前に $2002 を読み取って VBLANK フラグを下ろすことでこの問題を回避できます。
ネームテーブル選択はスクロール指定の一部と考えることもできます。
大抵のゲームでは $2000 の値を退避する変数を設けています(NMI 許可状態など、一部のみを変更したいというケースがあるため)。
$2001 - PPU_MASK (write)
bit 76543210 BGRsbMmG B: 色強調(青) - 表示のみに影響すると思います。詳しくは知りません^^; G: 色強調(緑) - 同上 R: 色強調(赤) - 同上 s: スプライト描画 (0:off, 1:on) b: BG 描画 (0:off, 1:on) M: 画面左端 8px でスプライトクリッピング (0:有効, 1:無効) m: 画面左端 8px で BG クリッピング (0:有効, 1:無効) G: 0:カラー, 1:モノクロ
大抵のゲームでは $2001 の値を退避する変数を設けています(描画オン/オフなど、一部のみを変更したいケースがあるため)。
$2002 - PPU_STATUS (read)
PPU の状態を取得できます。また、このレジスタの読み取りには副作用があります。まず値の意味を示します:
bit 76543210 VSO..... V: VBLANK フラグ S: Sprite 0 hit O: スプライトオーバーフローフラグだがバグがある。気にする必要なし 全てのビットは line 261 dot 1 でクリアされる。
このレジスタを読み取ると直ちに VBLANK フラグが下ろされます。また、$2005 / $2006 の書き込み状態がリセットされます。
このレジスタに関して興味があるのは bit7, bit6 だけなので、読み取る際は BIT 命令を使うと CPU レジスタを破壊せず、and 命令も省略できてスマートです。VBLANK および Sprite 0 hit を待つコード例を示します:
wait_vblank: bit $2002 bpl wait_vblank
; line 261 でクリアされるのを待ってから次の Sprite 0 hit を待つ wait_sprite0clear: bit $2002 bvs wait_sprite0clear wait_sprite0hit: bit $2002 bvc wait_sprite0hit
これは読み取り専用レジスタですが、まれに書き込みを行っているゲームがあります(『アルカノイド』など)。おそらく書き込みは何の効果もないと思われます。
$2003 - OAM_ADDR (write)
読み書きする OAM アドレスを指定します。しかし普通は OAM へのデータ転送には DMA を利用するので、このアドレスには単に 0 を書き込み、直後に $4014 へ書き込んで DMA 転送を行うゲームが多いです。
なお、DMA 転送の前にこのアドレスに書き込む値を毎フレーム変えているゲームもあります。これはスプライト優先順位を循環させることで、スプライトが 9 個以上横に並んだとき完全に見えなくなるのを防止する意図と思われますが、NesDevWiki によると 非推奨 のテクニックとのことです。
$2004 - OAM_DATA (read/write)
OAM 読み書き用ですが、普通は DMA でデータ転送するので気にする必要はありません。
$2005 - PPU_SCROLL (write * 2)
BG のスクロールオフセットを指定します。これは 2 度書きレジスタで、1 回目の書き込みで x, 2 回目の書き込みで y を指定します。
書き込み状態(1 回目 / 2 回目)は内部的に $2006 と共有する形で記憶されていますが、$2002 を読み取ることで状態をリセットできます。
大抵のゲームではスクロールオフセット x, y を退避する変数を設けています。これは、$2005 と $2006 は内部的に状態を共有しているため、$2006 へ書き込んだ後は $2005 の再設定が必要になるという理由が大きいと思われます。
$2006 - PPU_ADDR (write * 2)
読み書きする PPU アドレスを指定します。これは 2 度書きレジスタで、1 回目の書き込みで上位バイト、2 回目の書き込みで下位バイトを指定します。
書き込み状態(1 回目 / 2 回目)は内部的に $2005 と共有する形で記憶されていますが、$2002 を読み取ることで状態をリセットできます。
$2005 と $2006 は内部的に状態を共有しているので、$2006 へ書き込んだ後は $2005 の再設定が必要です。
$2007 - PPU_DATA (read/write)
PPU アドレス読み書き用です。$2006 で指定したアドレスに対して読み書きを行った後、内部 PPU アドレスが $2000 に従ってインクリメントされます(+1 または +32)。前述の通り、描画中に読み書きを行うべきではありません。
大抵は書き込み用にのみ使われますが、たまに CHR-ROM 領域にデータを格納しているゲームがあり、その場合は読み取りにも使われます。ただし読み取りにおいては内部バッファが 1 回遅れで更新されるため、最初に 1 回空読みを入れる必要があります(厳密には PPU $0000-$3EFF に対してのみ空読みが必要。詳細は NesDevWiki の解説を参照)。
$4014 - OAM_DMA (write)
OAM への DMA 転送を行います。書き込んだ値が転送元ページとなります。例えば 2 を書き込むと CPU アドレス $0200-$02FF の内容を OAM へ転送します。
DMA 転送は $2003 で指定された OAM アドレスに対して行われるので、事前に $2003 に 0 を書き込んでおくのが普通です。一応コードを示すと:
lda #0 sta $2003 lda #2 sta $4014
DMA 転送には 513 または 514 CPU サイクルかかります(どちらになるかは CPU サイクルの偶奇によるとのことです)。
*1:256*240 全体が映るわけではなく、TV により端が幾分欠ける(いわゆるオーバースキャン領域)。よって、画面端ギリギリには重要な情報を表示しないのが不文律となっている
NES 基礎知識 - コントローラ
今回は NES/FC の標準コントローラについて扱います。特殊コントローラ(アルカノイドパドルなど)は扱いません。
標準コントローラには I/O レジスタ $4016, $4017 経由でアクセスします。NES と FC で若干細部が異なりますが、基本は同じです。詳細は NesDevWiki の資料を参照。
まず、入力を読み取る前に準備が必要です。$4016 に 1, 0 の順で書き込むことで 1P/2P 両方の入力読み取り準備が完了します。
その後、1P については $4016, 2P については $4017 を読み取ることで 1 回ごとにボタン 1 つの入力状態が得られます(A, B, S, T, U, D, L, R の順)。よって、全ボタンを読み取るには 8 回の読み取りが必要です。大半のゲームでは 1 バイト変数内の各ビットで各ボタンの入力状態を管理しています。
$4016, $4017 読み取り時のデータフォーマットを示します(FC の場合):
$4016 (read) ------------ bit 76543210 .....MFD M: マイク入力があれば 1, なければ 0 F: 拡張ポート (3P) のボタンが押されていれば 1, さもなくば 0 D: ボタンが押されていれば 1, さもなくば 0 $4017 (read) ------------ bit 76543210 ......FD F: 拡張ポート (4P) のボタンが押されていれば 1, さもなくば 0 D: ボタンが押されていれば 1, さもなくば 0
要するに、マイクや拡張ポートを無視すれば、見る必要があるのは最下位ビットのみです。1P 入力を読み取る最小限のコードを示します:
; 1P 入力を読み取って $00 に保存する(形式: ABSTUDLR) ; 読み取り準備。$4016 に 1, 0 の順で書き込む ldx #1 stx $4016 dex stx $4016 ; 1 ボタンずつ読み取り、結果のビットを $00 へシフトインしていく ; 擬似コードで書くと: ; ; for(X = 8; X != 0; --X) ; A = read(0x4016) ; C = A & 1 ; mem[0x00] <<= 1 ; mem[0x00] |= C ldx #8 @loop: lda $4016 lsr ; ここでキャリーフラグ C に入力状態が入る rol $00 ; ローテートにより C がシフトインする dex bne @loop
ここでは入力記録用変数 $00 を ABSTUDLR 形式としましたが、もちろん RLDUTSBA 形式でもかまいません(rol を ror に変えるとそうなります)。商用ゲームではどちらの形式も採用されています。
上では簡略化したコードを示しましたが、実際のゲームでは各ボタンを読み取る際に bit0 だけでなく bit1 も調べて両者の OR を入力状態としていることがほとんどです。つまり拡張ポート入力に対応しているということです。
また、特に RPG などではボタンを「離してから押す」ことではじめて入力を認識する方が都合がいいことが多いので、前フレームの入力との XOR をとって「新規入力」を計算するコードが含まれていることが多いです。
大半のゲームでは(まれにマイクを含む)入力状態記録専用の変数群を設けているので、アセンブリ全体からこれらの変数を検索することで裏技探しができたりします。例えば 1 人用ゲームなのに 2P 入力状態を参照しているコードがあったらそれは裏技関連かもしれない、など。
なお、$4017 は読み取り時は 2P 入力状態を返しますが、書き込み時は全く役割が異なる(APU 制御用)ので注意。
NES 基礎知識 - コード読解
今回は CPU の項を踏まえた上で具体的なコード例などを見ていきます。ここで挙げるコードが自力で読めればハードウェアの絡まないロジックを追うには十分だと思います(どのゲームでも基本的にあまり難解なアルゴリズムは使われていません)。
コード例
配列アクセス
配列アクセスには indexed (lda $0700,x など)または indirect (lda ($00),y など)アドレッシングモードを使うのが普通です。
配列のベースアドレスが固定ならば indexed で書けます。配列コピーの例を示します:
; $0300-$030F を $0400-$040F へコピー ; 擬似コード化すると: ; ; for(X = 0x10; X != 0; --X) ; mem[0x0400 + X] = mem[0x0300 + X] ldx #$10 @loop: lda $0300,x sta $0400,x dex bne @loop
配列のベースアドレスが可変の場合、ゼロページ内の変数にポインタを格納し、indirect アクセスを行います。コード例を示します:
; ポインタ $00 が指す領域から $0400-$040F へ 0x10 バイトコピー ; 擬似コード化すると: ; ; ptr = mem[0x00] | (mem[0x01] << 8) ; for(Y = 0; Y != 0x10; ++Y) ; mem[0x0400 + Y] = mem[ptr + Y] ldy #0 @loop: lda ($00),y sta $0400,y iny cpy #$10 bne @loop
構造体アクセス
構造体アクセスには indirect アドレッシングモードがよく使われます。オブジェクトリストを構造体の配列で管理しているゲームでは大抵以下のようなコードが出てきます:
; オブジェクト構造体が以下のようであるとする: ; ; struct Object{ ; U8 state; ; U8 position_x; ; U8 position_y; ; U8 velocity_x; ; U8 velocity_y; ; }; ; ; ポインタ $00 がオブジェクト構造体の先頭を指しているとする。 ldy #0 lda ($00),y ; オブジェクト状態取得 ... iny lda ($00),y ; オブジェクト座標x取得 iny lda ($00),y ; オブジェクト座標y取得 ... iny lda ($00),y ; オブジェクト速度x取得 iny lda ($00),y ; オブジェクト速度y取得
8bit 絶対値
; 8bit 絶対値を得るルーチン (A は符号付き数とする) ; A = abs(A) ; ; 注意: A == -128 の場合はそのまま -128 が返る abs8: ; 符号なし比較で 0x80 より小さければ正なのでそのまま返す cmp #$80 bcc @end ; 負の場合、2 の補数をとって正に直す ; ビット反転して 1 を加えると 2 の補数が得られる eor #$FF ; ビット反転。not 命令がないので eor を使う clc adc #1 @end: rts
なお、符号なし比較で A >= 0x80 の場合、最初の cmp #$80
でキャリーフラグ C が真になるので、2 の補数をとる際の clc を省略して以下のように書くこともできます:
abs8: cmp #$80 bcc @end eor #$FF adc #0 ; ここでは C == 1 になっているはずなので、これで 1 が加えられる @end: rts
定数乗除算
6502 には乗除算命令がないため、自分で実装する必要があります。任意の値の乗除算は専用ルーチンが必要ですが、定数乗算および 2 のべき乗での除算はシフト命令を使って行えます。
例えば、A に 5 を掛けるには以下のようにします:
; 作業用変数に A の値をコピー sta $00 ; A <<= 2, つまり A *= 4 asl asl ; 元の値を加える。これで A は元の値の 5 倍となる clc adc $00
符号なし数 A を 8 で割るには以下のようにします:
; A >>= 3, つまり A /= 8 lsr lsr lsr
16bit 数などの場合はローテート命令を併用した多倍長シフトを行えばOK。
乗除算
任意の値の乗除算には専用ルーチンが必要です。といっても、内容的には小学校で習う筆算を 2 進数で行うだけです(私が見た範囲ではどのゲームも同じような実装になっています)。
例えば、4bit * 4bit の符号なし乗算は以下のように筆算できます:
1001 * 1011 ---- 1001 1001 1001 ------- 1100011
これをルーチン化した例を示します (8bit * 8bit -> 16bit 符号なし乗算):
; 符号なし乗算 (8bit * 8bit -> 16bit) ; ; In ; $0300 U8 左辺 ; $0301 U8 右辺 ; Out ; $0302 U16 結果 ($00 * $01, リトルエンディアン) ; ; $00: 作業用変数 (16bit, リトルエンディアン) ; $02: 作業用変数 (8bit) mul_u8_u8_u16: ; 結果 (result) を 0 で初期化 lda #0 sta $0302 sta $0303 ; 左辺を 16bit 変数 $00 に代入 (lhs_tmp) lda $0300 sta $00 lda #0 sta $01 ; 右辺を 8bit 変数 $02 に代入 (rhs_tmp) lda $0301 sta $02 ; 右辺の各ビットを見ていき、1 なら加算を行う要領 ; 擬似コードにすると以下のような感じ ; ; for(X = 8; X != 0; --X) ; C = rhs_tmp & 1 ; rhs_tmp >>= 1 ; if(C) ; result += lhs_tmp # 16bit ; lhs_tmp <<= 1 # 16bit ldx #8 @loop: lsr $02 bcc @loop_next lda $0302 ; 多倍長加算 clc adc $00 sta $0302 lda $0303 adc $01 sta $0303 @loop_next: asl $00 ; 多倍長シフト rol $01 dex bne @loop rts
除算も考え方自体は同様なので省略。慣れればなんとなく乗除算っぽいコードは見当がつくので、詳しく読む前にいったんデバッガでブレークを仕掛けて、"Step Out" でルーチン終了まで実行させ、期待通りの結果になっているかメモリビューアでチェックする、というのが手っ取り早いかもしれません。
10 進表示
2A03 には decimal mode がないため、10 進表示は自分で実装する必要があります。コードは省略しますが、10 進表示が必要な値は大抵以下のどちらかの方法で管理されています:
- 値を 10 進 (BCD) で保持し、加減算などは自分で実装する
- 値はそのまま保持し、表示する際に 10 進変換する
後者の 10 進変換については、例えば 16bit 値なら最大 5 桁なので、単純に
- 10000 で割る (除算ルーチンを使うか、単純にループで引けるだけ引く)
- 1000 で割る
- …
といった処理で変換できます。もっとうまい方法もありそうですが、私が見た範囲では全てこうなっていました。
引数を伴う JSR
JSR 命令の直後にデータ列を配置し、呼び出し先ルーチンから参照する、というテクニックがあります。これを読み解くにはスタックに関する理解が必要です。
JSR の直後にジャンプテーブルを配置し、指定ルーチンを呼び出す例を示します:
; ジャンプテーブル内の mem[0x00] 番目のルーチンへジャンプする ; jsr から戻ってこないことに注意! lda $00 jsr jump ; ここで (jsr 命令のアドレス) + 2 がスタックに積まれる dw routine0 ; dw は word 型(2バイト)データ。ここではポインタ dw routine1 dw routine2 ... ; jsr の直後に配置されたジャンプテーブル内の A 番目のルーチンへジャンプ jump: ; ジャンプテーブル内オフセットを計算 ; (スタックに積まれた戻りアドレス) + 1 がジャンプテーブル先頭になることに注意 ; Y = 2*A + 1 asl tay iny ; 戻りアドレスを pop し、ポインタ $00 に格納 ; この時点で jsr から普通には戻ってこないとわかる pla sta $00 pla sta $01 ; ジャンプテーブルから指定ルーチンを取得し、ポインタ $02 に格納 lda ($00),y sta $02 iny lda ($00),y sta $03 ; 指定ルーチンへジャンプ jmp ($0002)
このテクニックは初期任天堂ゲーム(SMB1 など)でよく使われています。
解析手順
ゲーム内の特定のロジックを調べたいだけならアセンブリを全部読む必要はなく、大抵デバッガだけで事足ります。まず RAM Search などで事前に目に見える値のアドレスを調べておき、そのアドレスにアクセスするコードをデバッガのブレークポイント機能で捕捉して読む、というのが基本です。
例えば RPG のダメージ計算について調べたいのであれば、HP のアドレスを調べておき、そのアドレスへの書き込みをデバッガで捕捉し、周辺のコードを読む、といった感じです。このときスタックを見ればそのルーチンがどこから呼ばれているかわかるので、必要に応じて呼び出し履歴を遡ることもできます。
慣れてきたら適当な逆アセンブラでアセンブリを出力し、自分なりにコメントを付けていくようにすると解析が捗ると思います。
手掛かりがつかみにくいロジックについては、トレースログ機能の利用も考慮するといいかもしれません。例えばアイテムドロップのように成功と失敗の 2 択であるような現象ならば、成功例と失敗例のムービーを撮り、両方について同じタイミングから数フレームのトレースログをとって比較すれば手掛かりが得られる可能性があります。
NES 基礎知識 - CPU
今回は CPU について扱います。NES の PPU や APU はかなり複雑ですが、CPU は単純なので理解は容易です。CPU さえ理解しておけば乱数などの「目に見えない値」を探したり、ダメージ計算などの内部ロジックを追うには十分です。
ロジックを追う際は別にアセンブリ全体を読む必要はなく、デバッガで適当にブレークを仕掛けてその周辺を読む程度で大抵は事足ります。NES ゲームは大半がアセンブラ直書きだと思われるので、人間には読めないようなコードはそうそう出てこないはずです。PPU や APU を扱うコードを除けばそんなに凝ったアルゴリズムも使われていない様子です。
NES の CPU は 6502 カスタムの 2A03 です。オリジナル 6502 からの変更点は:
- Decimal mode (10進演算)の削除
- サウンド、DMA, コントローラ制御機能追加
といったところですが、コードを読むだけなら 6502 と同様に扱えます(以下、特に区別する必要がなければ 2A03 ではなく 6502 と書いてしまいます)。
まず 6502 の基本事項をまとめておきます:
- 命令長は 1~3 バイト
- スタックは上位アドレスから下位アドレスへ伸びる
- ポインタはリトルエンディアン
- 負数は 2 の補数で表す
プログラム側でのエンディアンは特に CPU に合わせなくても不都合は生じないので、16bit数をビッグエンディアンで管理したりしているゲームもあります。ただ全体的にはリトルエンディアンで統一しているゲームの方が多いようです。負数は 2 の補数を採用しているゲームが多く、その方が便利だと思いますが、たまに符号ビット+絶対値方式などを採用しているゲームもあります(『アストロロボSASA』など)。
レジスタ
6502 には以下のレジスタがあります:
名前 | サイズ | 用途 |
---|---|---|
PC | 16bit | プログラムカウンタ。実行中アドレスを指す |
A | 8bit | アキュムレータ。演算用 |
X | 8bit | インデックス。配列アクセスやその他雑用 |
Y | 8bit | X とほぼ同じ |
S | 8bit | スタックポインタ |
P | 8bit | ステータスレジスタ。後述 |
プログラム中で明示的に扱うのはほぼ A, X, Y のみです。PC はジャンプ命令などで暗黙的に変更されます。S は大抵 RESET ハンドラ先頭で初期設定され、以降は意識することはありません。P は主に分岐命令で暗黙的に参照されます。
ステータスレジスタ
ステータスレジスタ P は主に条件分岐用で、ビットレイアウトは以下の通りです(実質 6bit)*1:
bit 76543210 NV..DIZC
bit | 内容 |
---|---|
N | ネガティブフラグ。値が負(最上位ビットが立っている)なら 1, さもなくば 0 |
V | オーバーフローフラグ。符号付きオーバフローが発生したら 1, さもなくば 0 |
D | デシマルフラグ。2A03 には decimal mode がないので無意味 |
I | IRQ 割り込み禁止フラグ。1 で禁止、0 で許可 |
Z | ゼロフラグ。値が 0 なら 1, さもなくば 0 |
C | キャリーフラグ。符号なしオーバーフローが発生したら 1, さもなくば 0 |
最もよく使われるのは N, Z フラグで、これらは大抵の命令において処理した値に応じて変化します。次によく使われるのが C フラグで、主に加減算とビットシフト/ローテート用です。とりあえず以上の 3 つだけ覚えておけば大体事足ります。
NES ゲームでは符号付き数はそこまで多用されないので V フラグはあまり使われません。I フラグは IRQ 割り込みを使わないゲーム(結構多い)では意識する必要はありません。
命令
本来はここでアドレッシングモードの説明を入れるところですが、具体例がないとわかりにくいので後回しにします。ここでは基本的に absolute アドレッシングモード(要するに 16bit アドレス指定)のみを使います。
ここでは非公式命令は扱いません。ほとんどの商用ゲームは非公式命令を使っていないので、普通は意識する必要はないはずです。暴走系のバグ技を使うなどの理由で必要な場合は NesDevWiki の資料などを参照。
リファレンス形式の資料としては http://obelisk.me.uk/6502/reference.html などがあります。また、たまに機械語の読み書きが必要になることがあるので http://www.oxyron.de/html/opcodes02.html のオペコード表の存在も覚えておくと便利かもしれません。
レジスタへのロード
LDA, LDX, LDY 命令で A, X, Y レジスタへ値をロードできます。"LOAD A", “LOAD X”, “LOAD Y” と覚えるといいです。
lda $0700 ; A = mem[0x0700]
ロード命令は N, Z フラグを書き換えます:
- N: ロードした値の最上位ビット
- Z: ロードした値が 0 であるか
メモリへのストア
STA, STX, STY 命令で A, X, Y レジスタの値をメモリへ書き込めます。"STORE A", “STORE X”, “STORE Y” と覚えるといいです。
sta $0700 ; mem[0x0700] = A
ストア命令はフラグを変更しません。
レジスタ間コピー
T** 命令でレジスタ間コピーができます。"Transfer A to X" のように覚えるといいです。
- TAX: A -> X
- TAY: A -> Y
- TXA: X -> A
- TXS: X -> S
- TYA: Y -> A
- TSX: S -> X
スタックポインタ S の設定/取得は TXS, TSX 命令を用いて X レジスタ経由で行うことになります。
TXS はフラグを変更しません。その他の命令は N, Z フラグを書き換えます:
- N: コピーした値の最上位ビット
- Z: コピーした値が 0 であるか
スタック操作
PHA, PHP で A, P レジスタをスタックへ push します。"PUSH A", “PUSH P” と覚えるといいです。
PLA, PLP で A, P レジスタをスタックから pop します。"PULL A", “PULL P” と覚えるといいです。
PLA は N, Z フラグを書き換えます:
- N: pop した値の最上位ビット
- Z: pop した値が 0 であるか
A, P 以外のレジスタやメモリ上の値は直接 push/pop できないので、いったん A へコピーしてから push/pop します:
; PUSH(X) txa pha ... ; X = POP() pla tax
6502 はスタックサイズが小さいため、これらの命令で明示的にスタックを操作することはあまりありません。ただし NMI/IRQ 割り込みハンドラでは割り込みから正しく復帰するために A, X, Y レジスタを push/pop するのが普通です。
フラグ操作
V, D, I, C フラグはプログラムから明示的に操作できます。V, D, I は割とどうでもいいですが、C の操作は頻出するので覚えておく必要があります。"SET C", “CLEAR C” のように覚えるといいです。
インクリメント/デクリメント
メモリ上の値に対しては INC, DEC を使います。
X レジスタに対しては INX, DEX を使います。
Y レジスタに対しては INY, DEY を使います。
これら全ての命令は N, Z フラグを書き換えます(加減算命令と異なり、V, C フラグは変化しないことに注意):
- N: 演算結果の最上位ビット
- Z: 演算結果が 0 であるか
A レジスタに対するインクリメント/デクリメント命令は存在しないので、加減算命令で代用します。
加算
ADC 命令でキャリー付き加算ができます。"ADD with Carry" と覚えるといいです。
これは A に対象を加算し、さらにキャリーフラグ C を加算します。ここでは「キャリー」=「繰り上がり」と考えるとわかりやすいです。要するに繰り上がりがあればさらに 1 を加えるというだけです。
通常の(キャリーなし)加算がしたければ、事前に C フラグをクリアしておく必要があります:
; A += mem[0x0700] clc adc $0700
ADC 命令は N, V, Z, C フラグを書き換えます:
- N: 加算結果の最上位ビット
- V: 符号付きオーバーフローが発生したか(同符号の値を加算した結果符号が変わったら真)
- Z: 加算結果が 0 であるか
- C: 繰り上がりが発生したら 1, さもなくば 0
キャリー付き加算は多倍長演算のためにあります。例えば、16bit 値 $0700 にもう 1 つの 16bit 値 $0702 を加えるには以下のようにします(リトルエンディアンを仮定):
; 下位バイトを加算(キャリーなし) lda $0700 clc adc $0702 ; ここで繰り上がりがあれば C = 1, さもなくば C = 0 となる sta $0700 ; 上位バイトを加算(キャリー付き) lda $0701 adc $0703 ; 繰り上がりがあればさらに 1 加える(clc を行っていないことに注意) sta $0701
24bit や 32bit の値なら更にキャリー付き加算を繰り返すだけです。この形のコードは頻出するので、慣れれば一目で「これは 16bit 値だな」などとわかるようになります。つまりサブピクセルなどが探しやすくなるわけです。
なお、他の CPU と異なり、キャリーなし加算を行う専用の命令はありません。
減算
SBC 命令でキャリー付き減算ができます。"SUBTRACT with Carry" と覚えるといいです。
これは A から対象を減算し、さらにキャリーフラグ C の否定を減算します。これだと少しわかりにくいので、「ボロー」という概念を導入すると理解しやすいです。「ボロー」は「キャリー」の反対、つまり繰り下がりです。要するに繰り下がりがあればさらに 1 を引くというだけです。
通常の(ボローなし)減算がしたければ、事前に C フラグをセットしておく必要があります(ADC の時とは逆):
; A -= mem[0x0700] sec sbc $0700
SBC 命令は N, V, Z, C フラグを書き換えます:
- N: 減算結果の最上位ビット
- V: 符号付きオーバーフローが発生したか(異符号の値を減算した結果符号が変わったら真)
- Z: 減算結果が 0 であるか
- C: 繰り下がりが発生したら 0, さもなくば 1
ADC の時と同様に、ボロー付き減算によって多倍長演算ができます:
; 下位バイトを減算(ボローなし) lda $0700 sec sbc $0702 ; ここで繰り下がりがあれば C = 0, さもなくば C = 1 となる sta $0700 ; 上位バイトを減算(ボロー付き) lda $0701 sbc $0703 ; 繰り下がりがあればさらに 1 を引く(sec を行っていないことに注意) sta $0701
ADC 同様、ボローなし減算を行う専用の命令はありません。
比較
CMP, CPX, CPY 命令で A, X, Y レジスタと対象の比較ができます。指定レジスタから対象を(ボローなし)減算し、結果を捨ててフラグだけ更新していると考えるとわかりやすいかもしれません。
これらの命令は N, Z, C フラグを書き換えます(V は変化なし):
- N: 減算結果の最上位ビット
- Z: 減算結果が 0 であるか
- C: 減算で繰り下がりが発生したら 0, さもなくば 1
普通は Z, C フラグのみを用いて大小関係を判定します。例えば CMP 命令の場合:
- Z == 0 ならば A == (対象)
- C == 1 ならば A >= (対象)
- C == 0 ならば A < (対象)
ビット演算
AND, ORA, EOR で and, or, xor 演算ができます。
- AND: A &= (対象)
- ORA: A |= (対象)
- EOR: A ^= (対象)
これらの命令は N, Z フラグを書き換えます:
- N: 演算結果の最上位ビット
- Z: 演算結果が 0 であるか
not 演算を行う命令はありませんが、EOR で代用できます:
eor #$FF ; A ^= 0xFF, つまり A = ~A
ビットシフト
ASL で左シフトができます(空いた部分には 0 が入る)。"Arithmetic Shift Left" です。
LSR で右シフトができます(空いた部分には 0 が入る)。"Logical Shift Right" です。
これらの命令は A またはメモリ上の値が対象です。A をシフトする場合、オペランドを省略します:
asl ; A <<= 1 lsr $0700 ; mem[0x0700] >>= 1
これらの命令は N, Z, C フラグを書き換えます:
- N: 結果の最上位ビット
- Z: 結果が 0 であるか
- C: はみ出たビット
6502 には乗除算命令がないので、シフトを用いて定数乗除算を行うコードは頻出します。
シフト回数は 1 で固定です。回数指定シフト命令はありません。
ビットローテート
C フラグを含めた 9bit ローテートができます。
ROL で左ローテートができます(左シフトを行い、空いた部分に C が入る)。"ROTATE Left" です。
ROR で右ローテートができます(右シフトを行い、空いた部分に C が入る)。"ROTATE Right" です。
これらの命令は N, Z, C フラグを書き換えます:
- N: 結果の最上位ビット
- Z: 結果が 0 であるか
- C: はみ出たビット
シフトとローテートを組み合わせることで多倍長シフトができます。例えば、16bit 値 $0700 (リトルエンディアン)を 1 回右シフトするには:
; 上位バイトを右シフト lsr $0701 ; はみ出たビットが C に入る ; 下位バイトを C フラグ込みで右ローテート ror $0700
ジャンプ
JMP 命令で無条件ジャンプができます。例えば jmp $8000
とすると $8000 へジャンプします。ポインタ経由の間接ジャンプもできますが、これはアドレッシングモードの項で述べます。
JMP 命令はフラグを変更しません。
分岐
N, V, Z, C フラグに基づく分岐ができます。分岐先オフセットは符号付き 8bit なので、範囲は [-128, 127] に制限されます。この範囲外へ飛びたい場合は JMP 命令などを併用する必要があります。
- BMI: N == 1 なら分岐。"Branch if MINUS"
- BPL: N == 0 なら分岐。"Branch if PLUS" (0 も正とみなす)
- BVS: V == 1 なら分岐。"Branch if oVerflow Set"
- BVC: V == 0 なら分岐。"Branch if oVerflow Clear"
- BEQ: Z == 1 なら分岐。"Branch if EQUAL" (等しい <-> 減算結果が 0)
- BNE: Z == 0 なら分岐。"Branch if Not Equal" (等しくない <-> 減算結果が非 0)
- BCS: C == 1 なら分岐。"Branch if Carry Set"
- BCC: C == 0 なら分岐。"Branch if Carry Clear"
分岐命令はフラグを変更しません。
いくつか例を見てみます:
; VBLANK を待つ。頻出 wait_vblank: lda $2002 ; VBLANK フラグが立っていれば最上位ビットが 1 になる bpl wait_vblank
; if(mem[0x0700] >= mem[0x0780]) ; ... lda $0700 cmp $0780 bcc less ; mem[0x0700] < mem[0x0780] ならば less へ飛ぶ ... less: ...
; 典型的ループ。頻出 ; for(X = 5; X != 0; --X) ; ... ldx #$05 ; X = 5 (即値ロード。アドレッシングモードの項で述べる) loop: ... dex bne loop
分岐命令を if や for といった擬似コードに置き換えるのは多少慣れが必要ですが、内容的には難しくないので地道に考えれば問題ないと思います。あまりにも長大なコードだったり、分岐がスパゲッティ化していると多少しんどいですが…。慣れるまではいったん機械的に goto に置き換え、それを goto なしのコードに書き換えるようにするのも良いでしょう。
サブルーチン呼び出し
JSR 命令でサブルーチンを呼び出せます。"Jump to SubRoutine" です。
建前上は JSR で呼び出したサブルーチンからは必ず RTS で復帰するということになっていますが、実際には変則的なスタック操作を行っているゲームが普通に存在するため、この仮定は必ずしも成り立ちません。よって、内部的にどのようなスタック操作が行われるのか把握しておく必要があります。
JSR 命令が実行されると、JSR 命令のアドレス に 2 を加えたアドレス(これを「戻りアドレス」と呼ぶ)をスタックに push した後、オペランドのアドレスへジャンプします。例えば、
C000 : jsr $8000 ; JSR 命令は 3 バイト C003 : ...
というコードは、0xC002 を push し、$8000 へジャンプします。大抵はこの後ルーチン $8000 が RTS で復帰して $C003 から実行が継続されますが、ルーチン $8000 内で変則的なスタック操作が行われている場合、$C003 に戻ってこないケースもありえます。
JSR 命令はフラグを変更しません。
サブルーチンからの復帰
RTS 命令でサブルーチンから復帰します。"RETURN from Subroutine" です。
建前上は JSR からの復帰に使うということになっていますが、実際には自分で戻りアドレスをスタックに積んでそこへジャンプする、などといった使い方もあります(RTS Trick)。
RTS 命令が実行されると、戻りアドレスを pop し、そのアドレスに 1 を加えたアドレスへジャンプします。
RTS 命令はフラグを変更しません。
割り込みハンドラからの復帰
RTI 命令で NMI/IRQ 割り込みハンドラから復帰します。"RETURN from Interrupt" です。
これに関しても一応内部的なスタック操作を把握しておく方がいいです。
- NMI/IRQ 割り込みが発生すると、割り込み完了後に復帰すべきアドレスそのもの(JSR と異なり、1 を引かない)を push し、さらにステータスレジスタ P を push し、割り込みハンドラへジャンプします。
- RTI 命令が実行されると、ステータスレジスタ P を pop し、復帰すべきアドレスを pop し、そのアドレスへジャンプします。
ビットテスト
BIT 命令でビットテストを行えます。レジスタを破壊せずに値を読めるので VBLANK 待ちなどでたまに使われますが、使用頻度は低いです。
BIT 命令は N, V, Z フラグを書き換えます:
- N: 対象の bit7
- V: 対象の bit6
- Z: A と対象を and した結果が 0 であるか
BIT 命令のオペランド内に別の命令を埋め込んでおき、命令の途中へジャンプするというテクニックが稀に使われることがあります。
NOP
NOP は何もせずサイクルのみ消費する命令です。タイミング制御などで稀に使われます。不可解な NOP があったらハードウェア(PPU/APU など)周りの事情が関係している可能性があるかもしれません。
NOP はプログラムにパッチを当てる際に便利です。例えば、分岐命令を NOP で潰して分岐しないようにしてみる、などといったことができます。
アドレッシングモード
ここまでは基本的に 16bit アドレス指定のみを使ってきましたが、別の形式でオペランドを指定することもできます。これを「アドレッシングモード」と呼びます。
全ての命令で全てのアドレッシングモードが使えるわけではありませんが、大体不便なく使えるようにはなっています(この概念を「命令セットの直交性」と言うようです)。
オペランドなし (Implied / Accumulator)
CLC など、そもそもオペランドをとらない命令もあります。また、ASL などはオペランドを省略すると A レジスタが対象となります。一応公式的には前者を “Implied”, 後者を “Accumulator” と呼んで区別していますが、実用上は「オペランドなし」でまとめて覚えてしまって問題ありません。
Immediate
「即値」モードです。つまり、アドレスでなく値そのものを対象とします。例えば:
lda #$FF ; A = 0xFF
Zeropage
ゼロページアドレスを対象とするモードです。例えば:
lda $FF ; A = mem[0xFF]
Zeropage,X
ゼロページアドレスにインデックス X を加えたアドレスを対象とするモードです。加算結果がページ境界をまたぐ場合、ゼロページ内でループします。例えば:
ldx #$05 ; X = 5 lda $00,X ; A = mem[0x00+X], ここでは $05 を読み取る lda $FF,X ; A = mem[(0xFF+X) & 0xFF], ここでは $04 を読み取る
Zeropage,Y
Zeropage,X と同様ですが、インデックスとして X の代わりに Y を使うモードです。LDX, STX 命令でのみ使えます。
Absolute
16bit アドレスを対象とするモードです。例えば:
lda $0700 ; A = mem[0x0700] lda $0000 ; ゼロページを指定しても構わない(が、コードサイズとサイクル数の無駄)
Absolute,X
16bit アドレスにインデックス X を加えたアドレスを対象とするモードです。加算結果がページ境界をまたいでも構いません。例えば:
ldx #$05 ; X = 5 lda $0600,X ; A = mem[0x0600+X], ここでは $0605 を読み取る lda $06FF,X ; A = mem[0x06FF+X], ここでは $0704 を読み取る
Absolute,Y
Absolute,X と同様ですが、インデックスとして X の代わりに Y を使うモードです。
(Indirect,X)
ゼロページアドレスにインデックス X を加えたアドレスからポインタを取得し、それが指すアドレスを対象とするモードです。ポインタ取得時にページ境界をまたぐ場合、ゼロページ内でループします。例えば:
; X = 5 ; ptr = mem[0x00+X] | (mem[0x00+X+1] << 8), ここでは mem[0x05] | (mem[0x06] << 8) ; A = mem[ptr] ldx #$05 lda ($00,X)
このモードは大して便利でもなく、滅多に使われないので忘れてしまっても構いません。
(Indirect),Y
ゼロページアドレスからポインタを取得し、それが指すアドレスにインデックス Y を加えたアドレスを対象とするモードです。ページ境界については、
- ポインタ取得時にページ境界をまたぐ場合、ゼロページ内でループします。
- Y を加算した結果はページ境界をまたいでも構いません。
例えば:
; ptr = mem[0x00] | (mem[0x01] << 8) ; A = mem[ptr] ; X = mem[ptr+1] ldy #$00 lda ($00),y iny ldx ($00),y
このモードは構造体アクセスやメモリコピーなどでよく使われるので慣れておくべきです。
Relative
分岐命令専用のモードです。機械語では符号付き 8bit の分岐オフセットがオペランドとなりますが、アセンブリ上ではアドレスまたはラベルで表記されるのが普通です。
(Indirect)
間接 JMP 命令専用のモードです。16bit アドレスからポインタを取得し、それが指すアドレスにジャンプします。例えば:
; ptr = mem[0x0700] | (mem[0x0701] << 8) ; jmp(ptr) jmp ($0700)
次回は実際のゲームで使われる 6502 コードをいくつか読んでいきます。
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:ユーザがいつリセットボタンを押すかわからないため、割り込みベクタを含む領域を差し替えると対応するバンク全てに割り込みベクタとハンドラを書かなければならないためだと思われる
FC 『カイの冒険』 デバッグモード
デバッグモードと思われる未使用コードを見つけました。
$C2FF-$C300 を NOP で潰すことで未使用コードが有効になり、以下の機能が使えるようになります:
- タイトルで1コンの AB を押しつつ NORMAL START を選ぶことで面セレクトができる
- 面セレクト画面から普通に抜けると、その後2コンでメモリ書き換えができる
- 面セレクト画面を抜ける際に2コンの A を押していると、メモリ書き換えができなくなる代わりに2コンの A を押している間自機が無敵になる
面セレクトとメモリ書き換えのスクリーンショット:
当該未使用コードは $C301- にあります。また、ROM自体を書き換えなくても、チートで $017F を非0にするとメモリ書き換えができ、$015F を非0にすると2コン無敵が使えるようになります。
6502逆アセンブラに関する考察 その3
前回、アドレス単位のコード解析について考察しました。今回はその結果を元に制御フローを考慮したコード判定を行うことを考えます。
このパスは誤判定を避けるため、極力保守的な方針で解析を行います。まずは6502の制御フローについて再確認します。
制御フロー命令
6502の大半の命令は直線的に実行されます(実行後にプログラムカウンタが直後のアドレスを指す)。こうならない可能性があるのは以下の命令です:
- BRK
- KIL (12種類)
- 分岐命令 (8種類)
- JSR
- RTI
- RTS
- JMP abs
- JMP ind
これらを「制御フロー命令」と呼ぶことにします。
ただし、割り込みを考慮すると任意の命令実行後にプログラムカウンタが書き換わる可能性があります(例えば、読み書きすると即座に割り込みを発生するようなI/Oレジスタを考えることができる。また、BRK が割り込みハイジャックによりNMIルーチンへ飛ぶケースなどもありうる)。とはいえこれは特殊ケースなので当面は無視します。
全ての制御フロー命令について、実行後の飛び先(割り込みは無視する)を考えると:
命令 | 飛び先の数 | 特定可? |
---|---|---|
KIL | 0 | |
JSR | 1 | 可 |
JMP abs | 1 | 可 |
BRK | 1 | 割り込みベクタがわかれば可 |
JMP ind | 1 | 基本的に不可 |
RTS | 1 | 基本的に不可 |
RTI | 1 | 基本的に不可 |
分岐 | 2 (or 1) | 可 |
「特定可」とは、飛び先の具体的なアドレスがわかるかどうかということです。今回はポインタ値やスタック状態の追跡までは行わない(一般的には不可能なケースもある)ので、JMP ind, RTS, RTI の飛び先は特定不可としています。
なお、JSR/RTS は必ずしも対になっているとは限らず、RTS があっても JSR の直後のアドレスに戻ってくるとは限りません(NESプログラムでは変則的なスタック操作がしばしば行われており、既に一つのテクニックと化しています)。また、分岐命令は実際には無条件分岐(飛び先がどちらか1通りのみ)だったり、2つの分岐先が一致するケース(かなり稀でしょうけど)もありえます。
制御フローを考慮したコード判定
制御フローを追うことでなるべく多くのアドレスを非 UNKNOWN 状態にすることを考えます。
CODE 状態のアドレスは順方向に影響を及ぼします。つまり、CODE 状態のアドレスからの制御フローは基本的に全て CODE となります。
NOTCODE 状態のアドレスは逆方向に影響を及ぼします。つまり、NOTCODE 状態のアドレスに到達する制御フローは基本的に全て NOTCODE となります。
分岐のない直線的な制御フローの場合について図示すると:
CODE-UNKNOWN-UNKNOWN-UNKNOWN-... -> CODE-CODE-CODE-CODE-... ...-UNKNOWN-UNKNOWN-UNKNOWN-NOTCODE -> ...-NOTCODE-NOTCODE-NOTCODE-NOTCODE
このように変換できるということです。
割り込みなど特殊な事情があると以下のように衝突が生じるケースも考えられます:
CODE-UNKNOWN-UNKNOWN-NOTCODE
この場合、途中の UNKNOWN が CODE, NOTCODE どちらになるかという問題が生じますが、この優先順位については後述します。
制御フローの探索自体は全て順方向に行い、UNKNOWN または CODE 状態のアドレスから開始します。UNKNOWN 状態のアドレスから開始するものを UNKNOWN 探索、CODE 状態のアドレスから開始するものを CODE 探索と呼ぶことにします。
UNKNOWN 探索では UNKNOWN -> NOTCODE の変化のみが起こります。CODE 探索では UNKNOWN -> CODE の変化のみが起こります。
簡単のため、まず UNKNOWN 探索を収束するまで行い、次に CODE 探索を収束するまで行います(これにより先に述べた優先順位が決まります)。なお、この順番が逆だと全体として収束しないことがありえます(後述)。
探索中、例えば JMP ind 命令などが現れて次アドレスが特定できなくなったり、次アドレスが入力ファイルの範囲外となることがあります。このような特定不可アドレスや範囲外アドレスが現れたらそれ以上制御フローを追跡できないので、そこで探索を打ち切ります。ただし範囲外アドレスについてはコード判定は通常通り付加します。
UNKNOWN 探索
各アドレスについて探索済みか否かのフラグを管理し、未探索アドレスがなくなるまで全ての UNKNOWN アドレスから探索を行います(探索中のループは前述のフラグで検出され、自動で探索が打ち切られます)。
現在見ているアドレスを orig とし、ここからの飛び先の数(0,1,2)によって処理を分けます。
飛び先が0通り (KIL 命令) の場合、単に探索を打ち切ります。
飛び先が1通りの場合、次アドレスを next とし、以下のように処理します:
orig | next | 結果 |
---|---|---|
UNKNOWN | UNKNOWN | 探索続行 |
UNKNOWN | CODE | 探索打ち切り |
UNKNOWN | NOTCODE | orig を NOTCODE に。探索打ち切り |
飛び先が2通り(分岐命令)の場合、2通りの次アドレスを next1, next2 とし、以下のように処理します:
orig | next1 | next2 | 結果 |
---|---|---|---|
UNKNOWN | UNKNOWN | UNKNOWN | next1, next2 から探索し、両方 NOTCODE なら orig を NOTCODE に。探索打ち切り |
UNKNOWN | UNKNOWN | CODE | 探索打ち切り |
UNKNOWN | UNKNOWN | NOTCODE | 次アドレスを next1 として探索続行 |
UNKNOWN | CODE | * | 探索打ち切り |
UNKNOWN | NOTCODE | NOTCODE | orig を NOTCODE に。探索打ち切り |
探索は順方向ですが、NOTCODE への変化は逆方向に起こるので、実際には探索時に制御フローを記録しておき、NOTCODE への変更は一括で行う必要があります。
CODE 探索
各アドレスについて探索済みか否かのフラグを管理し、未探索アドレスがなくなるまで全ての CODE アドレスから探索を行います(探索中のループは前述のフラグで検出され、自動で探索が打ち切られます)。
現在見ているアドレスを orig とし、ここからの飛び先の数(0,1,2)によって処理を分けます。
飛び先が0通り (KIL 命令) の場合、単に探索を打ち切ります。
飛び先が1通りの場合、次アドレスを next とし、以下のように処理します:
orig | next | 結果 |
---|---|---|
CODE | UNKNOWN | next を CODE に。探索続行 |
CODE | CODE | 探索続行 |
CODE | NOTCODE | 探索打ち切り(割り込みなど特殊な事情があるとみなす) |
飛び先が2通り(分岐命令)の場合、2通りの次アドレスを next1, next2 とし、以下のように処理します:
orig | next1 | next2 | 結果 |
---|---|---|---|
CODE | UNKNOWN | UNKNOWN | 探索打ち切り |
CODE | UNKNOWN | CODE | 探索打ち切り |
CODE | UNKNOWN | NOTCODE | 次アドレスを next1 として探索続行 |
CODE | CODE | * | 探索打ち切り |
CODE | NOTCODE | NOTCODE | 探索打ち切り(割り込みなど特殊な事情があるとみなす) |
CODE 探索より UNKNOWN 探索を先に行うのはここの分岐命令の処理のためです(CODE 探索が先だとここで UNKNOWN 探索が必要になってしまう)。
出力フェーズ
これは解析結果を参照しつつ入力ファイルを先頭から逆アセンブルしていくだけです。非公式命令については、CODE ならコード、UNKNOWN ならデータとして出力するのが妥当なところでしょう。
以上の処理を Python で実装したもの を置いておきます(ドキュメント未整備)。一応以前うまく行かなかったケースは改善されたっぽいですが、あんまりテストしてないのでまだ見落としなどがあるかもしれません^^;
アドレスラベルやコメントなどについても検討中ですが、これらについてはそもそもインタラクティブに操作できるUIがないと辛いものがあるんですよね(IDA はその点が有利なのは認めざるを得ない)。radare2 という解析ツールにちょっと期待してたりするんですが、これはレトロCPUはあまり眼中にないようで、6502モードだと命令の区切りがおかしくなったりするので今のところ微妙です。
NES以外については今のところあまり考察してませんが、BizHawk の Code/Data Logger がSNESなどに対応しているようなので、これを使えばある程度精度を上げられるかもしれません。