こんにちは、パレイド辺境部の橘です。
前回は、ブラウザに並べたチャット欄から BASIC コードを自動で流し込むところまで来ました。短い「星を描いて」は一瞬で動くのに、20 行、30 行と長くなると途中で取りこぼしが起き、RUN すると ?SN ERROR の嵐。原因は、キーを押すタイミングとファミリーベーシックのキーボードスキャンが微妙にずれることでした。
今回はこの取りこぼしを潰します。結論から言うと、垂直同期に合わせて押すだけではダメで、画面 (VRAM) を見ながら打つ必要があった、という話です。
「連射」でもおなじみ、垂直同期に合わせてみる
ファミリーベーシックは、垂直同期 (VBlank) のタイミングでキーボードを 1 回だけスキャンします。60fps なら約 16.7ms に一度。ならば、キーの押下と解放のトグルも VBlank に合わせれば、スキャンのタイミングと一致して取りこぼしは起きないはずです。
最初に書いた素直な実装はこれです。
// auto_typer.ts の当初案
const KEY_DOWN_FRAMES = 3
const KEY_UP_FRAMES = 1
for (const ch of code) {
const stroke = CHAR_MAP[ch]
keyboard.setKeyState(stroke.key, true) // 押下
await waitFrames(KEY_DOWN_FRAMES) // 3 VBlank 保持
keyboard.setKeyState(stroke.key, false) // 離す
await waitFrames(KEY_UP_FRAMES) // 1 VBlank 待機
}
押下 3 フレーム、解放 1 フレーム。合計 4 フレームで 1 文字、秒間 15 文字。理屈では、VBlank に必ず 1 回はスキャンされる押下窓を確保しているので、取りこぼしは起きない——はずでした。

なぜダメだったか
実際に長めのプログラムを流し込むと、相変わらず行番号が欠ける。?SN ERROR が並びます。
追いかけてわかったのは、VBlank でスキャンが走ること自体は確かでも、スキャン直後に BASIC が「受け取った」とは限らないということでした。特にプログラムの行末での改行直後、BASIC は受け取った行を解析し、カーソルを次の行の文頭に移動して、ようやく入力を受け付けます。
この改行の処理は、通常の文字の連続入力に加え、さらに数フレームかかる。しかも行の内容で可変。固定ウェイトでは、長い行の直後に次の文字を送ると、まだ処理中のキーボードバッファ読み出しを踏んで取り逃がされてしまいます。
垂直同期はハードウェア側のリズムであって、BASIC 側が次の文字を受け取る準備ができているかは別の問題です。時計は合っていても、相手が耳を塞いでいたら同じことですよね。
十分に長いウェイトを挟めば安定はしますが、1 秒に数文字しか打てなくなり、30 行のプログラムで 1 分以上かかる。これでは往年のパソコン(というよりマイコン)雑誌読者にはお馴染みの、ダンプリストを手で打つのと大差ありません。
押してダメなら…
発想を変えます。タイミング予測が限界のため、画面の出力結果を確かめる実装に切り替えます。
1 文字押したら、画面に本当にその文字が出たかを確かめてから次を押す。出ていなければもう一度押す。確認手段はすでに手元にあります。ネームテーブル——ファミコンの PPU が持っている 32 列 × 30 行のタイル配列——は、画面に何が表示されているかをそのままバイト列で持っていて、JavaScript 側から nes.getPpu().getVram() で読めます。

