GB/后记之二

写下这篇后记时, 距离这个项目上一次真正意义上的"维护", 已经过去了差不多 7 年. 期间我几次想把模拟器从功能级升级到时序级, 但每次都因为各种原因半途而废. 直到最近, 我终于在 AI 的帮助下(或者说, AI 在我的帮助下?), 把这件事做成了.

这段经历让我重新认识了 AI 的能力边界, 也逼着我重新思考了一遍编程究竟是什么. 它并不只是"让 AI 帮我写代码"这么简单, 更像是一次漫长的协作实验: 人负责提出问题, 建立反馈, AI 负责在反馈中不断修改实现. 下面就从头说起.

一个拖了七年的目标

先解释一下, 为什么我要执着于"时序级".

功能级模拟器以 CPU 指令为基本单位. 只要指令执行结果与原始硬件一致, 模拟器通常就可以认为是正确的. 时序级模拟器则要把粒度细化到 CPU 的每一个时钟周期. 它不仅要算对结果, 还要在正确的时间改变正确的状态.

功能级虚拟: 以 CPU 指令为基本单位进行模拟. 只要 CPU 执行的指令与原始硬件一致, 就认为模拟器是正确的.

时序级虚拟: 以 CPU 时钟周期为基本单位进行模拟. 只有 CPU 在每一个时钟周期的状态都与原始硬件一致, 才能认为模拟器是正确的. 相比功能级虚拟, 时序级虚拟的粒度更细: 一条 CPU 指令往往需要多个时钟周期才能完成, 因此我们不能再把一条指令当成原子操作.

从功能级走到时序级, 表面上像是把一个大步骤拆成几个小步骤, 实际上等于改变了整个模拟器的思维方式. 过去可以在一条指令执行完之后再更新状态, 现在却必须回答: 第一个周期发生了什么, 第二个周期发生了什么, 中间哪些状态对外可见? 这也是我过去几次尝试都没有成功的原因.

为什么过去失败了

总结下来, 摆在面前的困难主要有以下几点:

  1. 资料匮乏: 这是一台早已退场的机器, 关于 Game Boy 硬件时序的资料少得可怜. 现有资料几乎全是非官方研究, 而且源头往往指向少数几位黑客. 那些成果当然非常宝贵, 只是通常不成体系, 零零散散地分布在各个角落, 找起来像在考古.
  2. 测试困难: 我们确实能找到一些硬件测试 ROM, 但它们几乎全是用汇编写的. 面对一颗诞生于上世纪七十年代的 CPU, 现代人想从这些汇编代码里看懂测试作者的意图, 并不是一件轻松的事. 最尴尬的情况是: 测试失败了, 但我连它究竟在测什么都不知道.
  3. 砍掉一个头, 长出两个头: 这是更严重的问题. 前两个问题叠加在一起, 导致我费尽千辛万苦修好一个问题后, 发现原本能够通过的测试又失败了. 仿佛刚砍掉一个头, 马上又长出两个来, 着实令人沮丧. 这也是我过去几次升级失败的根本原因: 上百个测试 ROM 排着队等我处理, 而虚拟机项目又总是牵一发而动全身.

这类项目最怕的不是某一个 bug 特别难, 而是你修复 bug 的过程没有稳定的方向感. 一个测试通过了, 另一个测试却挂了; 你知道代码变了, 却不知道究竟是哪一个时序假设被破坏了. 到了最后, 调试很容易变成一种仪式: 改代码, 跑测试, 祈祷, 然后面对新的失败结果继续改代码.

搬救兵: 让 AI 参与进来

去年前后, 网络上已经开始流行各种 AI 编程工具. 我自己也一直在用, 但当时的想法很朴素: 这些工具可以帮我写一些简单代码, 复杂项目还是得靠自己.

不过, 2025 年和 2026 年的 AI 发展实在太快了. 我渐渐感觉到, AI 的能力边界正在不断往外推, 于是开始尝试让 AI 主导这个项目. 开头并不顺利, 甚至可以说相当失败. 好在经历了一段痛苦的磨合后, 我终于找到了一种比较有效的合作方式, 也就是前言里说的: AI 在我的帮助下.

