6502tinymachines

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 (素直に走っているフレームで、読み込む 列は無い):

行ドット何をどこで
1226$2002 を一度読む: スプライト 0 のフラグはクリアと見えた$813D
1660LDA $2002 / AND #.. / BEQ が 178 回回る: スプライト 0 を待って、30 行ドット 92 まで$8150
30122ヒット。DEY / BNE を 19 回、31 行ドット 66 までの遅延$8159
31100$2005 <- 02, 00: ステータスバーの下、レベルのスクロールを設定
31157$2000 <- 10: レベル用のネームテーブル
31 から 89ゲームの論理: 操作モードの木$8175 から $8212
8926$2002 を読み、$2000 <- 90: NMI オン$8178
8958RTI、そして 241 行ドット 12 まで $8057 で空回り$8181
24128NMI ベクタを取る$8082
24179$2000 <- 10: NMI オフ、ネームテーブル 0
241163$2001 <- 06: ブランク中の仕事のために描画オフ
241175$2002 を読む: ステータスがクリアされる$80A6
241211$2005 <- 00, 00: ステータスバーのためにスクロールをゼロに
241253$2003 <- 00、続いて $4014 <- 02: $0200 からのスプライト DMA
241273DMA の $2004 への 256 回の書き込み、246 行ドット 105 まで
246189$2002 を読み、VRAM バッファを吐き出す (このフレームでは何も無し)$8EDD
246228$2005 <- 00, 00
24734$2001 <- 1e: 描画オン
250295パッドをポール (ストローブの立ち下がり)、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ハンドラの $817514,777 hc操作モードの木: $8215 の JSR $8E04、表は -> $8231 (タイトル)、$AEDC (ゲーム)
$AEDC表から14,659ゲームのモード: $AEDF の JSR $8E04 -> $AEEA (プレイ中)、$8567 (遷移)
$AEEA表から14,553フレームの核: プレイヤー、続いてオブジェクト
$B04A$AEF36,123プレイヤー: $B04C の JSR $8E04 -> $B0E9
$B0E9表から6,007プレイヤーの制御: $B34E の JSR $8E04 -> 地上なら $B35A、空中なら $B376
$C047$AF05、1 フレームに 6 回3,463オブジェクトごと: 敵のスロット 6 個
$DC64$B15A2,970
$8223$814A、分割の直後2,046
$EEE9$AF161,821
$F180$B14F、$AF101,432
$8E5Cブランク中の $80E71,084パッド、両ポート
$F2D0ブランク中の $80E4955サウンドエンジン
$8F97ブランク中の $80ED877
$8EDDブランク中の $80C3265VRAM バッファを吐き出す

呼び出しの木は深さ 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 から 308119.369 (ばらつき 0.08)119.358
ゲーム、ラッチ 554 から 568250.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 から 12119.81 から 119.89119.79 から 119.870.10 行
59 から 64119.81 から 119.89119.81 から 119.860.04 行
123 から 126119.81 から 119.89119.80 から 119.850.09 行
127、128119.375、119.369119.416、119.3490.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 から取り込んでいる。写しはリポジトリの一つだけだ。報告には私たちの間の言葉がいくつか残っている: 報告書で使われる言葉。