atpagesサービス終了に伴い、旧個人サイトを移転します

今更かよと言われそうですが、atpages が 2/28 でサービス終了するようです。ということで、旧個人サイトgithub pages へ移転します。

atpagesに攻略サイトなどを上げてる各位はすぐ引っ越すんだ!間に合わなくなってもしらんぞー!!

解析資料を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 択であるような現象ならば、成功例と失敗例のムービーを撮り、両方について同じタイミングから数フレームのトレースログをとって比較すれば手掛かりが得られる可能性があります。