コードの模様の百科
透視 (x-ray) が積み上がって何になるか (exercise.md、Programme 3)。一つの項目は、模様の名前、それが何をするか、透視が測ったとおりのシグネチャ (触るアドレス、上げるイベントの種類、ダイのラングでのサイクル数)、6502 サイトの Halfshot ページで立てる窓、CRC で見つかったカートリッジ、そしてそれが教える仕組みからなる。項目が抱えるのは模様の形だけで、商用カートリッジのバイト列は決して抱えない: コードが載るのは、ソースが私たちのものである ROM からだけだ。
ここにある数値はすべて、模型の上で tools/xray.py が出したものだ。模型の CPU は、記録済みのバスによってダイに押さえられている (trace-plan.md、T1)。実機が透視を確かめるのは操作の結果によってで、取り込みによってではない。ベンチにアドレスバスが無いからだ。
1. ポーリングのルーチン
何をするか。 コントローラを読む: シフトレジスタをストローブし ($4016 へ 1、続いて 0)、それから $4016 を 8 回読む。1 回の読み出しが D0 の上のボタン 1 個で、そのビットを RAM の 1 バイトへ送り込んでいく。
どこで見つかったか。 パッドのカートリッジ pad-paint.nes (nes-console の testrom::pad_paint_program。export-testrom が書くとおりの paint 版の crc32)。ソースは私たちのものなので、コードを示す。マルチカートのメニューも同じやり方でポーリングする (項目 2)。
透視。 tools/xray.py runs/pad-paint.nes pad-a --latch 6 --byte 01 --frames 12 --bytes --window: ラッチ 6 で 1 ラッチぶん A を押したものを、押さないものに対して。
diverge h 531570: read of $4016 returned 0 in the base run and 1 with the byte (D0); latch 6 at h 531559, 11 half-cycles after it
the instruction: LDA $4016 at $810E (its fetch at h 531564), reads read 6 half-cycles in
the path: 48 half-cycles differ in 11 span(s) over 290 half-cycles before the next latch, 11 instructions; then 1 span(s) after it
h 531570..531571 LDA $4016 at $810E: pad $4016 read D0 1 (base 0)
h 531585..531585 ROR $02 at $8112: RAM $0002 <- 80 (base 00)
h 531612..531617 ROR $02 at $8112: RAM $0002 read 80 (base 00); RAM $0002 <- 80 (base 00); RAM $0002 <- 40 (base 00)
... (six more rotations, the bit walking down to bit 0)
h 531846..531847 LDA $02 at $8121: RAM $0002 read 01 (base 00)
h 531859..531859 STA $2007 at $8125: PPU $2007 <- 01 (base 00)
(echo) h 591136..591140 ROR $02 at $8112: RAM $0002 read 01 (base 00); RAM $0002 <- 01 (base 00)
rejoin: the records agree again from h 591141 to the end (714732)
signature: code at $810E..$8125, RAM touched $0002, 8 pad reads, 3 PPU writes, 0 cart writes on the path
シグネチャ。 $4016 の読み出しが 8 回、ちょうど 32 半サイクル間隔 (16 CPU サイクル: LDA abs 4、LSR 2、ROR zp 5、DEX 2、成立した BNE 3)。その前にストローブの書き込みが二つ。RAM の 1 バイトが 8 回書かれ、ビットがビット 7 からビット 0 へ歩く。そして後から 1 回、そのバイトが読まれる。パッドのバイトはラッチから 11 半サイクル後に CPU へ入り、その 290 半サイクル後に RAM の中で完全な 1 バイトになる。ROR zp は read-modify-write だ: 透視はその一つずつを、読み出し、古いバイトの書き戻し、それから新しいバイトとして示す。これはダイの上での 6502 の二重書き込みであって、道具の癖ではない。
コード。 testrom.rs から、$8100 の NMI ハンドラ:
8100 INC $00 ; フレームの計数
8102 LDA #$01
8104 STA $4016 ; ストローブを上げる
8107 LDA #$00
8109 STA $4016 ; ストローブを下げる: レジスタが 8 個のボタンを抱える
810C LDX #$08
810E LDA $4016 ; <- 透視の食い違い: D0 がボタン
8111 LSR A ; D0 をキャリーへ
8112 ROR $02 ; キャリーをバイトのビット 7 へ、残りは下へ
8114 DEX
8115 BNE $810E
8117 ... ; ペイント: $2006 <- $3F, $01; $2007 <- $02 & $3F
窓。 pad-poll-6.window は Halfshot ページで出荷されている (halfshot?window=pad-poll-6。同じカートリッジの、NMI の入口から見たラッチ 6 でのポーリング)。そしてその横に出荷する、透視自身の pad-a.window (読み込みの取り込みからパレット書き込みまで 312 半サイクル、重ねの行 13 本)。
仕組み。 コントローラは読み出しでクロックされるシフトレジスタ (4021) だ: ストローブがそれを読み込み、$4016 の読み出しごとに 1 ビットを差し出し、読み出しの立ち下がりが次のビットへクロックを送る。自分のポーリングのタイミングを持っているのはゲームであり、だからベンチで見るポートのラッチ幅とクロック間隔は、コンソールのサイクル数ではなくゲームのサイクル数だ (bench-v1b-uno.md)。ここでの読み出しの間の 16 サイクルはこのルーチンのものだ。ループを展開したゲームはもっと速く読み、DMC の取り込みはレジスタを二度クロックできる (pad-dmc。pad-log が予測する 9 クロックのポーリング)。
ディスパッチ。 このカートリッジがそのバイトに対してやることは、あり得るうちで最小のものだ: バイトをマスクしてパレットの項目 1 へ入れるので、絵が読まれたものを示す。それが項目 1 の後半であり、ゲームのものを透視したときの項目 7 (パッドのバイトから操作へのディスパッチ) の種でもある。
2. バンク切り替え: メニューの Start
何をするか。 マルチカートのメニューがパッドをポーリングし、Start が来たら描画を切り、ゲームのバンクをマッパーのレジスタへ書き、そのゲームをリセットベクタから起動する。バイト列は商用カートリッジのものなので、この項目は形だけだ: アドレス、計数、イベントの種類、そして取り込んだバイトをすべてマスクした上で透視が報告したもの。
どこで見つかったか。 Super Mario Bros. + Duck Hunt (USA)、crc32 D26EFD78、マッパー 66 (GxROM: $8000..$FFFF にレジスタ 1 個、バス衝突の AND)。
透視。 tools/xray.py <rom> smbdh-start --latch 200 --byte 08 --frames 222 --out <the ROM store>: ラッチ 200 で 1 ラッチぶん Start を押したもの (メニューの 190 回目のポーリング) を、押さないものに対して。報告のうち、形のままでいられる範囲:
diverge h 12535332: read of $4016 returned 0 in the base run and 1 with the byte (D0); latch 200 at h 12535197, 135 half-cycles after it
the instruction: AND $4016 at $8172 (its fetch at h 12535326), reads read 6 half-cycles in
the path: 27570 half-cycles differ in 2 span(s) over 27574 half-cycles before the next latch, 4592 instructions; then 7 span(s) after it
h 12535332..12535333 AND $4016 at $8172: pad $4016 read D0 1 (base 0)
h 12535338..12562905 INC $03 at $8177: a different code path from $8178: 11454 fetches differ; RAM: 32 writes to 19 addresses (most: $0003 x10, $01FF x3, $01FE x2, $0060 x2, ...); PPU: 2 writes ($2000 <- 90, $2003 <- 00); cart: 0 writes
(echo) h 12595070..12595071 LDA $04 at $817C: RAM $0004 read 10 (base 00)
(echo) h 12595113..12595113 STA $47 at $818A: RAM $0047 <- 10 (base 00)
(echo) h 12595238..12595239 LDA $47 at $8089: RAM $0047 read 10 (base 00)
(echo) h 12595248..12595249 AND $E0 at $808D: RAM $00E0 read D0 (base 00)
(echo) h 12595254..13222545 LDA #$.. at $8091: a different code path from $8092: 277692 fetches differ; ...; PPU: 5606 writes ($2001 <- 00, ...); cart: 1 writes (cart $BF00 <- 00 (base 20))
rejoin: never; the action run is 3 half-cycles longer than the base run over the same frames
after the path, to the end of the record:
ppu 12595265 $2001 <- 00 at frame 211 line 121
cart 12595319 $bf00 <- 00 (a mapper register) at frame 211
ppu 12755981 $2001 <- 06 at frame 214 line 42
ppu 12758123 $2000 <- 10 at frame 214 line 52
...
sprite DMAs ($4014) after the path: 6
signature: code at $8172..$8177, RAM touched $0000, $0003, $0004, $0005, $0022, $0047, $0050, $0051, $0060, $00E0, $01F7, $01F8 and 7 more, 5 pad reads, 3 PPU writes, 0 cart writes on the path
シグネチャ。 ポーリングは項目 1 の形で、数字が違う: $4016 の読み出しが 8 回、38 半サイクル間隔 (1 ビットあたり 19 サイクル。パッドのカートリッジは 16)。ビットは回転ではなく、読み出しに対する AND で試される。そして Start は 4 番目のビットなので、食い違いは 4 回目の読み出しに着地し、その後に 5 回の読み出しが続く。ビットで分岐した後の間隔は 46 半サイクルだ。押しは、その押しのフレームでは実行に移されない: ポーリングのフレームは RAM のアドレス 19 個に書き ($0003 のカウンタに 10 回、スタック、$0060)、他のどのフレームとも同じようにスプライト DMA で終わる。次のラッチで、そのバイトが RAM から出てきて ($0004、$0047)、$8089 のディスパッチがそれを読み、切り替えが 60 半サイクルのうちに続く: 描画を切り (フレーム 211 の行 121 で $2001 <- 00)、その 54 半サイクル後にマッパーのレジスタ ($BF00 <- 00: 両バンクをゼロへ、つまり最初のゲーム)、それから二度と合流しないコード経路 (ゲーム自身のリセット)、フレーム 214 の行 42 で描画が戻り ($2001 <- 06、続いて $2000 <- 10、ゲームの NMI が入る)、そしてフレームごとの DMA の前後で $2000 を 90 と 10 の間で切り替えるフレームループ。
窓。 まだ切っていない: ラング 0 が記録をその取り込みまで走らせる必要があり、1,200 万半サイクル、約 7 分かかる。そして商用カートリッジの走行の内側の窓はそのバイト列を抱えるので、ROM の保管場所に留まり、配られることはない。
仕組み。 GxROM のレジスタは ROM 空間まるごとで、基板は書かれたバイトとその下にある ROM のバイトを AND する (これがバス衝突) ので、メニューはバンクを、その ROM のバイトがすでにその値を抱えているアドレスを通して書く: このカートリッジのメニューのバンクでは $BF00 が 00 を抱えていて、それが書き込みがそこに着地する理由だ。描画が先に切れるのは、CHR のバンクも入れ替える切り替えでフレームを裂かないためだ。それからゲームは自分のベクタから起動し、最初にやることは自分の $2000 と $2001 で絵を戻すことだ。操作した側の走行が 3 半サイクルだけ長いのは、描画が入っていると PPU が奇数フレームで飛ばすドットのぶんだ: 一方が 3 フレームのあいだ描画を切っていれば、二つの走行のフレームはもう同じ長さではない。
実機が確かめられるもの。 取り込みではなく (ベンチにアドレスバスは無い) 結果だ: ポーリングの回数、フレーム 214 より後のトリガでの絵 (E2 がまさにこれをやった: ラッチ 300 でのタイトル)、そして音。まだ確かめられないもの: 描画が切れたのがどのフレームかは、ラッチ 201 でスコープをトリガし、復号した記録が空白のフレームを示すまで分からない。訊いてみると (2026-09-18)、実機は同じ押しで切り替わる。冷間でも、何度リセットした後でも。ポーリングは模型と同じようにタイトルの行へ移る。冷間起動した実機はこれを無視するという以前の読みは、押しより前にメニューを捕まえていたベンチのヘッドのせいだった (open-items.md)。
3. 割り込みの中のゲームループ
何をするか。 主プログラムは 1 命令の上で空回りし、ゲームの全体は NMI ハンドラの中で走る。ハンドラは入口で NMI を切り、RTI の前に入れ直すので、長くかかったフレームは落とされ、二度と再入されない。
どこで見つかったか。 Super Mario Bros. (mario-dissection.md): $8057 にある JMP $8057、1 フレームに 5,800 回の空回りでフレームの 58 パーセント。ハンドラは $8082、行 241 のドット 79 で $2000 <- 10、行 89 で $2000 <- 90、RTI は $8181。
シグネチャ。 どのフレームのプロファイルでも同じアドレスに見つかる、1 命令の待機ループ。行 241 で取られるベクタ。ハンドラの最初の PPU 書き込みが $2000 のビット 7 を落とし、最後がそれを立てる。
仕組み。 NMI がフレームの時計だ。ゲームがその中にあると、すべての単位がフレームになる。そしてその守りが、フレームの超過にかかる代価だ: 落ちたフレーム 1 枚であって、壊れたフレームではない。
4. ステータスバーのためのスプライト 0 分割
何をするか。 固定のステータスバーの下端でスプライト 0 のヒットのフラグを待ち、それからそのレベルのスクロールを書く。だからバーは留まり、世界はその下で動く。
どこで見つかったか。 Super Mario Bros.: $813D で $2002 を 1 回読み (フラグのクリア)、$8150 の LDA $2002 / AND #.. / BEQ が行 16 から行 30 まで 178 回空回りし、$8159 に DEY / BNE の 19 回ぶんの待ち、行 31 のドット 100 で $2005 を 2 回、ドット 157 で $2000 (2026-09-18 以降は PPU 自身の行とドット。dissect.py はそれまで最大 0.7 行ぶん高く出していた)。
シグネチャ。 $2002 の上での空回りが毎フレーム同じ行で終わり (ここでは 30、バーが 32 行なので)、短い数え上げの待ちで帰線期間へ入り、次の行で $2005 の書き込みが二つと $2000 が一つ。
仕組み。 2C02 は、スプライト 0 の不透明な画素が背景の不透明な画素と出会ったときに $2002 のビット 6 を立てる。バーの下端に停めたスプライトは、それを走査線のタイマーにする。待ちが書き込みを水平の帰線期間まで歩かせるので、変更は行と行の間に着地する。ベンチが試せること: 復号した捕捉が、行 32 より上ではバーがスクロールしておらず、下ではレベルがスクロールしていることを示さなければならない。
5. 帰線期間で吐き出す VRAM のバッファ
何をするか。 絵を描いている間に走るゲームの論理が、ネームテーブルとパレットへの書き込みを RAM に並べる。ハンドラはそれを帰線期間に $2007 へ書く。DMA の後、描画が入る前に。
どこで見つかったか。 Super Mario Bros.: バッファは $0300、吐き出すのは $8EBB の STA $2007。4 バイトが $3F0C へ、3 バイトが $207A へ、4 から 5 フレームごとに。スクロールが 16 画素を越えるときはネームテーブルの 1 列に 26 バイト ($2490)。アドレスは $3F00 に停め、その後 $0000 へ。描画が入るのは、まとまりの大きさによって行 247、249 または 252。
シグネチャ。 帰線期間の外に $2007 への書き込みが一つも無い。DMA の 256 回の書き込みの直後にまとまりが来る。どのまとまりも $2006 への 4 回の書き込みで閉じる。
仕組み。 VRAM が書けるのは PPU が取り込みをしていない間だけなので、絵の仕事は待ち行列へ先送りされ、帰線期間の予算 (ここでは約 20 行) がフレームごとの更新の上限になる: 26 タイルの 1 列が、このゲームが 1 フレームで書く最大のものだ。
6. ジャンプエンジン
何をするか。 JSR で呼ばれたルーチンが、自分の戻りアドレスを引き出し、その JSR の直後に続くアドレスの表を添字で引き、JMP の間接で着地する: 呼び出しの直後に表として書かれた case を持つ、1 バイトに対する switch だ。
どこで見つかったか。 Super Mario Bros.: $8E04、100 フレームで 474 回入る。表は $8215 (操作のモード)、$AEDF (ゲームのモード)、$B04C、$B34E (プレイヤーの状態)、$C88F、$C907、$92C8 の各 JSR の後にある。100 フレームで表 9 個。
シグネチャ。 JSR で入ったルーチンの中に、どの RTS よりも前に PLA が二つ。JMP ($..) が一つ。呼ばれた側の RTS が、呼び出し側の呼び出し側が積んだアドレスへ戻る。プロファイラは、この JMP でエンジンのフレームを閉じ、着地したルーチンを自前の呼び出しとして走らせる。
仕組み。 6502 に添字付きのジャンプは無い。この手品は、戻りアドレスを表のポインタにする。エンジンは一つで、ゲームの中のモード、状態、物体の種類はどれも表になる。
7. 状態のディスパッチ
何をするか。 プレイヤーの状態 (地面の上、空中、...) がジャンプエンジンの表の添字になる。押しが状態を変え、次のフレームは別のルーチンを走らせる。
どこで見つかったか。 Super Mario Bros.: $B34E の後の表。走っている 100 フレームのうち 54 フレームで $B35A、46 フレームで $B376 (台本どおりのジャンプ)。地面の上での押しの透視では、押した側の走行でだけ走ったルーチンは $B376 の一つで、746 半サイクル。$B484 の判定 (AND $0D: 今の A と前フレームの A) が物理を別の経路へ送った後だ。
シグネチャ。 一方の走行の木にあって、もう一方には無いルーチンが、エンジンから到達され、押しのフレームの中にある。
仕組み。 表として書かれた状態機械だ。透視のルーチンの差分は、コードを読まずにその遷移を見つける: それが、項目 7 が記録するために存在している手法だ。
ビルド時に nes-bench/docs/encyclopedia.md から取り込まれる。写しはリポジトリの一つだけだ。報告は独自の作業用の言葉をいくつか使う: 報告書で使われる言葉。