6502tinymachines

較正カートリッジを作る

手引きを一つ。カートリッジを作った日 (2026-09-13、nes @ 35cfe6e) を そのまま伸ばしたものだ。着想から ROM ファイルへ、ファイルから模型の画面へ、 そこからコンソールに差すカートリッジへと進み、どの段も、信じなければならない もので終わる代わりに、確かめられるもので終わる。すでにやって測った段には その日付が書いてある。あなたがやる番の段にも、そう書いてある。

これが仕える計画は calibration-plan.md だ。コードは nes リポジトリの crates/nes-console/src/cal.rs で、このページにある日付でない数字はすべて、 それを走らせて出てきたものだ。

0. 何ができあがるか

  • cal.nes、40,976 バイト: iNES ファイル、マッパー 0 (NROM)、32 KiB の プログラム、8 KiB のタイル。誰のゲームでもない、この一族自身のものなので、 ライセンスの問いなしに複製でき、配れて、焼ける。
  • その隣の cal.json: マニフェスト。帯のどのブロックも、どの画面のどの実測 領域も、どこにあるかをドットで、そして中身が何かを書いたもので、タイルを 並べたのと同じコードが書く。
  • タイマーとパッドで動く八つの画面。それぞれ上端に沿って帯があり、画面、 変種、フレームカウンタ、パッドのバイトを、白と黒のブロックで綴る。
  • 手にしているカートリッジがこれだと知る道が二つ: その sha256 と、本体の crc32 (読み取り器自身のチェックサム)。どちらも下にある。

1. 着想: 自分の名を名乗るフレーム

このベンチでのこれまでの比較はどれも、絵を時刻で並べていた: グラバーの フレームには Pi の時計、スコープの記録にはトリガのサンプル、残りは手で。 較正画面は代わりに、自分の名前を絵の中に持ち運ぶ。横に 15 ブロック、下に 3 行、各ブロックはタイル二枚四方 (16 x 16 ドット) で、白か黒。それぞれが 1 ビットだ。

行ブロックフィールド
A3同期 101
A3画面 id、0 から 7
A6その画面の中の変種
A1ホールド (Start が押された)
A1他のすべてのフィールドのパリティを 1 ビットに畳んだもの
A1予約、0
B3同期 010
B4フレームカウンタのビット 11 から 8
B8フレームカウンタのビット 7 から 0
C3同期 110
C4フレームカウンタのビット 15 から 12
C8コンソールが最後のポールで読んだパッドのバイト、A がビット 0

グラバーのフレーム、復号したスコープの記録、模型のフレームは、帯を読むこと で突き合わせられる。そしてパッドの反響があるおかげで、スクリプトが コンソールを動かし、どのバイトが届いたかを絵が証明できる。ブロックは黒地に 白で、デコーダがいちばん小さいクロマ誤差で読むパレット項目二つ ($30 と $0F) を使い、タイルの境界に乗っているので、絵の中からそれを探さなければ ならないものは何も無い。

2. 要るもの

  • 兄弟をチェックアウトし、そのダイデータを取得した nes リポジトリ (../2a03、../2c02。README のビルドの節)。スイッチレベルのチップ三つが 先に自分の表を測るので、コンソールを冷えた状態からビルドすると数分かかる。
  • Rust のツールチェーン。このワークステーションではシェルでまず export PATH="$HOME/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH" が要る。はぐれた古い rustc が正しいほうを覆い隠すからだ。
  • 実機のためには: カードから iNES ファイルを取るフラッシュカート、または 32 KiB のプログラム ROM と 8 KiB のキャラクタ ROM を載せた NROM 基板と それらを書き込む手段 (第 8 節)、そして書いたものを証明するための カートリッジ読み取り器 (cartridge.md)。

3. コードでカートリッジをどう書くか

この一族のカートリッジはバイト列を返す関数だ (testrom.rs: パッドの カートリッジ、カラーバー)。あれらは裸のオペコードにコメントを付けて書いて あって、一画面に収まるプログラムならそれでいい。こちらにはメニュー、 タイマー、180 タイルの帯、そして八つのネームテーブルがあるので、cal.rs は 60 行のアセンブラから始まる: バイトの Vec、ラベルの表、そして、まだ存在 しないラベルを名指しする分岐とジャンプのための修正の一覧だ。

