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

Salvatore Sanfilippo

使用 Claude Code 實作無塵室 Z80 / ZX Spectrum 模擬器

Anthropic 最近發布了一篇部落格文章,描述了一項實驗:在「clean room(無塵室)」環境下,指示最新版本的 Opus,也就是 4.6,以 Rust 撰寫一個 C 編譯器。

這項實驗的方法論讓我對他們想傳達的重點感到存疑。為什麼不提供 ISA(指令集架構) 文件給代理人?為什麼要用 Rust?撰寫 C 編譯器本質上就是一項龐大的圖結構操作工作:這類程式用 Rust 來寫反而更困難。此外,在 clean room 實驗中,代理人應該能夠取得所有關於最佳化編譯器領域中已確立的電腦科學進展的資訊:有許多論文可以輕易彙整成數個 Markdown 檔案。像是 SSA(靜態單賦值形式)、register allocation(暫存器配置)、instruction selection(指令選擇) 與 scheduling(排程)。這些東西應該要 *首先* 作為先決條件進行研究,而實作本身仍可算是「clean room」。

不讓代理人存取網際網路,也不讓它接觸任何其他編譯器的原始碼,這絕對是正確的決定。較難以理解的是幾乎零 steering(引導) 的原則,不過如果目標是展示完全自主地撰寫大型專案,這種做法倒也符合某種實驗的邏輯。然而,我們都知道,這並非大多數情況下 coding agent(程式碼代理人) 在實務上的使用方式。大量使用 coding agent 的人都非常清楚,即使完全不碰程式碼,只要在過程中給予幾次適當的提示,就能徹底改變成果的品質。

Z80 實驗

我覺得是時候自己也來嘗試一個類似的實驗,一個最多只需要一到兩個小時、且符合我的 Claude Code Max 方案的實驗:我決定撰寫一個 Z80 模擬器,接著再寫一個 ZX Spectrum 模擬器(甚至還包含一個 CP/M 模擬器,稍後會提到),並在一個我認為更符合「clean room」精神的條件下進行。成果可以在這裡找到:https://github.com/antirez/ZOT

我採用的流程

  1. 我撰寫了一個 Markdown 檔案,說明我想做什麼的規格。內容只是英文,以高層次的想法描述要實作的 Z80 模擬器的範疇。我提到像是:它應該一次執行一整個指令,而不是單一時脈週期,因為這個模擬器必須能在 RP2350 這類資源受限的硬體上執行。模擬器應該正確追蹤經過的時脈週期(我特別說明,之後可以利用這項功能來實作 ZX Spectrum 在記憶體存取期間與 ULA(非邏輯陣列) 之間的 contention(競爭存取))、提供記憶體存取的 callback(回呼),並且應該模擬 Z80 所有已知、包含官方與非官方的指令。

    對於接續進行的 Spectrum 實作,我在 Markdown 檔案中提供了更詳盡的資訊,例如,我希望在 RGB 緩衝區中的渲染方式,以及它必須是選用的,讓嵌入式裝置能在將掃描線傳送到 ST77xx 顯示器(或類似裝置)時直接渲染掃描線;如何透過 I/O 連接埠互動來設定 EAR 位元,以非常擬真的方式模擬卡帶載入,以及我對這個模擬器的許多其他期望。

    這個檔案也包含了代理人需要遵守的規則,例如:

    • 禁止存取網際網路,但你可以使用我放在 ./z80-specs 裡面的規格與測試向量檔案。
    • 程式碼應該簡單乾淨,絕不要過度複雜化。
    • 每完成一個紮實的進度,就要在 Git 儲存庫中提交。
    • 提交前,你應該測試所產出的成果是高品質且可正常運作的。
    • 隨著加入更多功能,撰寫詳盡的測試套件。每次重大變更後都必須重新執行測試。
    • 程式碼應該要有非常完善的註解:必須用即便不熟悉特定 Z80 或 Spectrum 內部細節的人也能理解的語彙來解釋。
    • 絕不要停下來等待提問,使用者已離開鍵盤。
    • 在此檔案的末尾,建立一份進行中的工作日誌,記錄你已完成什麼、還缺少什麼。務必持續更新此日誌。
    • 每次 context compaction(內容壓縮) 後,都要重新閱讀此檔案。
  2. 接著,我啟動了一個 Claude Code 工作階段,並請它從網際網路上擷取所有關於 Z80 的有用文件(之後對 Spectrum 也做了同樣的事),並僅將有用的事實性資訊擷取到 Markdown 檔案中。我也提供了 Z80 最嚴苛測試向量的二進位檔案、ZX Spectrum ROM,以及其他幾個可用來測試模擬器是否確實正確執行程式碼的二進位檔。蒐集完所有這些資訊後(它們都包含在儲存庫中,因此你可以檢視產出的內容),我完全移除了該 Claude Code 工作階段,以確保不可能混入搜尋過程中看到的原始碼。

  3. 我啟動了一個新的工作階段,請它檢視規格 Markdown 檔案,並查閱所有可用的文件,開始實作 Z80 模擬器。規則是絕對不得以任何理由存取網際網路(我在代理人實作程式碼時全程監督,確保這點沒有發生),也絕不得在磁碟上搜尋類似的原始碼,因為這是一個「clean room」實作。

  4. 在 Z80 的實作中,我完全沒有進行 steering。對於 Spectrum 的實作,我在實作 TAP 載入時則進行了大量的 steering。關於我給代理人的回饋,稍後會在本文中詳述。

  5. 作為最後一個步驟,我將儲存庫複製到 /tmp,完全移除「.git」儲存庫檔案,啟動一個全新的 Claude Code(以及 Codex)工作階段,並聲稱這個實作很可能是竊取或強烈參考他人作品而來。任務是對照所有主要的 Z80 實作,檢查是否有侵權的證據。這些代理人(包括 Codex 與 Claude Code)在經過廣泛搜尋後,都未能找到任何版權問題的證據。唯一相似的部分,都是關於行之有年的模擬模式,以及 Z80 特有、無法以其他方式實作的內容,該實作在顯著程度上看起來與其他所有實作都不相同。

