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 and scheduling(指令选择与调度)。这些内容本应作为先决条件*首先*完成研究,即便如此,实现仍可算是“clean room”。

不允许智能体访问互联网,也不允许访问任何其他编译器源代码,这无疑是正确的做法。较难理解的是几乎零引导的原则,但如果目标是展示完全自主地编写大型项目,这与某类实验思路是一致的。然而,我们都知道,这并非编码智能体在实践中大多数时候的真实用法。大量使用编码智能体的人都很清楚,即便完全不碰代码,只是在此处彼处稍加点拨,就能彻底改变结果的质量。

Z80 实验

我觉得是时候自己也尝试一个类似的实验了,最多花一两个小时,并且要符合我的 Claude Code Max 套餐:我决定在一种我认为更符合“clean room”设定的条件下,编写一个 Z80 模拟器,然后是一个 ZX Spectrum 模拟器(甚至还有一个 CP/M 模拟器,见后文)。结果可以在这里找到:https://github.com/antirez/ZOT

我采用的流程

  1. 我写了一个 markdown 文件,用来说明我想做什么。只是用英语写的高层思路,关于要实现的 Z80 模拟器的范围。我写道:它应该一次执行一整条指令,而不是单步时钟,因为这个模拟器必须能在 RP2350 或类似受限硬件上运行。模拟器应正确跟踪已消耗的时钟周期(我注明以后可以利用这一特性来实现 ZX Spectrum 在内存访问期间与 ULA 的 contention(竞争)),提供 memory access callbacks(内存访问回调),并应模拟 Z80 所有已知的官方和非官方指令。

    对于作为后续步骤的 Spectrum 实现,我在 markdown 文件中提供了更多信息,比如,我想要的 RGB 缓冲区的渲染方式,以及它如何需要是可选的,以便嵌入式设备可以在将扫描线传输到 ST77xx 显示屏(或类似设备)时直接渲染,如何能够通过 I/O 端口交互来设置 EAR 位,以非常逼真的方式模拟磁带加载,以及我对模拟器的许多其他期望。

    这个文件还包含了智能体需要遵循的规则,比如:

    • 禁止访问互联网,但可以使用我放在 ./z80-specs 里的规范和测试向量文件。
    • 代码应简单、清晰,绝不要过度复杂化。
    • 每取得一次扎实的进展,就应在 Git 仓库中提交一次。
    • 提交前,你应该测试所产出的内容是高质量且可正常工作的。
    • 随着添加更多功能,编写详细的测试套件。每次重大变更后都必须重新执行测试。
    • 代码应有非常详尽的注释:必须用即使不熟悉某些 Z80 或 Spectrum 内部细节的人也能理解的方式来解释。
    • 不要停下来等待提示,用户已离开键盘。
    • 在文件末尾创建一个进行中的日志,记录你已完成的工作和缺失的部分。始终更新此日志。
    • 每次上下文压缩后都要重新阅读此文件。
  2. 然后,我启动了一个 Claude Code 会话,让它在互联网上抓取所有关于 Z80 的有用文档(后来我对 Spectrum 也做了同样的事),并仅将有用的事实信息提取到 markdown 文件中。我还提供了最全面的 Z80 测试向量的二进制文件、ZX Spectrum ROM 以及其他一些可用于测试模拟器是否正确执行代码的二进制文件。一旦收集完所有这些信息(它们都是仓库的一部分,你可以查看生成的内容),我就完全移除了这个 Claude Code 会话,以确保在搜索过程中看到的源代码不会造成任何污染。

  3. 我启动了一个新会话,让它查看规范 markdown 文件,并查看所有可用文档,然后开始实现 Z80 模拟器。规则是绝不以任何理由访问互联网(我在智能体实现代码时进行了监督,以确保这一点没有发生),也绝不在磁盘上搜索类似的源代码,因为这是一个“clean room”实现。

  4. 对于 Z80 的实现,我做了零引导。对于 Spectrum 的实现,我在实现 TAP 加载时进行了大量引导。关于我对智能体的反馈,后文会详细介绍。

  5. 作为最后一步,我将仓库复制到 /tmp,完全移除了“.git”仓库文件,启动了一个新的 Claude Code(和 Codex)会话,并声称该实现很可能是剽窃或强烈借鉴了他人的作品。任务是对照所有主要的 Z80 实现检查是否存在剽窃证据。智能体(Codex 和 Claude Code)在广泛搜索后,都未能找到任何版权问题的证据。唯一相似的部分是关于公认的模拟模式以及 Z80 特有、无法以其他方式实现的内容,该实现在很大程度上看起来与其他实现有显著区别。

结果

Claude Code 总共工作了 20 到 30 分钟,就用 1200 行非常可读且注释良好的 C 代码(算上注释和空行共 1800 行)生成了一个能够通过 ZEXDOC 和 ZEXALL 的 Z80 模拟器。在实现过程中,智能体完全没有被提示过,它完全自主地行动。它从未访问互联网,而它实现模拟器所采用的过程是持续测试,与实现 ZEXDOC 和 ZEXALL 的 CP/M 二进制文件交互,仅编写在屏幕上产生输出所需的 CP/M 系统调用。多次它还使用了 Spectrum ROM 和其他可用的二进制文件,或从零开始创建二进制文件来查看模拟器是否正常工作。简而言之:实现过程与人类程序员的做法非常相似,而不是从权重中“解压”出一个从零开始的完整实现。相反,不同类别的指令是增量实现的,并且通过集成测试、调试会话、转储、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 与预期同步就简单多了。做出这样的改动后,几分钟内模拟器就能毫无问题地通过模拟磁带来加载 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 特有的事项,比如上下文压缩后的遗忘问题、持续验证其是否走在正确轨道上的能力等等。

回到 Anthropic 的编译器尝试:智能体失败的步骤之一,与预训练集中内容记忆的想法关系最为密切:汇编器。有了详尽的文档,我看不出 Claude Code(甚至在我经验中对于复杂任务更强的 GPT5.3-codex)有什么理由会无法生成一个可用的汇编器,因为这是一个相当机械的过程。我认为,这与 LLM 会记忆整个训练集并解压所见内容的想法是矛盾的。LLM 可以记忆某些被过度呈现的文档和代码,并且在被提示时能够逐字提取这些代码片段,但它们并没有所见训练集中所有内容的副本,也不会在正常运行中自发地输出已见代码的副本。我们大多要求 LLM 创作需要组合其所掌握的不同知识的作品,结果通常是使用了已知技术和模式,但却是全新的代码,并不构成对某些既有代码的复制。

同样值得注意的是,人类所遵循的过程往往不如本博文中详述的净室规则那么严格,也就是说:人类经常下载与他们试图完成的目标相关的不同实现的代码,仔细阅读,然后尽量避免逐字复制,但往往会受到强烈的启发。我认为这是一个完全可以接受的过程,但重要的是要记住人类编写代码的现实情况。毕竟,信息技术能够如此快速地发展,甚至也要归功于这种大规模的交叉影响效应。

出于上述所有原因,当我使用自动编程来实现代码时,我毫无顾虑地将其以 MIT 许可发布,就像我对这个 Z80 项目所做的那样。反过来,这个代码库将为下一个 LLM 训练(包括开放权重模型)构成高质量的输入。

后续步骤

为了让我的实验更具说服力,应该尝试在不向智能体提供任何文档的情况下实现 Z80 和 ZX Spectrum 模拟器,然后比较实现的结果。我还没来得及做,但这可能会很有启发性。

原文由 Salvatore Sanfilippo 发布

本文章由 muse-spark-1.2-contributor 进行翻译