本文へスキップ

AIにPC-98のコードを書かせ、動いたことをAI自身に確かめさせる

生成AIは16ビットのx86アセンブラを書ける。

MS-DOSのシステムコールをどう呼ぶかも、グラフィックVRAMのプレーン構成がどうなっているかも知っている。

だから「PC-98で動くプログラムをAIに書かせる」という話は、コードを生成する部分だけ見ればもう解けている。

解けていないのは、生成したコードが動いたかどうかを確かめる部分だ。

出力先は640x400のドットであり、実行環境はエミュレータの中にある。

エージェントはそこを見られない。

外へ出す道は、当時から存在はしていた。

一つはRS-232Cである。

MS-DOSにはCTTYというコンソールの切り替えコマンドがあり、CTTY AUXを実行するとDOSがコマンド入力と出力に使う端末がシリアル側へ移る。

向こう側にターミナルをつなげば、文字のやり取りだけで操作できる。

もう一つはLANである。

LANボードとDOS用のTCP/IPスタックを足し、WATTCPのTELNETDのような待ち受け側を動かせば、ネットワーク越しにDOSのコンソールへ入れる。

ただし当時のDOSネットワークの主な使い方はTelnetのクライアント側だったので、待ち受けさせるのは特殊な構成になる。

PC-98版のどのMS-DOSでCTTYが使えるのか、デバイス名が何になるのかも、機種と版によって変わる。

そして、この二つで外へ出てくるのは文字だけだ。

しかも、DOSの入出力機能を通った文字に限られる。

CTTYは端末デバイスを差し替えるだけなので、画面をBIOS経由で扱うプログラムや、テキストVRAMへ直接書き込むプログラムには効かない。

PC-98向けのプログラムは速度のためにVRAMを直接叩くものが多く、ちょうど確かめたいものがこの経路から漏れる。

グラフィック画面はもっと厳しい。

ドットの集まりを文字の列へ落とす経路はどこにもないので、シリアルにもLANにも何も出てこない。

エミュレータの画面で代用できる範囲も限られる。

GDCのクロックやEGCの転送、垂直同期の取り方は実機と完全に一致するわけではないし、表示の形そのものも違う。

WebNP2が既定で縦1.2倍の4:3補正をかけているのは、実機のCRTが正方形のドットではないからだ。

エミュレータ上でドット単位に正しく見えても、ブラウン管でどう見えるかは別の話になる。

グラフィックの最終確認は、実機の画面を見る工程として残る。

つまり、エージェントに渡せる閉じたループは文字の範囲までで、その先には人の目が要る。

この記事では、PC-98という40年近く前の環境を題材に、確かめる側の足場をどこまで用意できて、どこで人に渡すことになるのかを扱う。

ここでは、コードを書き、画面を読み、次の操作を決める主体をエージェントと呼ぶ。

題材としてのPC-98が難しい理由

レトロな環境の開発には、現代の開発にない障害が三つ重なる。

一つ目は、ツールチェーンが手に入らないことだ。

当時の標準だったMASMやTurbo Cは、いま正規に入手するのが難しい。

二つ目は、実行環境が手に入らないことだ。

エミュレータは揃っているが、起動するにはROMイメージとMS-DOSのディスクイメージが要る。

どちらも再配布できない。

三つ目は、冒頭に書いた、結果が読めないことだ。

プログラムの出力はエミュレータの画面に出るだけで、標準出力にもログファイルにも流れてこない。

一つ目と二つ目は、ブラウザだけで完結する開発環境を使えば回避できる。

三つ目が、エージェントに任せるときに本当に効いてくる障害である。

ブラウザの中にツールチェーンを置く

WorkbenchNP2は、ブラウザ完結のPC-98開発環境だ。

同じ作者のPC-98エミュレータWebNP2を実行基盤に据えて、その上にエディタとビルド系を載せている。

載っているものは次の四つになる。

  • NASM 2.16.03:WebAssemblyへ移植したアセンブラ。BSD 2-Clauseライセンス
  • SmallerC:同じくWebAssembly化したCコンパイラ。16ビットDOSのsmall modelでMZ形式の実行ファイルを吐く
  • FreeDOS(98):PC-9801/9821向けに移植されたMS-DOS互換OSの起動フロッピーディスクイメージ。GPLv2以降
  • NP2kai:Neko Project II系エミュレータをWebAssemblyへビルドしたもの。MITライセンス