a.label("main");
a.e(&[LDA_ZP, Z_NMI]);       // NMI ハンドラのフラグを待つ
a.br(BEQ, "main");
a.e(&[LDA_IMM, 0x00, STA_ZP, Z_NMI]);

e はバイトを足し、br は後で解決される分岐、jsr と jmp も同じ。 オペコードは名前付きの定数なので、1 行が 6502 として読める。finish() は 修正をすべて埋め、届かない分岐を拒む。最初のビルドがそれを踏んだ: NMI ハンドラの本体は 1000 バイトを超え、相対分岐の届く先より遠いので、その スキップは JMP を回る分岐になっている。生成された ROM は、コードが $9000 のデータに走り込んだらアサーションで拒まれる。

4. メモリマップとフレームの予算

プログラム全体には、固い制約が一つある: PPU が描いている最中にその メモリへ書くと絵が壊れるので、どの書き込みも垂直ブランキング、1 フレーム およそ 2,270 CPU サイクルの中に収まらなければならない。帯だけでタイルの 書き込みが 180 回とアドレスの書き込み 6 回、パレットがさらに 16、パッドが 8 回の読み出し、それに強調とスクロールが数回で、命令を数えるとおよそ 1,700 サイクル。タイルが何であるべきかをそこで決める余地はもう無いので、 こうなっている:

  • NMI ハンドラは PPU との往復しかしない。フレームを数え、パッドを $04 へ読み、$80 から 16 バイトのパレットと $20 から帯の 90 バイトを PPU へ写す。ブロックは高さ 2 タイルなので、ブロックの 3 行はそれぞれ 二度書かれる (合わせて 180 タイル)。それからスクロールを戻し、マスクを 書く (背景オン、強調ビットは $90 から)。
  • メインループは NMI ごとに一度、残りのすべてをやる: 新しいボタンの 押下に気づき (Select が画面を進め、Start がホールドを切り替える)、 タイマーを走らせ (VARIANT_FRAMES[screen] フレームごとに変種を一つ、 VARIANTS[screen] 個まで、そしてホールドされていなければ次の画面へ)、 画面が変わったらネームテーブルを描き直し、次のフレームのパレットと帯を そのバッファへ組み立てる。

結果として、マニフェストが述べている遅れが生まれる: フレーム k を終える ブランキングでポールされたバイトは、k+1 のあいだにバッファへ組み立てられ、 k+1 を終えるブランキングで書かれ、フレーム k+2 で見える。実機と模型は それをきっちり共有し、フレームカウンタが両方のフレームに名前を付ける。

画面が変わると、1,024 バイトのネームテーブルと属性が $A000 + screen * $400 から写されるあいだ、1 フレームだけ描画が切られる。 NMI はそのフレームでも発火して数えるので、カウンタが飛ぶことはない。 写しの最中に PPU に触らないだけだ。

ゼロページ、模型の中でこれを見たい読者のために: $00-01 フレームカウンタ、 $02 画面、$03 タイマー、$04 パッド、$05 前のパッド、$06 ホールド、 $09 変種、$0A 描き直し要求、$0B NMI が起きた、$0E ロード中。

5. 画面

どの画面もタイル位置の関数 content(screen, tx, ty) で、タイルとパレットを 返す。ネームテーブルとその属性テーブルはそこから生成され、生成器の中の検査 が、2 x 2 の属性の区画に二つのパレットが入るレイアウトを拒む。背景色はどの 画面でも黒、パレット 3 の三番目の色はどの画面でも白 (帯の色) なので、各画面 が自分で持てる色は 11 色だ。

id画面帯の下の中身
0strip何も無し: 黒と白のレベルだけ
1palette8 枚のパッチ、64 x 80 ドット、4 枚ずつ 2 段。7 ページのうちページ p は色相 2p+1 と 2p+2 を四つの輝度で見せ、ページ 6 は灰色と $xD の列を見せる。変種 (0 から 55) はページの 8 倍に強調ビットを足したもので、それぞれ 60 フレーム
2barsカラーバーのスロット順に 32 ドットのセルが 40 個。8 つのうち変種 v は輝度 v/2 と、色相 1 から 11 または 2 から 12 を見せるので、どの色相もどの輝度で現れる。それぞれ 120 フレーム
3gratings上の帯に 1、2、4、8 ドットの縦線、中ほどに同じピッチの横線、その下に 1 ドットと 2 ドットの市松
4edges幅 64 ドットの黒と白の面、続いて赤と緑の面。だから輝度の段が両方向にあり、クロマの段も一つある
5dotcrawl1 ドットの市松を青で、2 ドットを橙で、それぞれ画面の半分ずつ
6pad256 x 160 ドットの面が一つ。何も押していなければ灰、どれかのボタンで緑。帯がそのバイトを反響する
7geometry絵の縁に 1 ドットの枠、32 ドットごとに目盛としてタイル一枚ぶん、(128, 120) を通る 1 ドットの十字

