読者です 読者をやめる 読者になる 読者になる

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