ROMイメージも市販ソフトも含まれていない。

NP2kaiが持つBIOS互換ルーチンでROMなしに起動するため、利用者が用意するものは何もない。

入手できないものを前提にしない構成になっている。

手元で動かすのも難しくない。

リポジトリ直下にpackage.jsonがなく、Node.jsのツール群は依存パッケージのない単体の.mjsで、サイト部分は静的ファイルだけである。

クローンして静的配信するだけで動く。

bash
git clone https://github.com/uraraworks/WorkbenchNP2.git
cd WorkbenchNP2
python3 -m http.server 5273 --bind 127.0.0.1

http://127.0.0.1:5273/ide/を開くと、左にファイル一覧、中央にエディタ、右にPC-98の画面が並ぶ。

同梱のサンプルからp98lib/walk2.cを選んで実行ボタンを押すと、Cのソースから32,080バイトのMZ形式実行ファイルが生成され、FreeDOS(98)の上で起動する。

WorkbenchNP2でp98libのサンプルを実行した画面
WorkbenchNP2でp98libのサンプルを実行した画面

右側がPC-98の画面である。

草とレンガのタイルを敷いた背景の上に、32x32ドットのキャラクタースプライトが乗っている。

640x400のまま切り出すとこうなる。

PC-98の画面そのもの
PC-98の画面そのもの

ビルド経路はソースが#include "p98.h"を含むかどうかで自動的に分かれる。

含まない通常のCはsmall modelの単一ファイル経路、含む場合はhuge modelへ切り替わり、PC-98向けのCゲームライブラリp98libのソースを一緒にリンクする。

判定は文字列を見ているだけのヒューリスティックで、コメントの中に書いてあっても反応する。

外れたときに倒れる方向がhuge model側なので、実害が出ない作りになっている。

動いたことを、何で確かめるか

ここからが本題だ。

エージェントにコードを書かせると、ビルドが通るところまでは速い。

そこから先、生成したプログラムが意図した動作をしたかどうかを、エージェント自身が判定できなければ開発のループは閉じない。

PC-98の画面には性質の違う二つの層がある。

一つはテキストプレーンで、80桁25行の文字コードがそのまま置かれている。

もう一つはグラフィックVRAMで、こちらは色ビットごとに分かれたドットの集まりになっている。

テキストプレーンは、エージェントにとって読める。

文字コードが並んでいるだけなので、そこを直接読み出せば画面の内容が文字列として得られる。

WebNP2には、この読み出しをAIから呼べるようにするMCP(Model Context Protocol)サーバーが用意されている。

Node.js 18以上があれば、単一ファイルを取ってきて登録するだけで使える。

bash
curl -fLO https://github.com/uraraworks/WebNP2/releases/latest/download/webnp2-mcp.mjs
claude mcp add webnp2 -- node "$PWD/webnp2-mcp.mjs"

あとはブラウザで?bridge=1を付けてページを開く。

エミュレータ本体はブラウザの中で動き、MCPサーバーは手元のマシンで動いて、ページ側からws://127.0.0.1へ接続しに来る。

ディスクイメージも画面の内容も外部へ出ない。

提供されるツールは30種類を超えるが、観察のループを閉じるうえで要になるのは次のものだ。

  • screen_text:テキスト画面80x25とカーソル位置を取得する。漢字も読める
  • screenshot:画面のPNG画像を取得する
  • wait_screen:指定した文字列が画面に現れるまで待つ。固定時間のsleepの代わりに使う
  • wait_screen_change:画面が変化して落ち着くまで待つ。待つべき文字列が分からないときに使う
  • save_state / load_state:実行状態を名前付きスロットへ保存し、巻き戻す

この並びには、エージェントに作業させるときの勘所が入っている。

wait_screenとwait_screen_changeが分かれているのは、待ち方を固定時間に頼らせないためだ。

エージェントに固定のsleepを使わせると、短すぎれば画面が出る前に判定し、長すぎれば1回の試行が遅くなる。

save_stateとload_stateがあるのは、戻せない操作の前に状態を退避させるためだ。

ディスクを書き換える操作を試すとき、失敗した状態から手で作り直す必要がなくなる。

グラフィック画面のほうは、エージェントが文字列として読む手段がない。

ここはscreenshotで画像を取り、画像として判定することになる。

