較正カートリッジを作る
手引きを一つ。カートリッジを作った日 (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 ビットだ。
| 行 | ブロック | フィールド |
|---|---|---|
| A | 3 | 同期 101 |
| A | 3 | 画面 id、0 から 7 |
| A | 6 | その画面の中の変種 |
| A | 1 | ホールド (Start が押された) |
| A | 1 | 他のすべてのフィールドのパリティを 1 ビットに畳んだもの |
| A | 1 | 予約、0 |
| B | 3 | 同期 010 |
| B | 4 | フレームカウンタのビット 11 から 8 |
| B | 8 | フレームカウンタのビット 7 から 0 |
| C | 3 | 同期 110 |
| C | 4 | フレームカウンタのビット 15 から 12 |
| C | 8 | コンソールが最後のポールで読んだパッドのバイト、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 | 画面 | 帯の下の中身 |
|---|---|---|
| 0 | strip | 何も無し: 黒と白のレベルだけ |
| 1 | palette | 8 枚のパッチ、64 x 80 ドット、4 枚ずつ 2 段。7 ページのうちページ p は色相 2p+1 と 2p+2 を四つの輝度で見せ、ページ 6 は灰色と $xD の列を見せる。変種 (0 から 55) はページの 8 倍に強調ビットを足したもので、それぞれ 60 フレーム |
| 2 | bars | カラーバーのスロット順に 32 ドットのセルが 40 個。8 つのうち変種 v は輝度 v/2 と、色相 1 から 11 または 2 から 12 を見せるので、どの色相もどの輝度で現れる。それぞれ 120 フレーム |
| 3 | gratings | 上の帯に 1、2、4、8 ドットの縦線、中ほどに同じピッチの横線、その下に 1 ドットと 2 ドットの市松 |
| 4 | edges | 幅 64 ドットの黒と白の面、続いて赤と緑の面。だから輝度の段が両方向にあり、クロマの段も一つある |
| 5 | dotcrawl | 1 ドットの市松を青で、2 ドットを橙で、それぞれ画面の半分ずつ |
| 6 | pad | 256 x 160 ドットの面が一つ。何も押していなければ灰、どれかのボタンで緑。帯がそのバイトを反響する |
| 7 | geometry | 絵の縁に 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 の sha256 | 4b9d92ebc78ccccce55f150fe237e845b5a27a46c7a28a5fbbe6188a9c8ef6b2 |
| 本体の 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 個 | 524288 | 3ca73ce7d67e78412739857f9fe6bccde7e09a03b6ebc5cbe5ab38c771efed94 |
chr.bin (U3) | 8 KiB が 64 個 | 524288 | fb61eb01b3218701270f3924570ec2ebcee758c12f39154f99338f7cf826270d |
焼くのも、同じ仕様から (実際に使ったのはベンチの 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 から取り込んでいる。写しはリポジトリの一つだけだ。報告には私たちの間の言葉がいくつか残っている: 報告書で使われる言葉。