「あの頃AIがあったら…」第4回: 垂直同期ではダメだった — VRAM を見ながら打つ

「あの頃AIがあったら…」第4回: 垂直同期ではダメだった — VRAM を見ながら打つ — 垂直同期, VRAM, ファミリーベーシック AIテキスト

こんにちは、パレイド辺境部の橘です。

前回は、ブラウザに並べたチャット欄から 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 回はスキャンされる押下窓を確保しているので、取りこぼしは起きない——はずでした。

ChatGPによる解説図。今回の例ではキー押下に3フレーム持たせています。

なぜダメだったか

実際に長めのプログラムを流し込むと、相変わらず行番号が欠ける。?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が画面を見て次の処理を判断する話に進みます。

タイトルとURLをコピーしました