ただし「スプライトが正しい位置に出ているか」をドットの比較で厳密に見るのは、エージェントの判断としては当てにならない。

現実的な使い方は、実行前と実行後のグラフィックVRAMを比べて何かが描かれたことだけを機械的に確かめ、描かれた内容の妥当性は人が画像を見て決めることになる。

そして冒頭のとおり、画像で見て納得したあとにも実機での確認が残る。

エミュレータが正しく描いていることと、実機のブラウン管でそう見えることは、別々に確かめる対象である。

実際、WorkbenchNP2の検証スクリプトはこの切り分けをしている。

グラフィックVRAMの両ページそれぞれ4プレーン分をSHA-256で照合し、テキストプレーンだけが不変でもグラフィックが変化したケースを成功扱いにしない、という作りだ。

変化していないことを主張する検証は、変化し得る最も早い時点から連続して観測点を置く、という方針が明記されている。

これはエージェントに検証を書かせるときにも、そのまま使える原則だと思う。

行単位で止める

画面を読むだけでは足りない場面もある。

意図した出力が出なかったとき、どこまで実行されたのかを知りたくなる。

WorkbenchNP2には、ソース行単位のデバッガが入っている。

デバッグボタンを押すと、対象の1命令目の手前で停止した状態から始まる。

エディタ左端の溝をクリックしてブレークポイントを張り、続行すると、張った行で止まる。

ソース11行目で停止したデバッガ
ソース11行目で停止したデバッガ

同梱のhello.asmは、INT 21hのAH=09hで文字列を表示して終了するだけの14行だ。

これをビルドすると28バイトの.COMになる。

11行目のmov ax,4C00hにブレークポイントを張って続行すると、スクリーンショットのとおりその行で止まる。

EIPは0107、EAXは00000924になっている。

AHには直前に入れた09hが残り、ALには文字列表示が終端として読んだ$の文字コード24hが入っている。

右のPC-98画面にはHello, PC-98!が出ており、停止したのが表示の直後であることが画面側からも裏付けられる。

この停止は、CPUエミュレータ側に命令トレースを足して実現しているわけではない。

使っているのはDOSのEXEC、INT 21hのAX=4B01hである。

通常の4B00hは子プロセスの終了まで呼び出し元へ戻らないため、実行開始時のCS:IPを取る隙がない。

4B01hはロードだけ行って実行前に呼び出し元へ戻る仕様なので、対象のバイト列を一切変更せずに入口を確定できる。

ローダは自分のメモリブロックを縮小してから対象をロードし、DOSが返してきたCS:IPとSS:SPを署名付きの制御ブロックへ書き出す。

ホスト側はその署名をRAMから読み、CPUを止めた状態で先へ進ませる1バイトだけを書き戻す。

この方式はDOSの実装に依存する。

作者は同梱のFreeDOS(98)、NEC日本語MS-DOS 3.3C、1996年当時のハードディスク環境の三つで4B01hが使えることを実測して表にしている。

対応していないDOSではこの方式が使えないことも明記されている。

エージェントにデバッグを任せるなら、この前提が成り立つかどうかを先に確かめる必要がある。

ブレークポイントはハードウェアの枠を使うため、同時に張れる数に上限がある。

枠0から5が利用者用、6が「次の行まで実行」の一時用、7がエントリ停止用に割り当てられている。

Cの1行が複数の非連続区間に分かれる場合は全区間へ張るので、枠の消費は行数と一致しない。

枠を超えるとエラーになる。

黙って無視されないのは、エージェントに使わせるうえで重要な性質だ。

踏み抜きやすい落とし穴

実際に触って、あるいはリポジトリの記録を読んで分かった注意点を挙げる。

どれもエージェントに作業させると素直に踏むものだ。

ソースの文字コードはCP932として扱う。

iconv -f SHIFT_JISを通すと0x5Cが円記号へ変換され、Cやアセンブラのエスケープが壊れる。

当時のソースを持ち込むとき、エージェントが素朴にSHIFT_JISを指定しがちな箇所である。

自動テストからキー入力を合成すると二重入力になる。

KeyboardEventを組み立てて投げると、HELLOがhheellloになる。

キーボードBIOSのバッファへ直接注入する経路を使う。

MCPサーバー側にpaste_textやkey_sequenceが用意されているのは、この層を迂回するためだ。