成果

Claude Code 總共工作了 20 到 30 分鐘,就產生了一個能夠通過 ZEXDOC 與 ZEXALL 的 Z80 模擬器,程式碼僅有 1200 行非常易讀且註解完善的 C 程式碼(若包含註解與空白行則為 1800 行)。在實作過程中,代理人完全沒有被提示過,它完全獨立運作。它從未存取網際網路,而它用來實作模擬器的過程,是持續測試、與實作 ZEXDOC 與 ZEXALL 的 CP/M 二進位檔互動,僅撰寫在螢幕上產生輸出所需的 CP/M 系統呼叫。它也多次使用 Spectrum ROM 與其他可用的二進位檔,或從零開始建立二進位檔,來確認模擬器是否正常運作。簡而言之:實作是以非常類似人類程式設計師的方式進行,而非從權重中「解壓縮」出完整的實作。相反地,不同類別的指令是逐步增量實作的,期間出現的錯誤則透過整合測試、除錯工作階段、傾印、printf 呼叫等方式來修正。

下一步:ZX Spectrum

我再次重複了這個流程。我非常精確地指示文件蒐集工作階段,說明我希望它在網際網路上搜尋的細節種類,特別是 ULA 與 RAM 存取之間的互動、鍵盤對應、I/O 連接埠、卡帶的運作方式與所使用的 PWM 編碼種類,以及它如何被編碼為 TAP 或 TZX 檔案。

如我先前所說,這次的設計筆記非常詳盡,因為我希望這個模擬器是專為嵌入式系統設計的,因此僅模擬 48K、選用性的畫面緩衝區渲染、極少的額外記憶體使用(不使用大型的 ULA/Z80 存取競爭對照表)、ROM 不複製到 RAM 以避免額外使用 16K 記憶體,而僅在初始化期間參照(因此在執行檔中只有一份拷貝),等等。

代理人成功建立了一份關於 ZX Spectrum 內部運作的非常詳盡的文件。我提供了幾個遊戲的 .z80 映像檔,讓它能在真實軟體的實際環境中測試模擬器。同樣地,我移除了該工作階段並重新開始。代理人大約在 10 分鐘後完成工作,其過程真的令我著迷,而你可能也非常熟悉:事實上,你會看到代理人運用多種不同的技能。它在所有與程式設計相關的事務上都是專家,因此在實作模擬器的同時,它能立即撰寫詳盡的檢測程式碼,以逐步「觀察」Z80 正在做什麼,以及這如何改變 Spectrum 模擬的狀態。就這方面而言,我認為自動化程式設計已經是超人類的,不是說它目前能夠產生人類無法產生的程式碼,而是在於它能同時運用不同的程式語言、系統程式設計技術、DSP 相關知識、作業系統技巧、數學,以及達成目標所需的一切,並以最直接的方式完成。

