6502tinymachines

較正カートリッジの計画

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 を、項目ごとに
2bars既存の 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.nes409764b9d92ebc78ccccce55f150fe237e845b5a27a46c7a28a5fbbe6188a9c8ef6b221091B99
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 から取り込まれる。写しはリポジトリの一つだけだ。報告は独自の作業用の言葉をいくつか使う: 報告書で使われる言葉。