Super Mario Bros. を解剖する
X 線とフレームプロファイラで最初に分解したゲームだ (exercise.md、
プログラム 3): どう配置されているか、ループをどう回しているか、どの走査線で
何をしているか、そしてパッドとジャンプのあいだで何が起きるか。ここにあるもの
はすべて、模型が取ったマルチカートの記録の上で実測した (nes-console の
トレースに対する tools/dissect.py と tools/xray.py)。その CPU は記録された
バスによってダイに押さえられている。リストから先に読んで後で確認したものは
一つも無い。カートリッジのバイト列は ROM の中身なので ROM の保管場所に留まる。
だからこの文書はゲームの形 (アドレス、走査線、個数、即値を伏せたニーモニック)
を運び、そのコードも、その絵も、決して運ばない。
記録
一回の走行、電源投入から 660 フレーム、play.txt: ラッチ 200 で Start
(マルチカートのメニュー)、ラッチ 330 で Start (ゲームのタイトル)、ラッチ 520
から Right、570 から 590 まで A、640 で Right を放す。トレースが書く絵から
見たその状態: フレーム 331 でタイトル、500 で黒い遷移、585 までにマリオが
走って画面がスクロール、640 でクエスチョンブロックが通過。CPU の半サイクル
3,930 万、ラッチ 645 回、PPU への書き込み 189,434 回、マッパーへの書き込み
479 回、NMI のエッジ 1298 回、ピンのデータ 1.1 GB、書き出しに 6.7 s。
ループ: ゲーム全体が割り込みの内側で走る
メインプログラムは、コンソールを立ち上げてしまえば命令ひとつだ: $8057 に
ある JMP $8057、自分自身へのジャンプ。プロファイラは、ゲームプレイのどの
フレームでも、ハンドラが戻ってから次の NMI までそこで回り続けているのを
見つける。5,800 回、34,842 半サイクル、フレームの 58 パーセントだ。ゲームとは
その NMI ハンドラであり、241 行でベクタを通って $8082 に入り、次のフレームの
87 行から 114 行のあいだで $8181 の RTI によって終わる。最初にやることは
NMI を切ること (241 行ドット 149 で $2000 <- 10)、RTI の前に最後にやること
はそれを戻すこと ($2000 <- 90) なので、ハンドラが長く走ったフレームは、
入り直されるのではなく落とされる。
走査線の順に見たハンドラ、フレーム 600 (素直に走っているフレームで、読み込む 列は無い):
| 行 | ドット | 何を | どこで |
|---|---|---|---|
| 1 | 226 | $2002 を一度読む: スプライト 0 のフラグはクリアと見えた | $813D |
| 16 | 60 | LDA $2002 / AND #.. / BEQ が 178 回回る: スプライト 0 を待って、30 行ドット 92 まで | $8150 |
| 30 | 122 | ヒット。DEY / BNE を 19 回、31 行ドット 66 までの遅延 | $8159 |
| 31 | 100 | $2005 <- 02, 00: ステータスバーの下、レベルのスクロールを設定 | |
| 31 | 157 | $2000 <- 10: レベル用のネームテーブル | |
| 31 から 89 | ゲームの論理: 操作モードの木 | $8175 から $8212 | |
| 89 | 26 | $2002 を読み、$2000 <- 90: NMI オン | $8178 |
| 89 | 58 | RTI、そして 241 行ドット 12 まで $8057 で空回り | $8181 |
| 241 | 28 | NMI ベクタを取る | $8082 |
| 241 | 79 | $2000 <- 10: NMI オフ、ネームテーブル 0 | |
| 241 | 163 | $2001 <- 06: ブランク中の仕事のために描画オフ | |
| 241 | 175 | $2002 を読む: ステータスがクリアされる | $80A6 |
| 241 | 211 | $2005 <- 00, 00: ステータスバーのためにスクロールをゼロに | |
| 241 | 253 | $2003 <- 00、続いて $4014 <- 02: $0200 からのスプライト DMA | |
| 241 | 273 | DMA の $2004 への 256 回の書き込み、246 行ドット 105 まで | |
| 246 | 189 | $2002 を読み、VRAM バッファを吐き出す (このフレームでは何も無し) | $8EDD |
| 246 | 228 | $2005 <- 00, 00 | |
| 247 | 34 | $2001 <- 1e: 描画オン | |
| 250 | 295 | パッドをポール (ストローブの立ち下がり)、16 回の読み出し (両ポート) | $8E63 の STA $4016 |
表は PPU の順で走る: フレームはプリレンダ行と絵で始まり、ブランクで終わる
ので、フレーム 585 の絵を描いたハンドラはフレーム 584 の終わりに走ったことに
なる。(行とドットは 2026-09-18 から PPU 自身のものだ: dissect.py はプリ
レンダ行を 0 と番号付けし、描画された奇数フレームが飛ばすドットのぶんだけ
毎回ずれていたので、その行は最大 0.7 高く出ていた。「実機でやったこと、同じ晩に」
を参照。) だからブランクは DMA、VRAM バッファ、そして音とパッドのルーチンに
使われる。絵の上の方は、ステータスバーの下端でのスプライト 0 のヒットを待つ
のに使われる (16 行から 30 行。バーは 32 行の高さだ)。レベルのスクロールは
31 行で書かれ、そこが分割点だ。そしてゲーム自身の論理は、見える絵の 31 行から
およそ 89 行まで走る。次の画面の列を読み込むフレーム (フレーム 615: $2490
へ 26 バイト) は、247 行ではなく 252 行で描画をオンにし、98 行でハンドラを
終える。論理の予算は、絵の残りだ。
フレーム 560 から 659 にわたって: NMI は毎フレーム 241 行。描画オンは 247
(4 バイトの突発があれば 249、列があれば 252)。RTI は素直なフレームでは
87 か 88、列を読み込むか世界が忙しいときは 108 から 114。空回りは素直な
フレームで 58.4 パーセント、忙しいほうでは 48 から 50 パーセント。241 行を
越えて走るものは何も無い: その前に NMI のガードがフレームを落とす。
ルーチン
ゲームは $8E04 にある一つのジャンプエンジンを通して振り分ける。JSR で
呼ばれ、100 フレームで 474 回入っている: 自分の戻りアドレスを引き抜き、JSR
の後に続く表を索引し、間接 JMP で着地する。プロファイラはそれを追う。走って
いるフレームの木を、各ルーチンの内側の半サイクルで (1 フレームあたり、
100 フレームのプロファイルから):
| ルーチン | どこから | 1 フレームあたり | 表が言うこと |
|---|---|---|---|
$8212 | ハンドラの $8175 | 14,777 hc | 操作モードの木: $8215 の JSR $8E04、表は -> $8231 (タイトル)、$AEDC (ゲーム) |
$AEDC | 表から | 14,659 | ゲームのモード: $AEDF の JSR $8E04 -> $AEEA (プレイ中)、$8567 (遷移) |
$AEEA | 表から | 14,553 | フレームの核: プレイヤー、続いてオブジェクト |
$B04A | $AEF3 | 6,123 | プレイヤー: $B04C の JSR $8E04 -> $B0E9 |
$B0E9 | 表から | 6,007 | プレイヤーの制御: $B34E の JSR $8E04 -> 地上なら $B35A、空中なら $B376 |
$C047 | $AF05、1 フレームに 6 回 | 3,463 | オブジェクトごと: 敵のスロット 6 個 |
$DC64 | $B15A | 2,970 | |
$8223 | $814A、分割の直後 | 2,046 | |
$EEE9 | $AF16 | 1,821 | |
$F180 | $B14F、$AF10 | 1,432 | |
$8E5C | ブランク中の $80E7 | 1,084 | パッド、両ポート |
$F2D0 | ブランク中の $80E4 | 955 | サウンドエンジン |
$8F97 | ブランク中の $80ED | 877 | |
$8EDD | ブランク中の $80C3 | 265 | VRAM バッファを吐き出す |
呼び出しの木は深さ 7 に達する ($9BE1、100 フレームで $E408 から 760 回)。
タイトルのフレームは同じハンドラを $8215 -> $8231 -> $8245 で走らせ、その下
の核も同じだ (デモはゲームが自分で自分を遊んでいる)。遷移のフレームは
$8215 -> $AEDC -> $8567 -> $889D を走らせ、そのハンドラは 1 行で終わって、
空回りは 91 パーセントになる。
走っている 100 フレームでの命令フェッチから見たコードのページ: $80 が
57 パーセント (空回り)、$81 が 8.9、$82 が 4.3、$F2 が 3.9 (サウンド)、
$8E が 3.0 (エンジン、パッド、バッファ)、$C1 が 2.3、$E4 が 2.1、続いて
$8F、$F1、$AF、$9B、$EF、$E3、$C0、$DD、$BF がそれぞれ
2 パーセント未満: ゲームの作業集合は 32 キロバイトのうちおよそ 40 ページで、
残りはこの走行が届かなかったデータと状態だ。
RAM
100 フレームで 483 個のアドレスへ 78,464 回の書き込み: ゼロページが 27,642
($0000 だけで 5,941: どのルーチンも共有する作業用のバイト、続いて $0007、
$0004、$0006、$0008、$0005、$0002、$0003)、スタックのページが
26,951 ($01F3 から $01FA: 木が届く深さに、ポール自身のプッシュを足した
もの)、$0200 が 10,337 (DMA が読むスプライトのページ)、$0300 が 1,883
(VRAM バッファ)、$0400 が 1,257、$0600 が 3,046、$0700 が 7,336
(ゲームの状態)、$0500 が 12。
VRAM の経路
ブランクの外で $2007 に書くものは何も無い。ゲームの論理は絵のあいだに
$0300 のバッファを埋め、ハンドラが DMA の後に $8EBB (STA $2007) でそれを
吐き出し、それから描画が入る前にアドレスを $3F00 と $0000 に停める
($2006 への 4 回の書き込み)。走っているレベルでは、突発は 4 から 5 フレーム
ごとに来る: $3F0C へ 4 バイト (パレットの 1 項目、ブロックのアニメーション)、
$207A へ 3 バイト (タイマーの桁、ネームテーブルの 3 行 26 列)、そして
スクロールが 16 ピクセルを越えたときには、もう一方のネームテーブルの一列へ
26 バイト ($2490: あるタイル列の 4 行から 29 行)。スクロールそのものは
31 行での $2005 への 2 回の書き込みで、マリオが走るにつれて続くフレームで
11、12 となる: この速さでは 1 フレームに 1 ピクセルだ。
パッドからジャンプまで
ポール。 ハンドラからの $8E5C: 各ポートについて $8E6D の
LDA $4016,X を 8 回、バイトはスタックの上で積み上げる ($8E6C の PHA、
読み出し、シフト、$8E76 の PLA)。1 ビットに 28 サイクル、読み出しのあいだ
は 56 半サイクルで、パッドのカートリッジの 16 サイクル、メニューの 19 に対して
のものだ。バイトは $06FC,X (押されているボタン) に着地し、スタックを一度
通った後に $074A,X (変わったボタン) に着地して、$8E91 で RTS。1 フレーム
に 16 回の読み出し、最初のものはラッチの 29 半サイクル後、251 行で。
読む側。 バイトはハンドラの $819D (木の前の検査) と、ゲームの核の
$AEED で読まれる。後者はそれを $06FC に書き戻し、$B109 で A と B の
ビットだけをゼロページの一時領域 $0A に格納する。$B47E でプレイヤーの
コードが $0A を読み込み、$B484 の AND $0D が前のフレームの写しに照らして
それを検査する: 押下とは、いま A で、そのとき A でなかったことだ。
空中での一叩き。 最初の X 線は、押さえ続けている Right の上に、ラッチ 600
で 1 ラッチぶん A を押した。先のジャンプはまだ空中にいた。二つの記録は
4 フレーム、34 のスパンで食い違い、それから最後までまた一致する: ポールの
バイト、$06FC、$074A、$0A、$B484 の検査、そして絵が見せるものは何も
無い。二つの走行のフレーム 615 から 632 の絵は同一だ。ゲームはその押下を
無視し、X 線は絵より先にそう言った。
ジャンプ。 二つ目の X 線は、マリオが地上にいるラッチ 630 で A を押した。
$B484 までは同じ道で、そこから先は違うコードの道 (最初の区間で 2,529 回の
フェッチ)、$CE を読んで $07 に書く $F23D から $F260 の物理、
$06A1,X へ索引を一つずらして格納する $9408 のループ、そして $B34E の
プレイヤー状態の表が $B35A ではなく $B376 へ振り分ける: 押した走行だけで
走ったルーチン、746 半サイクル、ジャンプ自身のものだ。マリオのスプライトは
フレーム 650 から違い (16 x 31 ピクセルの箱)、プレイフィールド全体はフレーム
664 から違う ($2000 <- 90 に対して 92: スクロールが違うフレームで
ネームテーブルの境界を越えた。つまりジャンプがマリオの速さを変えた)。そして
二つの記録は二度と合流しない。押下のフレームから後、1,271 スパン: あの
1 バイトが走行を最後まで変えた。
実機が確かめること、確かめられないこと
実機はフェッチを見せられないが、その効果は実機の側にある: ポールの回数
(ラッチごとに八つのクロック。このカートリッジでは compare-logs.py が押さえて
いる)、トリガでの絵 (E2 のタイトル、ラッチ 300)、スクロールが動くフレーム、
そして音だ。ここにある主張のうちベンチが直接試せるのは分割だけだ: 走っている
ラッチでトリガした捕捉を復号したとき、32 行より上ではステータスバーが
スクロールせず、その下ではレベルがスクロールしているのが、模型が見せるのと
同じフレームで見えなければならない。やった。下の「分割」だ。
実機でやったこと、同じ晩に
ポールの走査線 (tools/poll-line.py、exercise/poll-line.txt、
exercise/menu-warm-poll.txt): ラッチの線をスコープのチャネル 2 に、映像を
3 に置き、一つの捕捉が 14 回のポールを抱え、それぞれをその前の垂直同期に
照らして置いた。行の周期は記録自身の水平同期から実測した (63.500 us)。この
測定はその晩に行われて両側とも読み間違い、翌日の午後に正しく読めた。両方の
読みをここに残すのは、間違ったほうが二つの誤りを見つけた道筋だからだ。
その晩: エンコーダの垂直同期 (ドット 0 から数えて 245 行から 247 行) を使うと、
実機のメニューのポールは PPU の 119.6 行に、模型の 120.15 に対して置かれ、
ゲームのほうは 250 に対して 251 だった。二つの画面で同じ 0.6 行だ。どちらの
数字も正しくなかった。エンコーダの同期は行の粒度で書き下ろされたまま、一度も
実測されていなかった。問うてみると (2c02 の vsync-probe、フレームを通して
半段ごとに DAC の同期先端の脚を見る)、ダイは 244 行の水平同期が始まるところ、
ドット 280 で垂直同期を始めており、この記録も同じことを言っている (幅の広い
パルスが、直前の水平同期からちょうど 1 行後、長さ 0.934 行、1 行あけて三つ)。
だから実機の行は 0.18 だけ高すぎた。そして模型の行は、dissect.py がトレース
のドット数の上でやった算術から来ていて、それはプリレンダ行を 0 と番号付けし、
描画された奇数フレームが飛ばすドットのぶんだけ毎回ずれていた: メニューの
フレーム 212 では 0.3 行高く (しかもメニューの最初の 2 秒から読んでいて、
そこは残りより半行遅くポールする)、フレーム 585 では 0.15 だった。
翌日の午後、エンコーダをダイに押さえた状態で (ntsc-crt v0.2.10)、
poll-line.py が始まりを 244 + 280/341 に置き、模型の位置は自分のストローブが
立ち上がるところで読んで (POSITIONS=1 pad-log。ボードが $4016 への書き込み
ごとに PPU の位置を記録する)、導出はしていない:
| 画面 | 実機、立ち上がり (14 回の中央値) | 模型、立ち上がり (同じラッチにわたる中央値) |
|---|---|---|
| メニュー、冷えた状態、ラッチ 554 から 568 の記録 5 本 | 119.375 (ばらつき 0.08) | 119.358 (ラッチ 294 から 308 とそれ以降。最初の 127 ラッチは 119.83 で、その後これ) |
| メニュー、温間リセット、ラッチ 294 から 308 | 119.369 (ばらつき 0.08) | 119.358 |
| ゲーム、ラッチ 554 から 568 | 250.733 (ばらつき 1.7: VRAM の突発) | 250.710 (ばらつきは同じ) |
二つの画面、二つのポールのルーチン、そして実機と模型は、メニューでは 100 分の 1 行、ゲームでは 100 分の 3 行まで一致し、ストローブの立ち上がりは どちらでも立ち下がりの 24 ドット前にある。
メニューの最初の 2 秒 (exercise/menu-early-8.txt、-60、-124。走行
20260918-154203、-154311、-154424。menu-early-8/60/124 として記録)。
模型のメニューは最初の 127 ラッチで半行遅くポールし (119.80 から 119.87)、
ラッチ 127 から先は 119.36 になる。だから実機にも同じことを、三チャネル同時に
問うた: ブリッジの TRIG をチャネル 1 に、リセットの前に仕掛けておく
(RESET はブリッジのトリガを消すし、スコープは仕掛かるまでに数秒かかる)。
ラッチを 2 に、映像を 3 に。三チャネルでのスコープの深さである 6 M 点、
1 記録に 7 回のポール。poll-line.py --trig-ch 1 はポールをそのパルスから
番号付けする (チャネル上でいちばん長い high の区間。そこにはパッドのクロックも
小さな瞬きとして乗っている。ラッチ n はその前の最後の立ち上がりだ)。模型自身
のストローブの位置に照らして、ラッチごとに:
| ラッチ | 実機、立ち上がり | 模型、立ち上がり | 最大の差 |
|---|---|---|---|
| 7 から 12 | 119.81 から 119.89 | 119.79 から 119.87 | 0.10 行 |
| 59 から 64 | 119.81 から 119.89 | 119.81 から 119.86 | 0.04 行 |
| 123 から 126 | 119.81 から 119.89 | 119.80 から 119.85 | 0.09 行 |
| 127、128 | 119.375、119.369 | 119.416、119.349 | 0.04 行 |
実機はラッチ 127 で半行落ち、それは模型と同じラッチだ。そして三つの記録の どのラッチも、模型のものから 10 分の 1 行より遠くには座っていない。早い区間 では両側がフレームからフレームへ 0.08 行揺れる。その揺れはラッチごとに同じ ではないし、同じであるべきだと言うものはここに何も無い (模型の揺れは 12 ラッチの周期で回る。実機のものは周期を読んでいない)。
そこへ行く途中の発見、取り下げ。 リレーで冷えた状態から電源を入れると、
実機のメニューは Select を受け取るのに Start はまるで受け取らないように見え、
二度目のリセットの後には Start を受け取った (9 回の走行: 1 ラッチ、2 ラッチ、
400 での押下、60 ラッチの保持、2 回の押下)。バイトはコンソールに届いていた
(ポールの 4 番目のスロットでポートのデータ線が low)。模型は RAM を十通りに
埋めても冷えた状態で押下を受け取り、この違いは冷えた起動が残す状態のせいだと
片付けられた。原因はヘッドだった: ブリッジのラッチの数え上げがブリッジの
RESET を生き延びていたので、リセット後の WAIT が即座に返り、スコープは
押下より前のメニューを捉えていた (それらの走行のログで wait for latch から
armed までは、仕掛けにかかる 3 秒だ。「受け取った」1 回では 12.8 s)。
ブリッジの受領で数を消すようにすると、押下はリセットがまったく無い状態でも、
1 回でも、3 回でも通る (走行 193915、194105、194257 が、その順のリセットの
回数だ)。ポールはタイトルの 247 行と 249 行にある。その顛末は open-items.md にある。模型の記録から読んだ
メニューの論理はそのまま成り立つ: 押下がマスクを仕掛け、離すとスイッチが
撃たれる。Select は別の道だ。
分割 (tools/split-score.py、exercise/split.txt、走行
20260918-135721、翌日の午後)。温間リセットの手順 (リセット 2 回、それから
押下: 当時はそれだけが効くように見えた。上を参照) でゲームに到達し、ラッチ 520
から Right を保持、ラッチ 600 でブリッジの TRIG によってスコープを止め、
映像は CH3: マリオが右へ歩く 8 フレームだ。この測定は、上の主張が求めている
ものそのもので、分割がどこにあるかをどちらの側にも教えずに行った: 2 フレーム
離れた二つのフレームのあいだで、絵の各行の水平方向のずれを、復号した輝度から
(その行の相互相関のピークを補間して、ドットで) 求める。実機の記録の上と、
模型のフレームの上で。ステータスバーの行は動かない。レベルの行はスクロールの
進みぶん動く。何も乗っていない行には答えが無く、平坦と報告される。実機と模型
は行ごとに同じ絵を出した: 15 行から 32 行は静止 (バーの文字。コムの届く範囲が
1 行それを越える)、33 行から 46 行は平坦 (最初の雲の上の空)、47 行から下は
動いていて、2 フレームで実機が 1.94 ドットに対して模型が 1.89、4 フレームで
4.34 に対して 4.30、そしてレベルを通して同じ平坦な帯 (73 から 142、161 から
173)。だからバーは 32 行までスクロールせず、レベルは両方で 47 行から
スクロールしていて、分割はそのあいだにある: 14 行の平坦な行は絵のもので、
このフレームのどんな測定も、それより狭く分割を挟むことはできない。解剖の
31 行での書き込みは、32 行から効き始めるので、その括りの内側にある。
三つ目の比較、実機のトリガされたフレームを模型の F-1 から F+2 に照らしたもの
(バーの行が二つの絵のあいだの一定のずれを与え、レベルの行がその先のスクロール
を与える) は、その午後に道具が持っていた規則の下で模型の F+1 を名指した:
レベルは模型の F より 1.27 ドット先に座っていて、その F+1 からは 0.11 だった。
それはゲームではなく、トリガの規約のほうだった。あの規則はラッチが落ちた
フレームの後の絵を取るもので、ブランクの頭でポールするゲームには正しい。だが
これは 250 行、垂直同期の始まり (244 行ドット 280) より後でポールするので、
トリガはそのフレームの同期を過ぎたところに着地し、復元は次の同期に錨を下ろし、
返ってくる絵はそのまた次のものになる。
Console::run_to_picture_after_latch はいま、記録されたラッチの位置から決める
(nes @ efbcc46、tests/latch_frame.rs。その始まりはドット一つ両側で釘付けされ
ていて、MUTATE=1 で赤)。capture-score と split-score はそれを共有し、この
記録は F+0 を名指して、レベルはバーより 0.11 ドット先だ。E2 のタイトルは
静止した絵なので、この違いを見せられなかった。スクロールするフレームは
ただちに見せた。合成での往復 (模型自身のフレームから実機を合成したもの) は
同じ括りに収まり、進みも 4 分の 1 ドット以内で同じ、F も同じで、1 フレーム
遅れて合成したり、同じフレームを二度使って合成したりすると赤になる。
これが種を撒くもの
百科事典のための五つの模様、それぞれに数字が付いている: NMI のガードを伴う、
割り込みの内側のゲームループ。ステータスバーのためのスプライト 0 の分割。
ブランクで吐き出される VRAM バッファ。ジャンプエンジン (JSR の後に置いた表
を通して、引き抜いて跳ぶやり方)。そして状態の振り分け (プレイヤーの状態で
索引する表)。それに手法が一つ: 1 バイトだけ違う二つの走行のあいだでルーチンの
木を差分すると、コードを 1 行も読まずに、ある行動に属するルーチンを名指せる。
ビルド時に nes-bench/docs/mario-dissection.md から取り込んでいる。写しはリポジトリの一つだけだ。報告には私たちの間の言葉がいくつか残っている: 報告書で使われる言葉。