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

Salvatore Sanfilippo

用 Claude Code 實作潔淨室 Z80 / ZX Spectrum 模擬器

原文由 Salvatore Sanfilippo 發布,訂閱此部落格

Anthropic 最近發表了一篇部落格文章,描述了一項實驗:在「潔淨室」環境下,指示最新版的 Opus 4.6 用 Rust 撰寫一個 C 編譯器。

這個實驗的方法論讓我對他們想表達的論點感到懷疑。為什麼不給代理 ISA 文件?為什麼要用 Rust?寫 C 編譯器本質上就是一個巨大的圖形操作練習:這類程式用 Rust 寫反而更困難。再者,在潔淨室實驗中,代理應該要能取得所有關於最佳化編譯器領域中那些已經確立的電腦科學進展的資訊:有不少論文可以輕易整理成幾份 markdown 檔案。像是 SSA、暫存器配置、指令選擇與排程。這些東西理應先被研究,作為先決條件,而實作本身依然算是「潔淨室」。

不讓代理存取網際網路,也不讓它接觸任何其他編譯器的原始碼,這當然是正確的決定。比較難以理解的是幾乎零引導的原則,但如果目標是展示完全自主地撰寫一個大型專案,這在某種實驗脈絡下倒也說得通。然而,我們都知道,這並不是大多數情況下人們實際使用 coding agent 的方式。大量使用過 coding agent 的人都很清楚,就算完全不動到程式碼,只要在關鍵處輕輕推幾下,成果的品質就會完全不同。

Z80 實驗

我覺得是時候自己也來試試類似的實驗,一個最多花一、兩個小時、也能符合我的 Claude Code Max 方案的實驗:我決定在一個我認為更符合「潔淨室」設定的條件下,撰寫一個 Z80 模擬器,然後再寫一個 ZX Spectrum 模擬器(甚至還有 CP/M 模擬器,後面會提到)。成果可以在這裡找到:https://github.com/antirez/ZOT

我採用的流程

  1. 我先寫了一份 markdown 檔案,說明我想做的事。只是用英文寫下高層次的想法,界定要實作的 Z80 模擬器的範圍。像是:它應該一次執行一整個指令,而不是單一時脈步進,因為這個模擬器必須能在 RP2350 這類資源受限的硬體上執行。模擬器應該正確追蹤經過的時脈週期(我還特別說明,之後可以利用這個功能來實作 ZX Spectrum 在記憶體存取時與 ULA 的 contention),提供記憶體存取的 callback,並且要能模擬 Z80 所有已知的官方與非官方指令。

    對於接續進行的 Spectrum 實作,我在 markdown 檔案裡提供了更多資訊,例如:我希望在 RGB 緩衝區中如何渲染、而且這個功能必須是可選的,讓嵌入式裝置可以在把 scanline 傳送到 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 模擬器。規則是無論如何都不得存取網際網路(我在代理撰寫程式碼時全程監督,確保這點沒有發生),也絕不能在磁碟上搜尋類似的原始碼,因為這是一個「潔淨室」實作。

  4. 在 Z80 的實作上,我完全沒有做任何引導。至於 Spectrum 的實作,我在實作 TAP 載入時則做了大量的引導。關於我給代理的回饋,後面會再詳述。

  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 syscall。它也多次使用了 Spectrum ROM 和其他可用的二進位檔,或是從零開始自己建立二進位檔來確認模擬器是否正常運作。簡言之:整個實作過程與人類程式設計師的做法非常相似,而不是從權重中「解壓縮」出一份完整的實作。相反地,不同類別的指令是逐步增量實作的,期間出現的 bug 則透過整合測試、除錯過程、dump、printf 等方式來修復。

下一步:ZX Spectrum

我又重複了一次同樣的流程。我非常精確地指示文件收集的工作階段,要在網路上搜尋哪些細節,特別是 ULA 與 RAM 存取的互動、鍵盤對應、I/O 連接埠、卡帶的運作方式與使用的 PWM 編碼種類,以及它們如何被編碼成 TAP 或 TZX 檔案。

如我所說,這次的設計筆記非常詳盡,因為我希望這個模擬器是專為嵌入式系統設計的,所以只模擬 48k、可選的 framebuffer 渲染、額外記憶體使用量極少(沒有用於 ULA/Z80 存取 contention 的大型查詢表)、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 與預期的時序同步就簡單多了,不需要 callback 或它先前實作的那些錯誤抽象。做了這樣的改動後,幾分鐘內模擬器就能毫無問題地透過模擬卡帶載入 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 syscall(總共三個),並為延伸的 z80 測試(透過 make fulltest 執行)實作出來。那麼,到了這個地步,為什麼不直接實作一個完整的 CP/M 環境呢?又是同樣的流程,幾分鐘內就得到同樣好的結果。這次我在 VT100 / ADM3 終端機跳脫序列的轉換上與它互動得更多,一開始回報了 WordStar 中無法運作的部分,幾分鐘後我測試的所有東西就都運作得相當不錯了(不過,還有些需要修正的地方,例如模擬 2Mhz 時脈,現在它是以全速執行,導致 CP/M 遊戲無法遊玩)。

啟示是什麼?

最明顯的啟示是:永遠要為你的代理提供設計提示和關於它們將要做之事的詳盡文件。這類文件本身就可以由代理自己取得。還有,也要確保代理有一份 markdown 檔案,裡面寫明如何執行程式設計任務的規則,以及一份它正在做之事的追蹤紀錄,並且要經常更新與重讀。

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

回到 Anthropic 的編譯器嘗試:代理失敗的其中一個步驟,是那個與「記憶預訓練資料集內容」這個想法關聯最強的部分:組合器。有了詳盡的文件,我看不出 Claude Code(更不用說在我經驗中處理複雜任務更強的 GPT5.3-codex)有任何可能無法產出一個可運作的組合器,因為那是一個相當機械化的過程。我認為,這與「LLM 記住了整個訓練集並將看過的東西解壓縮出來」的想法是矛盾的。LLM 確實能記住某些過度出現的文件與程式碼,而且在被提示時也能逐字提取這些程式碼片段,但在正常運作中,它們並沒有一份在訓練集中看過的所有東西的拷貝,也不會自發地吐出看過的程式碼副本。我們大多是要求 LLM 去創造需要組合其所擁有多種知識的工作,而結果通常是使用了已知技術與模式,但卻是全新的程式碼,並不構成對某些既有程式碼的複製。

值得注意的是,相較於這篇部落格文章中詳述的潔淨室規則,人類往往遵循的是不那麼嚴謹的流程,也就是:人類經常下載與他們想完成的目標相關的不同實作的程式碼,仔細閱讀,然後試圖避免逐字複製,但往往還是會受到強烈的啟發。我認為這個過程完全可以接受,但重要的是要記住人類實際寫程式時發生的現實。畢竟,資訊科技之所以能如此快速地演進,甚至正是拜這種大規模交叉授粉效應所賜。

基於以上所有原因,當我使用自動化程式設計來實作程式碼時,我毫不介意以 MIT 授權釋出它,就像我對這個 Z80 專案所做的一樣。反過來,這個程式碼庫也將成為下一代 LLM 訓練的高品質輸入,包括開放權重的模型。

接下來的步驟

為了讓我的實驗更具說服力,應該嘗試在不提供任何文件給代理的情況下實作 Z80 與 ZX Spectrum 模擬器,然後比較實作的結果。我還沒找到時間做這件事,但那應該會很有參考價值。

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

留言