較正カートリッジの計画
2026-09-13、ブレッドボードが基準の状態に届き、物理カートリッジの部品が揃った
晩に書いた。カートリッジは一つ、一族の他のものと同じようにコードで組み、その
どの画面も測られるために設計する: スコープ、グラバー、カメラを通して実機から、
そして同じ復号器を通して模型から、同じ道具が両方を読んで。色、解像度、
フィルタ、そしてパッド。難しさの順でこれで、パッドがベンチを作った目的の輪を
閉じる。bench-plan.md のマイルストーン (B0 から B3) と closed-cycle-plan.md
はそのまま立っている。これは、それらの比較を述べるのを安く、ごまかすのを
難しくするカートリッジだ。
すでに本当のこと
- カートリッジは関数だ。
nes-console/src/testrom.rsが pad、pad-dmc、 pad-paint、bars のカートリッジをバイト列として組み、export-testrom <out> <kind>がそれを iNES として書く。bars のカートリッジはすでに入力なしで フレームのタイマーに合わせて 4 行の輝度を巡回させるので、実機と模型が 無人で同じように走らせる。 - 捕捉は一つの復号器を通して模型に対して採点される。
capture-scoreは 模型自身のフレームの中に平坦な領域を見つけ、復号器のクロマフィルタから 整定のマージン (margin_dots) を決め、スコープの記録から領域ごとに輝度、 色相、彩度を採点する。ベンチのb1-score.pyがそれを駆動する。eyes.pyは グラバーを、スコープの復号済みの記録と模型の両方に対して採点する。hue-stageは色相の誤差がどこに住んでいるか (負荷の下での実機の出力) を 実測した。 - パッドは両側でスクリプトにできる。 ブリッジの
MODE INJECT、SET hh、AT n hhがラッチ番号のところで 1 バイトを保持する。模型は同じスクリプトをset_padから受け取る。トレースの例は、8 回の読み出しが綴るバイトを オラクルとしてスクリプトに突き合わせ、ダイが記録を再生する。 - 目は据え付いた。 BRIO は名前付きの視野を持ってマウントに載っている。 グラバーは 720x480 のフレームをまるごと届ける。スコープはスクリプトで 待機させ、ブリッジのトリガで発火させる。
一つだけ新しい仕掛け: 自分の名を名乗るフレーム
これまでの比較はどれも、絵を時間 (Pi の時計、スコープのトリガの標本) か手で 合わせてきた。較正の画面は、自分の名とフレーム番号を絵の中に運ぶ: 上端の数行 に並ぶ高コントラストのブロックの帯で、画面の id とフレームのカウンタを二進で、 復号器がいちばんクロマの誤差なく読む二つの灰色で、黒地に白で。そうすれば、 グラバーのフレーム、復号したスコープのフレーム、模型のフレームは、トリガが どのフレームを捉えたかを当てるのではなく、帯を読んで突き合わせられる。帯は 自動パッドの読み出し表示でもある: パッドの画面は、コンソールが最後の ポーリングで読んだバイトをさらに 8 個のブロックに反響させるので、スクリプトから ブリッジ、コンソール、絵、グラバーへの輪が 1 バイトの上で閉じ、バイトは スクリプトに一致するかしないかのどちらかになる。
帯はタイルから描かれるので、その幾何は PPU 自身のものだ: ブロックの端はタイルの 境界に落ち、読み取り器は絵を探すのではなくマニフェスト (下記) からどこを見れば よいかを知る。
カートリッジ
NROM、PRG 32 KiB と CHR 8 KiB。他のものと同じなので、同じカートに焼けて同じ リーダーでダンプできる。画面はそれぞれネームテーブル一枚とパレット一組で、 選び方は二つ: フレームのタイマーによる自動巡回 (bars がそうするように、模型と 実機が無人で一致する) と、パッドによる選択 (Select が画面を進め、Start が 保持する) で、一回の作業を一つの画面に停めておける。
| id | 画面 | 何を測るか | どう読むか |
|---|---|---|---|
| 0 | 背景色の上に帯だけ | 帯がそもそも読めること。黒と白のレベル | グラバー、スコープ: 同期、ブランキング、残りが基準にする二つのレベル |
| 1 | パレット | 64 項目すべてを平坦なパッチとして、輝度の 4 行、背景色は $0F。強調 (エンファシス) ビットごとにもう一巡 (タイマーで 8 つの変種) | capture-score の領域を通してスコープの記録から。eyes.py の模型比較を通してグラバーから。色相の表と実機の DAC を、項目ごとに |
| 2 | bars | 既存の bars の画面をそのまま | すでに採点済み。古い測定に対する継続性の検査 |
| 3 | 格子 | 1、2、4、8 ドットの縦線、続いて横線。市松模様 | 解像度: 各ピッチでのコントラストをグラバーと復号器から、同じピッチでの模型自身のコントラストに対して (MTF、4 点) |
| 4 | エッジ | 黒から白への段差と白から黒への段差、両側に広い平坦な面、輝度だけで。続いて彩度の高い二色の間の同じ段差 | フィルタ: グラバーと復号器のエッジ応答、リンギングと立ち上がりを模型に対して。クロマの段差と輝度の段差の比較が、帯域がどこにあるかを言う |
| 5 | ドットクロール | フレームごとにクロマの位相が入れ替わる細かい色の格子 | 三フレームの位相の巡り: 復号器のコム、グラバーのコム、そして模型のフレームのパリティが実機のものと合うか |
| 6 | パッド | 帯が反響する最後のポーリングのバイト、BCD のポーリング数、そしてボタンが着地したフレームで色が変わる面 | 自動パッド: 反響したバイトを、そのラッチでのスクリプトのバイトに対して。レイテンシ: 面が変わるフレームをグラバーから、ブリッジがログしたラッチに対して |
| 7 | 幾何 | 有効領域の端に 1 タイルの枠、中心に十字線、32 ドットごとに目盛り | オーバースキャンとグラバーの切り取り。スコープのラインのタイミングを模型のものに対して |
どの画面も帯を持つ。画面 1 と 5 は停めている間もタイマーで進む。測っているものが 巡りだからだ。
マニフェスト。 export-testrom cal <out> は cal.nes を書き、その隣に
cal.json を書く: 画面ごとに、測る価値のある領域すべて (ドット単位の矩形、
そこにあるパレット項目かパターン、帯が何を符号化しているか) とベクタを、タイル
を敷いたのと同じ生成器から導いて。絵について二度打ち込まれるものは何も無い。
絵を読む道具は、どこを見るかをマニフェストから読む。
道具: tools/cal.py
どの目にも使える一つの読み取り器。ベンチのリポジトリにあり、b1-score.py と
eyes.py がすでにやっているやり方で、模型を nes-console 経由で、復号器を
ntsc-crt の例経由で駆動する:
python3 tools/cal.py model cal.nes [--screen N] # 模型のフレームと、その期待される読み
python3 tools/cal.py grab cal.nes <frames.jpg...> # 帯を読み、画面を突き合わせ、採点する
python3 tools/cal.py scope cal.nes <record.u8> <rate> # 復号した記録を、同じ採点で
python3 tools/cal.py pad cal.nes <script.txt> <frames...> # 反響したバイトをスクリプトに対して
python3 tools/cal.py report <run-dir> # 画面ごとに一つの表、実測で日付入り
どの読みも、打ち込んだ目標に対してではなく、同じ経路を通した同じ画面の模型の
読みとの比較だ: パレットのパッチは、capture-score がすでに持っている許容の
内側で模型の復号したパッチと一致すれば正しい。格子は、模型に対するコントラスト
比が述べた帯の内側にあれば正しい。実機と模型が食い違うところでは、道具がどの
画面、どの領域で、どれだけ違うかを言い、その差こそが発見になる。色相の段の
ときと同じように。
マイルストーン
C0: カートリッジが存在し、両側で走る。機械側 2026-09-13 完了
(nes @ 35cfe6e: crates/nes-console/src/cal.rs、そのテスト
tests/cal.rs、export-testrom cal、cal-screens)。testrom.rs の
cal_program() として計画していたものは、小さなラベル解決付きのアセンブラを
持つ独立したモジュールになった。八つの画面、タイマー、パッドで動くメニュー、
180 枚のタイルの帯は、素のバイト列として書いて正直さを保てる量のプログラムでは
なかったからだ。組んでみて決まったことが二つ:
- 帯は 1 行ではなく、15 個のブロックが 3 行で、各ブロックは 2 タイル四方。 限界は NMI のブランキングの予算だ (PPU への書き込みを数えて実測すると、 2,270 サイクルのうち約 1,700)。そこで主ループが次のフレームのタイルと パレットを組み、NMI はそれを写すだけにする。代償は、パッドをポーリングする ブランキングから、それを反響する帯のフレームまでの 2 フレームの遅れで、 実機と模型で同一であり、マニフェストにそう書いてある。
- 帯は色を一つ食う (パレット 3 の色 3、どの画面でも白)。そして背景色はどの 画面でも黒なので、一つの画面は自分の色を十一色持つ。だからパレットの画面は 64 x 80 ドットのパッチを 1 ページに 8 枚、7 ページにわたって見せ、bars の 画面は一変種に十一色相を 8 変種にわたって見せる。全部を通せば、どの色相も どの輝度でも出る。どちらも変種ごとにマニフェストに入っている。
走らせたゲート: tests/cal.rs の五つのテストは緑 (帯は最初に描かれたフレーム
から読め、1 ずつ数える。Select は八つの画面すべてを進め、マニフェストのどの
領域もその項目かそのパターンをドット単位で保つ。タイマーは画面 0 を 240
フレームで 1 へ、パレットの画面の変種を 60 フレームで進める。パッドのバイトは
それをポーリングしたブランキングの 2 フレーム後に反響し、パッドの画面の面は
同じフレームで緑になる)。そして MUTATE=1、読み取り器をタイル 1 個右に
ずらすと、帯を読む四つが赤になる。八つの画面は一族自身の復号 (cal-screens)
を通して目で見た。
ファイル (実測 2026-09-13、nes @ 35cfe6e から出力):
| ファイル | バイト | sha256 | 本体の crc32 |
|---|---|---|---|
cal.nes | 40976 | 4b9d92ebc78ccccce55f150fe237e845b5a27a46c7a28a5fbbe6188a9c8ef6b2 | 21091B99 |
cal.json | その隣のマニフェスト、8 画面、69 領域、12 の帯のフィールド | roms/SHA256SUMS の中 |
ここの roms/ と Pi の上 (sha256sum -c SHA256SUMS) にあり、
tinymachines.ai/nes/cal.nes で /nes/cal.json を隣に置いて配っている。
C0 の実機側: 未了。 ROM をフラッシュカートへ (あるいは物理カートへ、
docs/build-the-cal-cart.md)、リーダーのダンプを上の CRC に対して、自動巡回と
Select をグラバーで見て、そしてグラバーのフレームから最初の帯を読むこと:
tools/cal.py grab、C1 の最初の道具で、組んであり (下の C1)、部品からのフレームを待っている。
C1: 色。機械側 2026-09-13 に完成、実機側は未了。 tools/cal.py は存在する
(nes-bench @ 4cbd14b): grab はグラバーのフレームの中にコンソールのグリッドを
帯自身の構造から見つけ (帯が読めるオフセットをすべて出し、その中で、読んだ
ビットから描いた理想の帯といちばん合う帯域のものを選ぶ。復号はあらゆる端を
にじませるので、段差のエネルギーでは同点になるからだ)、マニフェストの矩形で
帯を読み、読めないフレームは拒み、画面の平坦な領域を、同じ画面と同じ変種の
模型の復号した絵に対して採点する。scope は記録を復号し、その帯を読み、
同じフレームまで capture-score を走らせる。model はフレームのオフセットを
実測する (実測: c と読める帯は、電源投入から数えて模型のフレーム c + 3)。
selftest は模型の八つの画面と、同じ絵をグラバーの幾何に再標本化し、道具には
教えていないオフセットに置いて JPEG で圧縮したものを読む: すべて 1 標本の内側で
見つかる。復号の経路は帯をマニフェストより 1 行上に置くので (コムのライン
遅延)、見つける側が絵に従う。MUTATE=1、ブロックの半分ずれると、十六個すべてで
赤になる。採点器自身のパレット画面での合成の往復 (capture-score cal.nes 250
と 310: 変種 0 と 1、強調 0 と 1) は 1 ページの 8 パッチすべてを N6 の許容の
内側で読む。捕捉より前に出た緑だ。残っているのは実機の側だ: カートリッジを
コンソールに入れ、パレットの画面のスコープの記録を変種ごとに取り、それぞれに
cal.py scope をかけること。それからグラバーのフレームを cal.py grab に
通すこと。画面 1 をスコープと capture-score の領域を通して、そしてグラバーと
eyes.py の経路を通して、模型に対して。ゲート: 64 項目すべてを各輝度で
マージンの規則付きで採点し、その差の表を docs/calibration.md に日付付きで。
8 つの強調の変種も同様に。回帰: bars の画面が 2026-09-06 に採点したとおりに
許容の内側で採点されること。C1 が出すのは、実機の DAC を書き写した表の隣に
置いた表で、hue-stage がやったような色相ごとではなく項目ごとのものだ。
C2: 解像度とフィルタ。 画面 3、4、5 をグラバーとスコープから。ゲート: 4 点の MTF と二つのエッジ応答を、目ごとに、模型のものに対して、それぞれ一つの図に。 ドットクロールのパリティを述べること (実機のフレームのパリティを模型のものに 対して)。グラバー自身の帯域が実測されたものになり、以後のグラバーの比較は すべてそれで補正されるか、少なくともそれに対して述べられる。
C3: パッド、閉じる。 画面 6 を、ブリッジを INJECT モードにして AT n hh の
行のスクリプトで。ゲート: グラバーから読んだ反響のバイトが、そのラッチでの
スクリプトのバイトとどれも等しいこと (帯がオラクルの証人だ。ブリッジのログと
模型の set_pad はどちらとも一致する)。ラッチから面の変化までのレイテンシが、
フレーム数で、グラバーから見て模型のものと合うこと。MUTATE=1 はスクリプトを
1 ラッチ遅れて読み、どの変化でも赤にならなければならない。これは、自分の入力を
自分で報告するカートリッジによる B1 の比較であり、自動パッドの最初の一巡だ:
スクリプトが実機を動かし、絵がそれを証明する。
C4: 四つ目の目としてのカメラ。 BRIO をテレビかグラバーのモニタに向け、その フレームを、幾何の画面の枠からの遠近の補正の後で、同じ帯の読み取り器で読む。 ゲート: 帯が読めること。パレットのパッチが、述べたより広い許容の内側で 採点されること。カメラが測れないもの (それ自身のホワイトバランスとガンマ) を 述べること。そうすればカメラのフレームが捕捉と間違われることはない。
規則、一度だけ述べる
- カートリッジは一族自身のものだ: コードとしてコミットし、要求に応じて出力し、 pad や bars のカートリッジと同じように配ってよい。商用の ROM の中身はこの 計画に入らない。
- どの目標も、同じ経路を通した模型の読みであって、打ち込んだ数字ではない。 許容は一度だけ、道具の中で、それが来た測定と一緒に述べる。
- 読みは帯によって自分のフレームを名乗る。読める帯を持たないフレームは拒み、 当てはしない。
- マニフェストは生成器から導く。手で打ち込んだ矩形はバグだ。
- 実測の行は日付と走行のディレクトリを持つ。公開サイトがそれを受け取るのは、 取り込みのスクリプトを通してのみ。
ここで決めたこと
- NROM、ROM は一つ、画面はタイマーとパッドで: パッド用の二つ目のカートリッジは 無し、リーダーがまだダンプできないマッパーも無し。
- 帯はタイルの上の二進のブロックで、文字ではない: 復号器は 3 行のコードで読め、 カメラは一度の遠近の補正で読める。
- C1 の最初の目は、B1 と同じくスコープだ。伝達関数が分かっているのはこちらだ から。グラバーのそれは C2 の出力であって、C1 の入力ではない。
- 順序: C0、C1、C3、C2、C4。パッドの輪 (C3) が解像度より先に来るのは、それが ベンチの目的であり、C0 が組むもの以外に何も要らないからだ。
ビルド時に nes-bench/docs/calibration-plan.md から取り込まれる。写しはリポジトリの一つだけだ。報告は独自の作業用の言葉をいくつか使う: 報告書で使われる言葉。