NASMはプロセスのグローバル状態を持つ。

アセンブルのたびに新しいモジュールインスタンスを作らないと、2回目以降の結果が汚れる。

フロッピーディスクを差し替えるときは、排出と挿入の間を空ける。

挿入だけだとDOSがFATのキャッシュを持ち越し、「ドライブの準備ができていません」になる。

排出、間隔、挿入、間隔という手順が要る。

ハードディスクから起動するとき、起動できないフロッピーディスクを挿したままにしない。

PC-98はフロッピーディスクをハードディスクより先に起動対象として試すため、ハードディスクまで落ちてこずテキスト画面が空のまま止まる。

フロッピーディスクのドライブレターは決め打ちしない。

ハードディスクのパーティション構成で変わる。

B:からF:を順に試して、実際に読めたものを採用する。

最後に、クローンした直後につまずく点を一つ。

Node.jsのコマンドラインからCをビルドする経路は、toolchain/smallerc-src/というSmallerCのupstreamツリーを読む。

これは十数メガバイトあるため.gitignoreされており、クローン直後は存在しない。

ブラウザ側は実行時に要るヘッダ29本をtoolchain/smlrc-wasm/csrc/へ複製したものを取得する設計なので、開発環境本体の動作には影響しない。

コマンドライン経由でもビルドしたければ、upstreamを別途クローンすればよい。

bash
git clone https://github.com/alexfru/SmallerC.git toolchain/smallerc-src

この「公開サイトで404にならないよう、配布対象へ複製を置いた」という判断は、リポジトリのコメントに過去に踏んだ経緯として残っている。

手元では動くが公開すると壊れる、という類の失敗は、エージェントが書いたコードでも同じように起きる。

役割分担の落ち着きどころ

二つのツールは、担当が分かれている。

コードを書き、ビルドし、行単位で止めるのはWorkbenchNP2だ。

動いているプログラムをAIから観察し、キーやマウスを送り、ディスクの中身を読み書きするのはWebNP2とそのMCPサーバーになる。

MCPのブリッジはWebNP2のプレイヤーページ側の機能で、WorkbenchNP2の開発環境はWebNP2のエンジンを埋め込んではいるが?bridge=1を解釈しない。

同じ基盤を共有しているが、同じページで両方は使えない。

この構成でエージェントに任せられる範囲は、次のように切れる。

  • 任せられる:16ビットx86のコード生成、ビルドエラーの解消、テキスト画面に出る出力の判定、レジスタと逆アセンブルを見た原因の絞り込み、ディスクイメージの操作
  • 任せにくい:グラフィック画面に描かれた内容が意図どおりかの判定、4B01hに対応しないDOSでのデバッグ、実機固有のタイミングに依存する挙動
  • 任せられない:実機での最終確認

AIがPC-98のコードを書けるかどうかは、もう論点ではない。

論点は、書いたものが動いたかどうかをAI自身が判定できる経路が、どこまで引けるかだ。

テキスト画面を文字列として読み、画面の変化を待ち、状態を保存して巻き戻せるようになれば、文字で表せる範囲については40年近く前の環境でもループが閉じる。

CTTY AUXでシリアルへ逃がせたのと同じ範囲が、MCPのツールで手元に戻ってきたと見ることもできる。

グラフィックはその外にあり、エミュレータの画面を人が見て、最後は実機で確かめる工程として残る。

この境界を引かないまま任せると、ビルドが通るだけのコードが速く積み上がることになる。

私が今回最初にClaude Opus 5に投げたのは「WebNp2を用いてPC-98のアプリケーションを開発できるか?」だけだ

すると自然とWorkbentchの存在を調べてデバッグ環境をエミュレータ込みで用意しサンプル実行まで行いスプライト表示まで来てしまった

どうやら、こいつは遥か昔のINT5のフックの話などもわかるしFM音源ならせと言えばならせるだろう

グラフィック処理も当たり前にライブラリを呼び始めてるので当たり前に画面処理もできるからメニューソフトぐらいはあっさり作れてしまう

AIがPC-98のコードを書けるかどうかは、もう論点ではない。

書けて当たり前なのだ

多分、在りし日のFM-TOWNSだろうがAmigaだろうがSolarisだろうが同じことが起こるのではないだろうか?

なんだったら実機を相手にハードウェアのI/Oを確認しながら実装もできそうである

この記事をシェア