整个重构过程大约持续了 20 天, 数据如下:

  • 新增 commit: 115 个
  • 时间间隔: 20 天 19 小时 25 分 28 秒
  • 新增代码行: 5,071 行
  • 删除代码行: 3,923 行

数字看起来很热闹, 但它们并不能说明问题已经解决. 真正重要的是: 这 20 天里, 我终于把过去无法持续推进的工作变成了一轮又一轮可以验证的迭代, 这段时间里我非常的有信心, 我知道只要投入时间这个项目就一定能完成.

决定执行哪个标准

事实上, Game Boy 有好几个不同的硬件版本, 也就意味着有好几个不同的时序标准. 测试 ROM 里有些测试是针对特定硬件版本的, 也有些测试是通用的. 我决定只关注 DMGCGB 这两个标准, 也就是销量最大的那一批初版黑白 Game Boy 和彩色 Game Boy 标准. 我需要手工从庞大的测试 ROM 里挑出针对这两个标准的测试.

先让测试结果变得可读

Game Boy 模拟器的测试用例由一系列 ROM 文件组成. ROM 在模拟器中运行后, 会把结果输出到窗口(以画面方式)里. 问题是, 在最初的工作流中, AI 看不到这个窗口, 自然也就不知道测试到底通过了没有.

一开始, 我把测试结果自动截图后发给 AI. 它确实能识别截图里的内容, 但效率很低, 也容易看错. 后来我认真读了测试 ROM 的文档, 发现这些 ROM 运行时会把测试结果写入一个特定的内存地址, 而这个地址模拟器是可以读取的. 于是, 我让 AI 直接读取这段内存, 把它当作测试结果. 测试终于从"看图猜结果"变成了可以自动验证的文本, 效率一下子提高了不少.

这一步看起来不像什么惊天动地的技术突破, 却是整个流程里最关键的一步. AI 不需要"看懂"窗口, 也不需要依赖一个截图工具; 它只需要运行测试, 读取结果, 然后根据失败信息继续定位问题. 对人类来说, 这同样重要, 因为只有反馈足够明确, 才知道下一次修改究竟有没有把事情往正确的方向推进.

换句话说, 在让 AI 修复代码之前, 首先要让测试能够告诉 AI 发生了什么. 没有反馈的 AI, 就像一个闭着眼睛修钟表的人, 手可以动得很快, 但很难知道自己是不是把时间修得更准了.

我究竟让 AI 做了什么

测试结果能够被读取之后, 我并没有把整个项目丢给 AI, 然后坐等奇迹发生. 实际的工作方式很具体: 每次只选择一个测试用例, 告诉 AI 这个测试当前失败的表现, 要求它阅读相关代码, 提出修改, 运行测试, 再根据结果继续迭代.

我负责选择问题, 判断方向, 并在必要时中断那些已经开始偏离的尝试; AI 负责阅读代码, 追踪调用关系, 修改实现, 以及执行大量重复的验证工作. 它最擅长的是在一个明确的反馈回路里快速尝试, 而不是凭空替我决定整个项目应该怎样设计.

这也是我后来逐渐形成的一个认识: AI 的效率并不只取决于模型本身, 还取决于人能不能把任务切得足够小, 把成功和失败定义得足够清楚. 一个"请把模拟器修好"的任务太大, 也太模糊; 一个"请修复这个测试, 并确保已有测试不回退"的任务, 就已经可以开始工作了.

模型之间的差距

为了完成这个项目, 我试用了不少 AI 模型, 也购买了 Copilot 和国内腾讯的 CodeBuddy. 这两个订阅让我几乎可以接触到当时主流的模型. 实际用下来, 我发现不同模型在完成同一类任务时, 表现差距相当大. 对于简单的代码生成, 这种差距未必明显; 但对于需要持续阅读代码, 理解隐含状态, 反复运行测试的任务, 差距很快就会被无限放大.

那时 Copilot 还是按次收费, 而不是按 Token 收费; CodeBuddy 则是订阅后几乎不限量使用. 于是我养成了一个习惯: 在给 AI 的指令里加上明确的停止条件, 防止它们摸鱼. 大概是这样的:

在成功修复该测试用例前, 你不能停止工作, 也不能去做其他的事情.