たとえば画面に A が出ている位置には、タイル番号 &H0x41 が入っている。0 が出ている位置には &H30。ほぼ ASCII そのままです (&H5B-&H5D だけ記号が違うとか、細部に例外はありますが)。文字ごとの期待タイル番号表をあらかじめ作っておけば、「いま押した文字がネームテーブル上のどこかに現れたか」を照合できます。
実装はこうなりました。
// auto_typer.ts 抜粋
private async pressKeyWithRetry(key, shift, expectedTile) {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
// 押して離す
keyboard.setKeyState(key, true)
await waitFrames(KEY_DOWN_FRAMES)
keyboard.setKeyState(key, false)
// VRAM で確認
const confirmed = await waitForVramConfirm(expectedTile)
if (confirmed) return
// 出てなかった → 再送
}
}
private async waitForVramConfirm(expectedTile) {
for (let i = 0; i < VRAM_CONFIRM_TIMEOUT_FRAMES; i++) { // 最大 30 フレーム ≒ 500ms
await waitFrames(1)
if (nametableDiffContainsTile(expectedTile)) {
snapshotNametable()
return true
}
}
return false
}
1 文字送るごとに、直前のネームテーブルスナップショットと現在を比較し、変化した位置の中に期待タイルが現れたかだけを見ます。現れれば次の文字。現れなければ最大 30 フレーム (約 500ms) 待ち、それでもダメなら再送。再送は 3 回まで。
この仕組みに切り替えた瞬間、取りこぼしが消えました。BASIC が忙しいときは自動で待ち、手が空けばすぐ次を打つ。ウェイトは固定ではなく、相手の様子に合わせて伸び縮みするようになりました。
二つの例外 — 空白と改行
気持ちよく動きはじめた仕組みにも、地味に二つだけ困りごとがあります。
ひとつめは、空白。ファミリーベーシックの空白タイルは 0x20 ですが、「まだ何も描画されていない領域」も VRAM 上は 0x20 で埋められています。両者が同じバイト値なので、スペースを打っても「変化した位置に 0x20 が現れた」を検出できない。区別できないのです。仕方がないのでスペースだけは従来の固定ウェイト方式にフォールバックしました。BASIC は空白の直後でそれほど重い処理をしないので、これで実害は出ていません。
ふたつめは、改行 (RETURN)。改行を押すと、行の解析、画面スクロール、プロンプト再描画、カーソル移動、と複数の位置が同時に変わります。「特定のタイルが現れたら成功」というモデルが合わない。ここは別のモデルに切り替えて、「ネームテーブルが 何フレーム連続で変化しなかったか 」を見ます。
// auto_typer.ts の RETURN 処理
const RETURN_MIN_WAIT_FRAMES = 10 // 最低限待つ
const RETURN_STABLE_FRAMES = 6 // これだけ連続で無変化なら安定
private async waitForReturnProcessed() {
await waitFrames(RETURN_MIN_WAIT_FRAMES)
let stableCount = 0
for (let i = 0; i < VRAM_CONFIRM_TIMEOUT_FRAMES; i++) {
await waitFrames(1)
if (nametableHasChanged()) {
stableCount = 0
snapshotNametable()
} else {
if (++stableCount >= RETURN_STABLE_FRAMES) return
}
}
}
改行後、最低 10 フレーム待ち、そこから「6 フレーム連続で画面に変化がなかった」時点で BASIC の処理が落ち着いたとみなして、次の行を打ちはじめる。短い行なら 1/4 秒もかからず、長い行なら勝手に待つ。このへん、相手の呼吸に合わせて譜面を読むみたいな感覚です。
結果
30 行のプログラムも、100 行近いものも、取りこぼしなく流し込めるようになりました。速度は 1 文字あたりおおむね 50〜80ms。BASIC が暇な区間は秒間 20 文字近く出ますし、行末や重い処理の前後では自動で調整します。ここではじめて、AIによるコーディング支援らしい流れが、途切れずに回りはじめました。
連射パッドなら、最初の垂直同期方式でも実用上の問題は少ないでしょう。1文字足りないだけで動かないプログラミングの世界では、改めて VRAM を見るループを組む必要があります。一文字ごとに結果を確認し、失敗したら再送し、相手が忙しければ待つ。制御工学の教科書に出てくるような話ですが、1984 年のマシンに 2026 年のコードから打ち込む現場でも、やはり同じ原則に落ち着きました。
副産物 — AI にも画面が見える
VRAM をテキストに戻す仕組みが一度できてしまうと、もうひとつの用途が自然に開きます。LLM にも同じ画面を見せられる、ということです。
NS-HUBASIC V2.0A
3583 BYTES FREE
>10 PRINT "HELLO"
>RUN
HELLO
Ok
>_
緑色のテレビに浮かんでいるあの画面が、そのまま LLM の読めるテキストになる。ユーザー指示と一緒に現在の画面状態を流せば、AI は「いま 10 行に PRINT "HELLO" が入っていて、RUN 済みで、プロンプトに戻っている」という前提で応答を組み立てられます。エラーが出ていれば、エラーメッセージもそのまま見える。
ここまで来ると、AI との関係が少し変わります。
User: エラーが出た
(画面テキストに ?SN ERROR が含まれている)
Claude: 20 行の "FOR I=1 TO 3.5" を "FOR I=1 TO 3" に直します。
Family BASIC は整数のみで、3.5 のような小数リテラルは
構文エラーになります。
AI が書いたコードが、画面上でどう振る舞ったかを AI 自身が見る。これはコードを下流までつなげる、という意味で、連載のタイトルに付けた「バイブコーディング」にようやく近づいた感覚があります。
次回
VRAM を読めるようになったので、次はAIが画面を見て次の処理を判断する話に進みます。



