pad-usb: ボタンからキー入力まで、層ごとに
純正の NES パッドを、ベンチの ESP32-P4 に USB キーボードとしてつないだとき、パッドと電話やノートの間を何が行き来するか。層が四つあり、それぞれに自分のプロトコルがあって、その先にホストがある。どの層も、次の層を信じる前に単独で確かめる。
2026-09-27 記。その晩のうちに下の計画は走り、ステップ 2 から 5 までが通った。組み立てそのもの、配線、そして Bluetooth ではなく USB である理由は pad-ble-build.md に、基板の選び方は esp32-part-choice.md にある。このページはプロトコルと計画だ。
本物のボタン操作がホストまで届く。 3.3 V の純正パッド、P4、フルスピードの USB、Linux のホスト(ベンチの Pi)が、八つのボタンすべてをそれぞれのキーとして、同時押しも含めて運ぶ。ブラウザにも見える(ステップ 6、2026-09-28、tinymachines.ai/lab/pad-keydown で): どのボタンもその code として、変化ごとにレポート一つで、ホストのオートリピートはホストのものとして見える。iPhone の上でゲームが動く(ステップ 7、2026-09-28): 八つのボタンすべてが tinymachines.ai/nes/play で遊べた。最初の一回は A、B、Select、Start の四つだけで動き、その理由はアダプタではなくホストの層にあった。何が起きて何を変えたかはホストの節にある。そこまで行くのに、ファームウェアは Arduino コアの USB クラスを離れ、別の USB コントローラへ移った。その理由と、このページの最初の版の何が間違っていたかは層 4 にある。
鎖は四つの層でできている
NES pad ESP32-P4 USB host
MN4021B --3 wires--> poll_pad() --> pad_report() --> HID report --> keydown
8 buttons one byte, bit=pressed 8 bytes 9 on the wire "KeyX"
layer 1: the pad's shift register (latch, clock, data)
layer 2: the byte as a key report (keymap.h, one table)
layer 3: the report descriptor (how the host reads those 8 bytes)
layer 4: USB itself (enumeration, one interrupt endpoint)
どの層にも、その層だけを見て他は見ない道具がある。だから故障を四つの層にまたがって推測せず、一つの層に置ける。道具は下の「計画、一層ずつ」に並べてある。
層 1: パッドはシフトレジスタだ
純正パッドの中には MN4021B が一つある。CMOS の 8 ビット、パラレル入力・シリアル出力のシフトレジスタで、八つのボタンがそのパラレル入力につながっている。パッドから出る線は五本。電源、グラウンド、そして信号が三本。
| 信号 | 4021 のピン | 向き | 役目 |
|---|---|---|---|
| ラッチ (OUT0) | 9, P/S | P4 からパッドへ | high: 八つの入力を取り込む。low: 動かなくなる |
| クロック | 10, CLOCK | P4 からパッドへ | 立ち上がりのたびに次のボタンをデータへ送る |
| データ (D0) | 3, Q8 | パッドから P4 へ | いまのボタン。押されていると low |
| 電源 | 16, VDD | ここでは 3.3 V(コンソールは 5 V を与える) | |
| グラウンド | 8, VSS |
一回の読み取りを、firmware/pad-usb/pad-usb.ino のやり方で:
- ラッチを 12 µs high にしてから low に。八つのボタンはこれでレジスタの中に固まり、ボタン A はもうデータ線に出ている。
- データを読み、クロックを一回打つ(6 µs high、6 µs low)。これを八回繰り返す。八回の読みは A、B、Select、Start、上、下、左、右の順だ。
- low を読んだらそのビットを立てる。結果は 1 バイトで、ビット 0 が A、ビット 7 が右。コンソールのモデルと同じ順だ(
nes_glue::controller::Buttons::as_byte)。
仕事はおよそ 110 µs で、それを 16 ms ごとに繰り返す。一秒におよそ 60 回、コンソール自身の頻度だ。データ線は 3.3 V へ 10k でプルアップしてあるので、パッドが無いか電源が入っていなければ、でたらめなバイトではなく FF、八つ全部押された形を読む。
純正パッドは 3.3 V で動く: 2026-09-27 に測った。 4021 の定格は 3 から 18 V なので、データシートは動くと言っていた。いまはベンチもそう言い、「まず測る」の項目 4(2026-09-07 から開いていた)が閉じた。最初に試したパッドは 3.3 V で応えないレプリカだった。純正パッドには本物の MN4021B が入っている。パッドを疑う前にプルアップの値を確かめること: 最初の試みでは 10k のところに 10 Ω が付いていて、チップはそれに逆らって線を引き下げられず、D0 は押すたびに少し下がるだけだった。
ケーブルの色は信号ではない
レプリカのケーブルと任天堂のケーブルは色の割り当てが違い、二色がぶつかる: 赤は一方では電源、もう一方ではクロックで、黄は一方ではグラウンド、もう一方ではデータだ。色ではなく信号で配線し、チップのピンに対してテスターで確かめる。図面一式(版 K)は純正パッドの色で描いてあり、pad-ble-build.md には両方の表がある。
層 2: バイトがキーのレポートになる
firmware/pad-ble/keymap.h には表が一つと関数が一つある。USB 版もシンボリックリンクで同じファイルを使うので、このベンチの割り当てはちょうど一つだ。
| ビット | ボタン | キー | HID の用途 |
|---|---|---|---|
| 0 | A | x | 0x1B |
| 1 | B | z | 0x1D |
| 2 | Select | 右シフト | 修飾ビット 0x20 |
| 3 | Start | エンター | 0x28 |
| 4 | 上 | 上矢印 | 0x52 |
| 5 | 下 | 下矢印 | 0x51 |
| 6 | 左 | 左矢印 | 0x50 |
| 7 | 右 | 右矢印 | 0x4F |
この配置は測ったものではなく、決めたものだ。ブラウザの NES エミュレータが既定にする配置で、それがそもそもキーボードモードがある理由だ。Select がキーではなく修飾キーなのは、右シフトが修飾キーだからで、ホストは修飾キーをキーとは別に追う。
レポートは 8 バイト。修飾キーが 1 バイト、予約が 1 バイト、そしてビットの順に埋まるキー枠が六つ。下は手で書き出したものではなく、本物の pad_report() を机の上でコンパイルして出したものだ:
| パッドのバイト | 押したもの | レポート |
|---|---|---|
00 | なし | 00 00 00 00 00 00 00 00 |
01 | A | 00 00 1B 00 00 00 00 00 |
81 | A と右 | 00 00 1B 4F 00 00 00 00 |
0C | Select と Start | 20 00 28 00 00 00 00 00 |
F0 | 四方向すべて | 00 00 52 51 50 4F 00 00 |
FF | すべて | 20 00 1B 1D 28 52 51 50、キーが一つ落ちる |
ボタン八つに枠六つなので、レポートはあふれうる。だから関数は落としたものを数え、シリアルのログはキーを黙って失わずに DROPPED と出す。まともなパッドはあふれない。修飾キーが一つで、十字キーは反対の方向を同時に押せないので、キーは多くても五つだ。最後の行はパッドが外れているときに出る形で、パッドを配線していないとシリアルのログに最初に出てくるものだ。
レポートはバイトが変わったときだけ送る。変わらないレポートを送り直すキーボードは USB では害が無いが、読み取りのたびに出力すると大事な一行が埋もれる。BLE 版はもっと強い理由で同じ決まりを共有する。BLE では繰り返したレポートがいつまでも打ち続けることがある。
層 3: レポートディスクリプタが読み方をホストに教える
列挙のとき、ホストは P4 にレポートディスクリプタを求める。keymap.h にある 65 バイトで、ホストはそれを項目ごとに解析する。書いてあるのは: これはキーボード。レポート ID 1。1 ビットの修飾キーが八つ。定数のバイトが一つ。逆向きに LED のビットが五つと、埋め草が三つ。キー枠が六つで、それぞれ 0 から 101 の用途。ホストはレポートの姿をすべてここから組み立てる。ファームウェアの 8 バイトのバッファとディスクリプタの約束は一致していなければならず、しかも一致させる仕組みは何も無い。
だからディスクリプタは机の上で試験する。 形の崩れたディスクリプタはコンパイルに失敗しないし、列挙にも失敗しない。列挙は通り、それからどのキーも違うキーになる。そこで tools/test-pad-keymap.sh は keymap.h をネイティブにコンパイルし、ホストと同じようにディスクリプタを解析して、解析から出るレポートの大きさと pad_report() が実際に書く大きさを比べる。確かめることは 76 個あり、MUTATE=1 はありがちなバグを二つ仕込み、試験はそれで赤くならなければならない。
ディスクリプタがレポート ID を宣言しているので、どのレポートも前にその ID を付けて線を通る: 9 バイト、01 と、その後に上の 8 バイト。ホストはアプリケーションがレポートを見る前にそれを外す。
LED の出力(Num Lock、Caps Lock ほか)は宣言してあり、無視する。宣言の無いキーボードは珍しいキーボードで、目標はホストが何も考えずに受け入れることだからだ。
層 4: USB がレポートを運ぶ
P4 は自分の USB コントローラを二つ持っている(SOC_USB_OTG_SUPPORTED、OTG の周辺回路が二つ)。このベンチの ESP32-C6 にはまったく無かったものだ。一つはハイスピード、一つはフルスピード。ファームウェアは TinyUSB をフルスピードの方で直接動かす。それが当たり前の方でない理由は、次の次の節にある。ホストに伝わる内容は firmware/pad-usb/usb_device.cpp で決め、ホスト自身のログから読み返したものだ:
| ディスクリプタ | 値 | 注 |
|---|---|---|
| ベンダ ID、プロダクト ID | 303a:0002 | Espressif の VID と、その TinyUSB の例の PID |
| 製品名、製造者、シリアル | NES Pad、tinymachines、nes-bench pad-usb | |
| 速度 | フルスピード、12 Mbit/s | 測った: ホストが new full-speed USB device と記録した |
| インターフェースのクラス | HID (3)、サブクラス 0、プロトコル 0 | |
| エンドポイント | 割り込み IN 一つ、16 バイト | レポートの 9 バイトが収まる。LED のレポートはコントロールパイプで来る |
| ポーリング間隔 | 1 ms | フルスピードの 1 フレーム |
差し込んだときの流れ: ホストがデバイスをリセットし、デバイスとコンフィギュレーションのディスクリプタを読み、HID のインターフェースを見つけ、上のレポートディスクリプタを取り寄せ、それからは割り込み IN のエンドポイントをポーリングする。ボタンが変わると、次のポーリングが 9 バイトを受け取る。主なホストのどれにもドライバは要らない。HID キーボードはどのオペレーティングシステムも持っているクラスだ。
ブートキーボードではなく、レポートプロトコルのキーボードだ
2026-09-27 訂正。 keymap.h と pad-ble-build.md はこれをブートプロトコルのキーボードと呼んでいた。レポートの配置はブートの配置だが、USB のインターフェースが宣言するのはブートではなくサブクラス 0、プロトコル 0 で、しかもレポートは ID を持つ。ブートのレポートは決して持たない。だから完全なオペレーティングシステム(Android、iOS、macOS、Windows、Linux、ChromeOS)はディスクリプタを通して読み、影響を受けない。一方、ブートプロトコルしか話さない BIOS の設定画面や、一部の KVM スイッチ、一部のテレビボックスにはこれが見えない。このベンチでやることの中に、それらを要るものは無い。もし要ることがあれば、変更はインターフェースディスクリプタの HID_ITF_PROTOCOL_KEYBOARD と、レポート ID を持たないレポートディスクリプタだ。BLE は ID を要るので、それは共有のディスクリプタではなく USB 版だけのディスクリプタになる。
ソケットが二つ、キーボードはどちらか
基板には USB-C のソケットが二つあり、別々のデバイスだ:
PWR USB TO UARTは CH343 のシリアルブリッジ(1a86:55d3)で、ファームウェアを書き込み、ログを出すのはこちら。ベンチではヘッド上の/dev/p4-uartで、そこにつないだままにする。USB(H2、Waveshare の回路図では "USB1.1 Type-C")は GPIO24/25 にある P4 のフルスピードの対で、これがキーボードだ。ホストはここに差す。HOST/DEVICE のジャンパはこの組み立てには関係ない。
ホストを UART のソケットに差すと、シリアルポートは見えてもキーボードは見えず、どちらの端もその理由を言わない。
このページの最初の版はここで間違っていて、ファームウェアも間違っていた。 USB に差してジャンパを DEVICE にせよと書いていたが、それは決して動かなかったはずで、理由は二つあり、どちらもその晩に見つかった:
- Arduino コアは P4 ではキーボードをハイスピードのコントローラに置く(
tusb_init(1)、それにフルスピードでは許されない 512 バイトのエンドポイント)。このキットではハイスピードの対は、ジャンパが動かすスイッチ(U15、FSUSB42)にしか行かず、そこから基板上のハブへ、DEVICE なら USB-A の積み重ね J8 のポートの一つへ行く。そのポートの 5 V は基板が駆動している(U6、常時オン)ので、そこに差したホストは自分の 5 V と基板の 5 V をぶつけることになる。使わなかった。 - P4 はフルスピードの PHY を二つ持ち、GPIO24/25 の方を既定で USB-Serial-JTAG に渡している。
USBに差すと、ホストには303a:1001 USB JTAG/serial debug unitが見えた。PHY を OTG コントローラにつなぐ呼び出しはESP_OKを返したが何も変わらなかった。OTG コントローラをもう一方の PHY(GPIO26/27、どのソケットにも配線されていない)につないでいたからだ。LP_SYS.usb_ctrlの 1 ビットが二つを入れ替え(usb_wrap_ll_phy_select(&USB_WRAP, 0))、それでホストに NES Pad が見えた。
だから pad-usb は、TinyUSB がアプリケーションに求める六つのコールバックを自分で定義する。これでコアの USB のラッパもリンクに入らない。動いている間、USB-Serial-JTAG はそのソケットから外れる。書き込みは UART のソケット越しで、影響を受けない。
この層で測ったもの、測っていないもの
- 速度: フルスピード、測った。 キーボードは一押しで 9 バイト送る。12 Mbit/s は必要な量の数千倍だ。
- 列挙: 測った。 ホストのログはこれを
303a:0002 tinymachines NES Pad、USB HID v1.11 Keyboardと名指し、ファームウェアは# host: configuredと出す。 - 電話が電源を供給できるか: 測っていない。 USB ホストとして振る舞う電話は、そのソケットを通して基板に電源を与えなければならない。この基板がそのソケットだけで、しかも電話が出せる範囲で動くかは、まだ試していない。ベンチでは基板は UART のソケットからも電源を得ていた。
ホストがレポートをキーのイベントにする
厳密には USB の外だが、NES のゲームが実際にボタンを受け取るのはここなので、鎖の一部だ。オペレーティングシステムはレポートをキーダウンとキーアップのイベントにし、ブラウザのエミュレータはそれを keydown として、同じ一押しを二つの名前で見る:
event.codeは物理的なキーを名指す:KeyX、KeyZ、ShiftRight、Enter、ArrowUp。HID の用途からそのまま来るので、ホストに設定したキーボードの配列では変わらない。event.keyは文字を名指し、こちらは変わる。ドイツ語の配列では用途0x1Dはyを打ち、フランス語ではwを打つ。
だから B はどこでも KeyZ として届き、z として届くのはその位置に Z がある配列のホストだけだ。code で割り当てるエミュレータはどのホストでも動き、key で割り当てるものは B が電話の言語設定しだいになる。米国と日本の配列はどちらも Z と X を同じ位置に置くので、このベンチではこの違いは表に出ない。
2026-09-28 に tools/keydown-page.html で測った。tinymachines.ai/lab/pad-keydown に載せずに置いてあるページで(ページは自己完結で、記録を JSON 行として写す。動かしたホストの名前はここにはまだ無い)、二回走らせ、それぞれ八つのボタンを一つずつ押した:
- どの一押しもその
codeとして、押した順に届いた:KeyX、KeyZ、ShiftRight、Enter、ArrowUp、ArrowDown、ArrowLeft、ArrowRight。どのkeyupもそのkeydownの後に来た。A と B を押していた長さは一回目が 65 ms ほど、二回目が 210 から 250 ms、矢印は 150 から 500 ms で、どちらの長さでも何も失われなかった。 keyはホストのもので、上に書いたとおりだ: 一回目はホストの上の何かが A と B の間じゅう自分のShiftLeftを押さえていた(アダプタは左シフトを送らない。Select は右で、どちらの回もShiftRightとして届いた)ので、A と B はXとZとして来た。二回目は何も押さえておらず、xとzとして来た。repeat: trueの行は一つだけで、ホストのオートリピートだった。ArrowRightが下がってから 500 ms 後、上がる 17 ms 前。アダプタは変化ごとにレポートを一つ送る(pad-usb.ino)ので、繰り返すkeydownは押されたままのキーをホストが繰り返しているもので、押した回数を数えるエミュレータは、本物のキーボードのそれを無視するのと同じに、これを無視しなければならない。
いまは USB、Bluetooth は後で
BLE が最初の計画で、pad-ble がその名前なのもいまだにそのためだ。P4 では無線は基板上の ESP32-C6 で、SDIO 越しにつながっている。BLE 版はベンダによるそことのつなぎの中で落ち、いつも起動から 2881 ms 後、このプロジェクトのものではないコードの中だ。USB はその部品を丸ごと取り除く。無線も、コプロセッサも、ペアリングも無い。いきさつの全部は pad-ble-build.md の「方向は二度変わった」にある。
層 4 より上はすべて共有だ。同じ読み取り、同じ表、同じディスクリプタが両方の版に使われるので、Bluetooth が戻ってくるとき(直し方は、基板の C6 UART ヘッダを通して C6 に新しいファームウェアを入れること)に変わるのは運び方だけで、このページの最初の三つの層はどれも変わらない。
計画、一層ずつ
どのステップも一つの層を証明し、その後の層を何も必要としない。失敗したステップは、故障をそのステップの層に置く。
| ステップ | 証明するもの | 道具 | 合格の条件 | 2026-09-27 |
|---|---|---|---|---|
| 1 | 純正パッドの五本の線が 4021 の正しいピンに届く | テスター、パッドは電源無し | 白 16、茶 8、橙 9、赤 10、黄 3 | パッドの基板の写真からたどった。テスターでは当てていない。使ってみてステップ 3 が確かめる |
| 2 | 3.3 V での層 1: パッドが応える | firmware/pad-diag | A を押すと D0 が low に、離すと high に戻る | 通った、30 秒に 27 回 |
| 3 | 層 1 と 2: バイトがボタンに従う | firmware/pad-usb、tools/pad-watch.py | 各ボタンを一つずつ押すとそれぞれのビット、01 から 80 が出て、何も押さなければ 00 | 通った、同時押し(03、A0)も |
| 4 | 層 4: ホストが列挙する | ホストの USB のログ | 303a:0002、NES Pad、クラス HID、そして速度 | 通った、フルスピード |
| 5 | 層 3: ホストが意図どおりにレポートを読む | Linux で入力デバイスを直接読む | A で KEY_X、Select で KEY_RIGHTSHIFT | 通った、八つのキーすべてと同時押し |
| 6 | ホスト: ブラウザに見える | tinymachines.ai/lab/pad-keydown の tools/keydown-page.html | A で code が KeyX | 通った 2026-09-28、八つのコードすべて、二回。ホストのオートリピートと修飾キーはホストのものとして見えた |
| 7 | 鎖の全部を、それが向けられた機器で | 電話とブラウザのエミュレータ | ゲームが動く | 通った 2026-09-28、Safari の iPhone と、code で割り当てる tinymachines.ai/nes/play で: 基板は列挙され、八つのボタンすべてがゲームを動かした。最初の一回は A、B、Select、Start の四つだけで、十字キーは動かなかった: ホストはフィールドにフォーカスがない限りハードウェアキーボードの矢印を自分のものにするので、エミュレータはステップ 6 のページがずっとそうしてきたように、いまはフィールド一つにフォーカスを置いておき、次の一回で十字キーも動いた |
ステップ 2 で「まず測る」の項目 4 が閉じた。ステップ 3 にホストは要らない。P4 は USB に何かがつながっていてもいなくても、変化をすべてシリアルのログに出すからで、それがステップ 4 より前に来る理由だ。
未決のもの、主張しないもの
- 電話(ステップ 7、通った): Safari の iPhone(持ち主は 16 だと思っているが、それが電話の名前か iOS の版かは言われていない)が基板を列挙し、その上のブラウザのエミュレータが八つのボタンすべてで動いた(2026-09-28、
tinymachines.ai/nes/playで)。最初の一回は十字キーが動かず、持ち主が代わりに見たのは、反対側から見た同じ症状だった: 十字キーを押すとハイライトがページの外枠を回り、電話が矢印を自分のフォーカス移動に使っていた。電話はハードウェアキーボードの矢印キーをフォーカスのある要素に渡し、何もフォーカスされていなければスクロールのために取っておく。文字と Shift と Enter はどちらでもページに届く。ステップ 6 のページはちょうどこのために画面外の入力欄にフォーカスを置いておき、だから八つすべてが見えた。エミュレータはそうしていなかったが、いまはそうしている(そのplayEngine.tsに、同じ入力欄)。そして次の一回で十字キーも動いた。ここでまだ開いているのは、UART のケーブルを抜いて電話だけで基板に電源が足りるか、そしてステップ 6 のホスト。 - ホストの層の遅延。 keydown のページはイベントにホストの時計だけで刻印を押すので、ボタンからイベントまでの時間については何も言わない。
- 遅延。 測ったのではなくコードから見積もった上限: 変化は最長で 16 ms の読み取り一回を待ち、それから最長でホストのポーリング一回を待つ。ブリッジはコンソールの読み取りのすべてに刻印を押すので、ベンチは端から端まで測れる。
- パッド二つ。 キーボードのレポートの枠は六つで、パッド二つは十を求めうる。二人で遊ぶならレポート ID を二つ持つゲームパッドモードが要り、iOS は汎用の HID ゲームパッドを受け付けない。キーボードモードが先に来たのはそのためだ。
- 5 V でのレプリカ。 両方向にレベル変換が要り、しかもこの組み立てが向けられたパッドではない。