我同时开了两个窗口, 让 Copilot 和 CodeBuddy 一起工作. 有一次, 我让 CodeBuddy 里的 DeepSeek 修复一个测试用例. 它花了大约 4 个小时, 测试还是没有修好; 期间上下文经过了几次压缩, 项目代码倒是已经被改得面目全非.

我只好按下暂停键, 转而让 Copilot 处理同一个测试. 结果, Copilot 里的 Claude 不到两分钟就修好了.

这里还是要声明一下: 在 2026 年上半年这个时间点, 至少在这个项目和这类任务上, DeepSeek 的表现不如 Claude. 这只是我的使用体验, 并不意味着 DeepSeek 是一个不好的模型. 同时, 我自己的感受也是 Claude 模型不但领先国内模型, 也遥遥领先其他国外模型. 因此后期整个项目的工作几乎都交给了 Copilot 里的 Claude, 而其它模型只在 Copilot 或者 Claude 被限制时才会被使用.

把工作变成迭代

最初的流程跑通之后, 后面的工作基本就变成了重复迭代: 一次只修复一个测试用例. 提示词大概是这样的:

请你修复 /path/to/file 这个测试用例.

修复完成后, 执行 cargo test 来验证已开放的测试用例是否通过, 如果没有通过, 请你继续修复, 并思考你当前的修复为什么会影响其他测试用例, 将思考的结果写在注释里, 方便以后理解.

在成功修复该测试用例前, 你不能停止工作, 也不能去做其他的事情.

这段提示词现在看起来有点像在给 AI 下军令状, 但在当时确实很有用. 因为按次收费或订阅不限量时, 我最担心的不是 AI 不会写代码, 而是它写了几步之后, 发现问题复杂, 就开始总结, 建议, 询问下一步, 然后礼貌地把工作交还给我.

当然, 提示词本身不是魔法. 真正起作用的是后面的约束: 每次只处理一个测试; 修改之后必须运行测试; 发现回归就继续调查; 没有成功之前不能视作已经完成". 这些约束把一个开放式的编程任务, 变成了一个可以不断收敛的过程.

在这个过程中, AI 也不是每次都能一次修好. 它会误判, 会改错, 会在局部修复上绕远路. 有时它甚至会把问题描述得头头是道, 然后交出一份并不能通过测试的代码. 不过, 只要反馈回路还在, 错误就不再是终点, 而是下一轮迭代的输入.

AI 能做什么, 人要做什么

这次重构让我产生了一个越来越强烈的感觉: AI 的编码能力已经超过了大多数程序员能够独立完成的范围, 但这并不意味着程序员就不重要了. 恰恰相反, 人的工作正在向另一个方向移动.

在"古法编程"时代, 产品经理负责把模糊的想法整理成明确的需求, 程序员再把需求变成代码. 到了 AI 时代, 程序员可能同时承担这两种角色: 既要知道想做什么, 也要知道怎样把目标拆成可执行, 可验证的任务. AI 能走多远, 往往取决于我们能不能提出一个好问题, 以及能不能设计出足够可靠的反馈.

这并不是说人类只需要负责写提示词. 选择抽象方式, 判断修改是否合理, 决定哪些行为属于硬件特性, 哪些只是测试偶然通过, 这些事情仍然需要真正理解项目. AI 可以替你读很多代码, 却不能自动替你承担最终判断. 至少目前不能, 而我也不建议把模拟器的灵魂寄托在一句"应该没问题"上.

写在最后

AI 会怎样影响编程行业? 我觉得答案大概是: 有好有坏. 传统的, 需要多人攒局才能启动的开发模式可能会被打破, 同时也会催生出更多"一人公司". 一个人负责想法, 决策和验收, AI 负责把中间那一大段路走完.

但这并不意味着开发会变得毫不费力. 代码写得更快之后, 真正稀缺的可能变成了判断力: 什么值得做, 什么算完成, 什么反馈可信, 什么时候应该继续坚持, 什么时候应该及时停下来重来一次.

这次项目让我把模拟器从功能级推进到了时序级, 也让我明白了一件有些反直觉的事: AI 并没有替我完成这个项目, 它只是让我终于有能力持续完成过去一个人很难持续完成的工作. 至于这算不算轻松了, 问问那些排队等待修复的测试 ROM, 答案可能会更准确.