Implementing a clear room Z80 / ZX Spectrum emulator with Claude Code

Salvatore Sanfilippo

Claude Codeでクリーンルーム開発したZ80/ZX Spectrumエミュレータの実装

Anthropicは先日、最新版のOpusである4.6にRustでCコンパイラを書かせるという実験についてのブログ投稿を公開しました。いわゆる「クリーンルーム」環境での試みです。

正直、この実験の手法には首をかしげました。いったい何を示したかったのだろうか、という点で疑問が残ったのです。なぜエージェントにISAのドキュメントを渡さないのか。なぜRustなのか。Cコンパイラを書くというのは、まさに巨大なグラフ操作の連続です。Rustではむしろ書きにくい類のプログラムです。また、クリーンルーム実験であるなら、最適化コンパイラに関する確立された計算機科学の成果には、エージェントが自由にアクセスできるべきでしょう。数本の論文を要約して数枚のMarkdownにまとめるだけで済む話です。SSA、レジスタ割り付け、命令選択やスケジューリングといった事柄です。これらは前提としてまずリサーチしておくべきことであり、そうしても実装は依然として「クリーンルーム」と呼べるはずです。

エージェントにインターネットへのアクセスや、他のコンパイラのソースコードへのアクセスを禁じたのは、判断として正しかったと思います。一方で、ほとんどステアリング(誘導)を行わないという原則は理解しにくいところです。もっとも、大規模プロジェクトを完全に自律的に書き上げさせることを示すのが目的であれば、一つの実験としては筋が通っています。しかし、これは普段私たちがコーディングエージェントを実際に使っている方法ではないことを、誰もが知っています。エージェントを日常的に使い込んでいる人なら、コードに一切触れなくても、要所で少し手を入れるだけで結果の質がまったく変わることをよく分かっているはずです。

Z80実験

そこで、自分でも似たような実験をやってみることにしました。せいぜい1〜2時間で終わり、自分のClaude Code Maxプランの範囲でできる内容です。Z80エミュレータ、そしてZX Spectrumエミュレータ(さらにはCP/Mエミュレータまで、詳細は後述します)を、私なりに筋の通った「クリーンルーム」環境で書くことにしたのです。成果はこちらのリポジトリで公開しています:https://github.com/antirez/ZOT

私がとった手順

  1. まず、やりたいことを仕様としてMarkdownファイルにまとめました。英語で、Z80エミュレータのスコープについてのハイレベルなアイデアを書いたものです。たとえば次のように記しました。エミュレータは1クロック単位ではなく1命令単位で実行すること。RP2350のようなリソースの限られたハードウェアでも動かせるようにするためです。経過したクロックサイクルを正確に追跡すること(この機能は後でZX Spectrumのメモリアクセス時のULAとのコンテンションを実装するのに使えると明記しました)。メモリアクセス用のコールバックを提供すること。そして、Z80の既知の公式・非公式すべての命令をエミュレートすること。

    次のステップとして行ったSpectrumの実装については、Markdownにより多くの情報を盛り込みました。RGBバッファでのレンダリングの方式や、組み込み機器ではST77xx(あるいは同等)のディスプレイへ転送しながらスキャンラインを直接描画できるよう、このレンダリングをオプションにしておくこと。I/Oポートを介してEARビットを操作し、カセットローディングを極めて忠実に再現できるようにすること。そういった、エミュレータに求める数々の要望を書き連ねました。

    このファイルには、エージェントが従うべきルールも含めました。たとえば次のようなものです。

    • インターネットへのアクセスは禁止です。ただし、./z80-specsに用意した仕様書やテストベクタのファイルは使用できます。
    • コードはシンプルかつクリーンに保ち、決して過度に複雑にしないでください。
    • まとまった進捗があるたびに、gitリポジトリへコミットしてください。
    • コミットする前に、作成した成果物が高品質で正しく動作することをテストしてください。
    • 機能を追加するたびに詳細なテストスイートを作成し、大きな変更のたびにテストを再実行してください。
    • コードには十分にコメントを付け、Z80やSpectrum内部の詳細に詳しくない人でも理解できるように説明してください。
    • 途中でプロンプト待ちで停止しないでください。ユーザーはキーボードから離れています。
    • このファイルの末尾に作業ログ(WIPログ)を作成し、完了したことと未完のことを記録してください。常にこのログを更新してください。
    • コンテキストのコンパクションが行われるたびに、このファイルを読み直してください。
  2. 次に、Claude Codeのセッションを開始し、Z80に関する有用なドキュメントをインターネット上からすべて収集し(後にSpectrumについても同様に行いました)、有用な事実情報だけをMarkdownファイルに抽出するよう依頼しました。また、Z80用の最も手厚いテストベクタのバイナリファイルや、ZX SpectrumのROM、その他エミュレータが正しくコードを実行できているかテストするためのバイナリもいくつか用意しました。こうして情報が集まったら(これらはリポジトリに含まれているので、何が生成されたか確認できます)、検索中に目にしたソースコードによる汚染が一切起こらないよう、Claude Codeのセッション自体を完全に削除しました。

  3. 新しいセッションを開始し、仕様のMarkdownファイルと利用可能なすべてのドキュメントを確認したうえで、Z80エミュレータの実装を開始するよう指示しました。ルールは、いかなる理由でもインターネットにアクセスしないこと(実装中はエージェントを監督し、これが守られるようにしました)、そしてディスク上から類似のソースコードを探さないことでした。あくまで「クリーンルーム」での実装だからです。

  4. Z80の実装では、ステアリングは一切行いませんでした。Spectrumの実装では、TAPローディングの実装にあたっては積極的にステアリングを行いました。エージェントへのフィードバックについては、後ほど詳しく述べます。

  5. 最後のステップとして、リポジトリを/tmpにコピーし、.gitディレクトリを完全に削除したうえで、新たにClaude Code(およびCodex)のセッションを開始し、この実装はおそらく他者の成果物を盗用、あるいは強く参考にしたものだと主張しました。タスクは、主要なZ80実装すべてと照らし合わせて、盗用の証拠があるか確認することでした。両方のエージェント(CodexとClaude Code)は広範な調査の末、著作権上の問題を示す証拠は見つけられませんでした。類似していた部分は、確立されたエミュレーションのパターンや、Z80固有で別の書きようがない部分に限られ、実装は他のいずれの実装とも明確に異なっていました。

