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