変種が一つだけの画面はそれぞれ 240 フレーム続く。この巡回は無人で回るので、 実機と模型はパッドに誰もいないまま一致できる。Select と Start は、一つに 留まりたい回のためにある。

CHR は 20 タイル: 空白、三色それぞれの塗り、9 種の格子と線の模様、そして 四つの角。

6. マニフェスト

manifest() は同じ関数群を歩いて JSON を書く: 帯のジオメトリとその 12 個の フィールド、そして各画面について、その領域が変種ごとに見せるパレット項目か、 描く模様のどちらか (v1、h4、chk2: 種類、ドットでのピッチ、軸)。絵を 読む道具は、どこを見るかをマニフェストから読む。道具に手で打ち込んだ矩形は バグだ。二つが漂うことになるからだ。

{"name": "patch0", "x": 0, "y": 64, "w": 64, "h": 80, "entries": [1, 1, ..., 33, 33, ...]}
{"name": "v2", "x": 64, "y": 64, "w": 64, "h": 64, "pattern": "v2", "pitch": 2, "axis": "x", "colour": 48}

7. ビルドし、模型で走らせ、読み返す

cd nes
export PATH="$HOME/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH"
cargo run --release -p nes-console --example export-testrom -- out/cal.nes cal
cargo run --release -p nes-console --example cal-screens -- out/
cargo test --release -p nes-console --test cal
MUTATE=1 cargo test --release -p nes-console --test cal      # 赤にならなければならない

1 行目は cal.nes と cal.json を書く。2 行目はカートリッジをコンソールで 走らせ、手がやるように Select を押し、各画面をこの一族自身の復号を通して PPM で書き、そのフレームの帯が言っていることを印字する (実測 2026-09-13: 画面 0 はフレーム 7、そこから 6 フレームごとに次へ、ホールドは立っていて、 パッドは 00)。3 行目がゲートだ: 帯は最初に描かれたフレームから読めて 1 ずつ 数える。どの画面のどの領域も、マニフェストが言うものを、模様については ドット単位で持っている。タイマーは 240 フレームで画面 0 を 1 へ進め、 パレット画面の変種は 60 で進める。パッドのバイトは、それをポールした ブランキングの 2 フレーム後に反響され、パッド画面の面は同じフレームで緑に なる。4 行目は、マニフェストが置いている場所よりタイル一枚右で帯を読むので、 失敗しなければならない。そして実際、帯を読む四つのテストで失敗する。失敗 できない検査は検査ではない。

八つの画面すべてを、デコーダが見るとおりに描いた絵が、最初に見る価値のある ものだ: ドットクロールの市松は、クロールを抱えた平坦な色の面として出てくる。 これは NTSC の帯域での 1 ドットの模様について、デコーダが本当のことを言って いるのであって、画面 5 はそのためにある。

8. カートリッジに載せる

チェックサム (実測 2026-09-13、nes @ 35cfe6e)。 フラッシュカートの 写しは、両方が一致したときにこのカートリッジだ:

値
cal.nes の sha2564b9d92ebc78ccccce55f150fe237e845b5a27a46c7a28a5fbbe6188a9c8ef6b2
本体の crc32 (16 バイトのヘッダより後のファイル。読み取り器が出す数字)21091B99

ファイルはこのリポジトリの roms/ (gitignore してある。上の書き出しの行で 作り直すこと)、Pi の ~/nes-bench/roms/ に確かめるための SHA256SUMS 付き (sha256sum -c SHA256SUMS)、そして tinymachines.ai/nes/cal.nes に /nes/cal.json を隣に置いて配っている。