結果

Claude Codeは合計で20〜30分ほど作業し、ZEXDOCとZEXALLをパスするZ80エミュレータを作り上げました。非常に読みやすくコメントも丁寧なC言語のコードで1200行(コメントと空行を含めると1800行)です。実装中、エージェントへの追加のプロンプトは一度も行わず、完全に自律的に動作しました。インターネットにアクセスすることもありませんでした。エミュレータを実装する過程は、継続的なテストの繰り返しでした。ZEXDOCとZEXALLを実装したCP/Mバイナリと対話し、画面に出力を表示するために必要な最小限のCP/Mシステムコールだけを書き、用意されていたSpectrum ROMや他のバイナリ、あるいは検証のために自作したバイナリを何度も使って、エミュレータが正しく動作しているか確認していました。要するに、実装は人間のプログラマが行う方法とよく似た進め方で行われたのです。重みの中から完成品を「解凍」するように一気に出力したわけではありません。命令のクラスごとに段階的に実装し、統合テストやデバッグ、ダンプ、printfによる出力などでバグを修正していくというやり方でした。

次のステップ:ZX Spectrum

同じプロセスをもう一度繰り返しました。ドキュメント収集のセッションには、インターネットで調べてほしい詳細について非常に細かく指示を出しました。特に、RAMアクセス時のULAとの相互作用、キーボードマッピング、I/Oポート、カセットテープの仕組みや使われているPWMエンコーディング、そしてそれがTAPやTZXファイルにどのようにエンコードされているかといった点です。

前述のとおり、今回は設計メモをかなり詳細に書きました。このエミュレータを組み込みシステム向けに特化させたかったからです。具体的には、48Kのみのエミュレーション、フレームバッファのレンダリングはオプション、追加のメモリ使用は最小限(ULAとZ80のアクセスコンテンション用に大きなルックアップテーブルは作らない)、ROMをRAMにコピーせず初期化時に参照するだけにする(実行ファイル内に一つのコピーを持つだけで済むようにする)といった方針です。

エージェントはZX Spectrumの内部構造について非常に詳細なドキュメントを作成できました。私はゲームの.z80イメージをいくつか渡し、実際のソフトウェアを使った本番に近い環境でエミュレータをテストできるようにしました。再びセッションを削除し、まっさらな状態からやり直しました。エージェントは作業を開始し、10分ほどで終えました。そのプロセスは本当に魅力的で、おそらく皆さんもよくご存じのものだと思います。つまり、エージェントが多彩なスキルを駆使して作業する様子を目の当たりにするのです。プログラミングに関することなら何でもエキスパートなので、エミュレータを実装しながら、Z80がステップごとに何をしているのか、そしてそれがSpectrumのエミュレーション状態をどう変化させるのかを「観察」するための詳細な計装コードを即座に書き上げることができます。この点において、自動プログラミングはすでに超人的だと私は考えています。今の時点で人間には書けないコードを書けるという意味ではなく、異なるプログラミング言語、システムプログラミングのテクニック、DSP、OSの小技、数学といった、結果にたどり着くために必要なあらゆるものを同時に使いこなせるという意味です。

完了後、SDLを使ったシンプルな統合サンプルを書くよう頼みました。エミュレータはすぐにJetpacを問題なく実行できました。サウンドも正常に鳴り、私の非力なDellのLinuxマシンでもCPU使用率は非常に低く抑えられていました(SDLのレンダリングを含めて、1コアの8%程度です)。

