解析資料をgithubで公開します

過去にチマチマと作っていた解析資料を github に上げていこうと思います。TASってないゲームも含まれます。

アセンブリをそのまま配布するのはさすがにまずいので、Mesen デバッガ用のラベルファイル(*.mlb)にします。この形式だと若干不便なところもあります*1が、変数/関数にラベル/コメントを付ける程度なら問題ないので、最低限の要求は満たしてるかなと。

また、ニコニコに上げた動画が一部HTML5で再生できなくなってるようなので、Flashサポートが終了する前に順次 YouTube へ再アップ予定です。

Mesen メモ

  • ワークスペースは Debugger/ ディレクトリ内の *.cdl, *.Workspace.xml へ自動保存される。
  • コメント内に "0x" とか "$" は書けない模様。"FFh" みたいに書くしかなさげ。
  • サブルーチン認識がちょっと甘い。rts があると問答無用で終わりとみなしてる?
  • データを db などで表示できないため、データ部にコメントを付けるのが難しい。

*1:データ部にコメントを付けにくかったり、配列変数の表現が難しかったり…

NES 基礎知識 - 目次

TAS さんや攻略のための解析がしたい人向けです。ゲームを自作したい人は NesDevWiki をしっかり読んだ方がいいでしょう。

他機種向けにこういう記事書いてくれる人いないかなー(チラッ

NES 基礎知識 - APU

今回は APU について扱…いたかったのですが、残念ながら知識不足のためまともに解説できる気がしません^^;

ということで、参考になりそうなリンクを張るにとどめておきます。

とりあえず I/O レジスタへの書き込みでどんな音が鳴るのか調べるだけなら上に挙げた Plogue livenes を動かしてみれば大体わかると思います。

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” のように覚えるといいです。

  • SEC: C = 1
  • CLC: C = 0
  • SEI: I = 1
  • CLI: I = 0
  • SED: D = 1
  • CLD: D = 0
  • CLV: V = 0

インクリメント/デクリメント

メモリ上の値に対しては 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 コードをいくつか読んでいきます。

*1:bit5, bit4 は物理的に存在せず、P がスタックに積まれたときにのみ目に見える形で現れます。これについては NesDevWiki に 解説がありますが、NES ゲームで BRK 命令が使われているのは見たことがないので特に意識する必要はないと思います