完成後,我請它撰寫一個以 SDL 為基礎的簡單整合範例。模擬器立刻就能毫無問題地執行 Jetpac 遊戲,聲音正常,即使在我緩慢的 Dell Linux 機器上,CPU 使用率也非常低(包含 SDL 渲染在內,僅佔單一核心的 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/ZEXCOM 測試的 COM 檔案,輕易找出所使用的 CP/M 系統呼叫(總共三個),並為延伸的 z80 測試(透過 make fulltest 執行)實作它們。那麼,此時何不實作一個完整的 CP/M 環境呢?同樣的流程再次進行,同樣在幾分鐘內就獲得了良好的成果。這次我在 VT100/ADM3 終端機跳脫序列轉換方面與它有較多互動,一開始回報了 WordStar 無法正常運作的問題,幾分鐘內所有我測試的項目都能正常運作了(不過,仍有一些需要修正的地方,例如模擬 2Mhz 時脈,目前它是以全速執行,使得 CP/M 遊戲無法使用)。

啟示是什麼?

顯而易見的啟示是:務必為你的代理人提供設計提示與關於其將要執行任務的詳盡文件。這類文件可以由代理人本身取得。另外,也要確保代理人擁有一份說明如何執行程式設計任務的規則 Markdown 檔案,以及一份經常更新與重新閱讀的工作進度追蹤紀錄。

但我相信,對於近幾個月大量接觸自動化程式設計的每一個人來說,這些技巧都已經相當明顯。以「人類會需要什麼」的角度來思考,往往是最好的切入點,再加上一些針對 LLM 特有的考量,例如在 context compaction 後的遺忘問題、持續驗證是否走在正確軌道上的能力等等。

回到 Anthropic 的編譯器嘗試:代理人失敗的其中一個步驟,與其在預訓練資料集中記憶內容的想法最為相關:那就是組譯器。有了詳盡的文件,我看不出 Claude Code(更不用說 GPT5.3-codex,以我的經驗,對於複雜的任務,它的能力更強)有任何可能會無法產生可運作的組譯器,因為這是一個相當機械化的過程。我認為,這與 LLM 會記憶整個訓練集並解壓縮其所見內容的想法相矛盾。LLM 可以記憶某些過度呈現的文件與程式碼,但雖然它們在被提示時能夠逐字擷取這類程式碼,它們並沒有在訓練期間所見一切的完整副本,也不會在正常運作中自發性地輸出已見過程式碼的副本。我們大多要求 LLM 創造的工作,需要組合它們所擁有的不同知識,而結果通常是使用了已知技術與模式、但屬於全新程式碼的產物,並不構成對某些既有程式碼的複製。

同樣值得注意的是,人類所遵循的流程,往往比本文詳述的無塵室規則更不嚴謹,也就是說:人類常常會下載與他們試圖完成的目標相關的不同實作的程式碼,仔細閱讀,然後試圖避免逐字複製,但往往仍會受到強烈的啟發。這是一個我認為完全可以接受的過程,但重要的是要記住人類所撰寫的程式碼在現實中是如何產生的。畢竟,資訊科技之所以能如此快速地演進,也正是拜這種大規模交叉授粉效應之賜。

基於上述所有原因,當我使用自動化程式設計來實作程式碼時,我對於像這個 Z80 專案一樣以 MIT 授權釋出它,並沒有任何顧慮。反過來,這個程式碼庫也將成為下一個 LLM 訓練(包括開放權重模型)的高品質輸入資料。

後續步驟

為了讓我的實驗更具說服力,應該嘗試在不向代理人提供任何文件的情況下,實作 Z80 與 ZX Spectrum 模擬器,然後比較實作的結果。我還沒找到時間這麼做,但這可能會相當有啟發性。

原文由 Salvatore Sanfilippo 發布

本文章由 muse-spark-1.2-contributor 進行翻譯