基本的な部分が動くようになったので、次はカセットローディングをシミュレートしてTAPファイルを直接読み込めるようにしたいと考えました。ここで初めて、エージェントがいくつか見落としをしました。具体的には、Spectrumのローディングルーチンが期待するタイミングについてです。ここからがLLMの性能が落ち始める領域です。SDLエミュレータを実際に動かして、データ受信に合わせてボーダーの色が変わる様子を目で確認する、といったことが簡単にはできないからです。そこでClaude Codeにリファクタリングを頼み、zx_tick()を直接呼び出せるようにし、zx_frame()の中に含めないようにしました。zx_frame()は単なる薄いラッパーにするのです。こうすることで、エージェントが作っていたコールバックや不適切な抽象化を介さず、EARの同期を期待通りに行うことがずっと簡単になりました。この変更の後、数分もしないうちにエミュレータはカセットをエミュレートしてTAPファイルを問題なくロードできるようになりました。

現在の動作は次のようになっています:

do {
    zx_set_ear(zx, tzx_update(&tape, zx->cpu.clocks));
} while (!zx_tick(zx, 0));

その後もClaude Codeに追加でプロンプトを送り、キーバインドをより使いやすくするなどの調整を続けました。

CP/M

特に興味深かったのは、LLMがZ80用のZEXALL/ZEXDOCテストのCOMファイルを解析し、使われているCP/Mシステムコール(全部で3つです)を容易に見つけ出し、拡張Z80テスト(make fulltestで実行されます)向けにそれらを実装できたことです。ならば、この際フル機能のCP/M環境まで実装してみてはどうかと考えました。同じプロセスを再び試したところ、またしても数分で良好な結果が得られました。今回はVT100/ADM-3Aのエスケープシーケンス変換について少し多めに対話し、当初WordStarで動かなかった点を報告すると、数分後にはテストしたものは一通り十分に動くようになりました(とはいえ、まだ修正すべき点はあります。たとえば2MHzクロックのシミュレートなどです。現在はフルスピードで動作するため、CP/Mのゲームが遊べない状態です)。

ここから得られる教訓は何か

明白な教訓はこうです。エージェントには常に、設計上のヒントと、これから行うことに関する詳細なドキュメントを与えることです。そのドキュメント自体もエージェント自身に用意させることができます。また、エージェントがコーディングタスクをどのように遂行すべきかというルールと、自分が何をしているかの軌跡を記したMarkdownファイルを持たせ、頻繁に更新し、読み直させるようにすることです。

ただ、こうしたコツは、ここ数カ月自動プログラミングを集中的に触ってきた人なら誰にとっても、すでにかなり自明なことだと思います。「人間だったら何が必要か」という観点で考えるのが最も確実です。それに加えて、LLM特有の事柄、たとえばコンテキストのコンパクション後の忘却の問題や、常に正しい軌道に乗っているか検証し続ける能力といった点を押さえておけばよいのです。

Anthropicのコンパイラの試みに話を戻すと、エージェントが失敗したステップの一つは、事前学習データセットの中身の記憶という考えと最も強く結びついたものでした。アセンブラです。詳細なドキュメントがあれば、Claude Code(そして私の経験上、複雑なタスクではさらに有能なGPT-5.3-codex)が、動作するアセンブラを作るのに失敗するとは到底思えません。アセンブラの作成はかなり機械的なプロセスだからです。これは、LLMが学習データセット全体を記憶し、見たものを解凍しているだけだという考えとは矛盾すると思います。LLMは、過剰に出現する特定の文書やコードを記憶することはできます。しかし、促せばそうしたコードをそのまま引き出せることはあっても、学習中に見たすべてのもののコピーを持っているわけではありませんし、通常の動作の中で自発的に既存のコードのコピーを吐き出すこともありません。私たちがLLMに求めることのほとんどは、LLMが持つ異なる知識を組み合わせて何かを作ることです。その結果は、既知のテクニックやパターンを用いつつも、新しいコードになるのが常であり、既存のコードのコピーには当たりません。

また、注目すべき点として、人間はこのブログ投稿で詳述したクリーンルームのルールよりも、しばしば緩いプロセスをたどっているということがあります。つまり、人間は自分がやろうとしていることに関連する別の実装のコードをダウンロードし、注意深く読み、それをそのままコピーしないよう努めつつも、多くの場合そこから強いインスピレーションを得るのです。私はこのプロセス自体はまったく問題ないと考えています。ただ、人間が書くコードの現実がどうなっているかを念頭に置くことは重要です。結局のところ、情報技術がこれほど急速に進化できたのも、この大規模な相互受粉の効果によるところが大きいのです。

以上の理由から、自動プログラミングを使ってコードを実装する際、私はそれをMITライセンスで公開することに何の抵抗もありません。今回のZ80プロジェクトのようにです。そしてこのコードベースは、オープンウェイトのものも含め、次世代のLLMの学習にとって質の高い入力となるでしょう。

今後の展望

私の実験をより説得力のあるものにするには、エージェントに一切のドキュメントを与えずにZ80およびZX Spectrumエミュレータを実装させ、その結果を比較してみるべきでしょう。今回は時間が取れませんでしたが、きっと興味深い知見が得られるはずです。

原文は Salvatore Sanfilippo により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。