フラッシュカート。 cal.nes をカードに写し、カードを持っている機械の 上でその和を確かめ、起動する。それから読み取り器 (cartridge.md) が、 コンソールが見ているものを吸い出し、計算した crc32 を印字する。それは表の 値でなければならない。これはあなたの番の段だ。ここにあるものは、まだ実機の 上では何も走っていない。

物理の基板 (オーナーのビルド仕様、2026-09-14)。 基板は空のボードのページ (cart-blanks.md) にある NES CART PCB の "discrete mapper board" v3.3 だ: 32 ピンのフットプリントが二つ、CHR が U3 で PRG が U4、どちらも SST39SF040 (512 KiB、5 V のパラレルフラッシュ) を取り、XGecu Pro (TL866 の 一族、XGpro のソフト、デバイスは SST39SF040) で書き込む。NROM は基板の ロジックを一つも要らないので、U5 から U7 は空のまま。このコンソールの ロックアウトは無効化してあるので、CIC の位置 U2 も空のまま。H/V の はんだジャンパはヘッダのミラーリングビットに従い、このファイルでは垂直だ (絵はスクロールせず、ネームテーブルを一つしか使わないので、どちらでも同じ ものが見えるが、基板とファイルは一致しているべきだ)。この基板では、垂直は H と記されたパッドだ: 文字は逆で、その横に印刷された言葉は正しい。これと ほかのすべてのジャンパは、基板の両面とも、cal-cart-build.md にある。 進むままに書いた組み立ての記録だ。

チップはイメージより、PRG で 16 倍、CHR で 64 倍大きく、NROM 基板は 19 本の アドレス線のうち 15 本と 13 本しか駆動しないので、イメージはゼロで埋める のではなく、チップを満たすように敷き詰める: そうすれば、駆動されない線の どの状態も写しのどれかに着地する。tools/nesprep.py が分割と敷き詰めを やり、ゴミを焼くことになる場合を拒む (マジックの無いヘッダ、ヘッダより短い ファイル、チップを割り切らない領域、CHR のサイズがゼロ、つまり CHR RAM で あるもの):

python3 tools/nesprep.py roms/cal.nes            # -> roms/cal-flash/prg.bin, chr.bin
python3 tools/nesprep.py --selftest roms/cal.nes # どの写しもその領域に押さえる。MUTATE=1 で赤

書き出した cal.nes の上で実測 2026-09-13:

イメージ写しバイトsha256
prg.bin (U4)32 KiB が 16 個5242883ca73ce7d67e78412739857f9fe6bccde7e09a03b6ebc5cbe5ab38c771efed94
chr.bin (U3)8 KiB が 64 個524288fb61eb01b3218701270f3924570ec2ebcee758c12f39154f99338f7cf826270d

焼くのも、同じ仕様から (実際に使ったのはベンチの Pi の上の minipro で、 デバイスは SST39SF040、コマンドは cal-cart-build.md にある): XGpro で SST、SST39SF040 を選ぶ。チップは ZIF ソケットの下詰めで、ピン 1 をレバーの側に向け、上の 8 箇所は空け、ケースの 矢印を合わせて座らせる。まず空のチップを読み、その ID が実物のものでなければ 止める (貼り替えた偽物は、コンソールで失敗する前にここで違う値を読む)。 prg.bin を読み込み、書き込み、照合する。二枚目のチップに chr.bin で同じ ことをする。PRG を U4 へ、CHR を U3 へ、ジャンパはスクリプトが印字した ミラーリングへ。それから読み取り器で吸い出す: crc32 21091B99 ならこの ROM で、アドレス線の配線を一本間違えていれば、解釈できる画面として現れる よりはるかに早く、そこに現れる。

9. 次に来るもの

カートリッジがコンソールに入り、帯がグラバーから読めるようになれば、計画の C1 が始まる: パレット画面をスコープと捕捉の経路に通し、模型に照らして項目 ごとに比べる。そのための道具 tools/cal.py は組んであり (calibration-plan.md、C1)、grab から始まる。これは 模型の読み取り器と同じやり方で、マニフェストの矩形に沿ってグラバーの フレームから帯を読み、帯が読めないフレームは推測する代わりに拒む。

ビルド時に nes-bench/docs/build-the-cal-cart.md から取り込んでいる。写しはリポジトリの一つだけだ。報告には私たちの間の言葉がいくつか残っている: 報告書で使われる言葉。