我把 Amiga 软盘交给了 AI 智能体,一个晚上之后 Pirates! 就在 Unity 里跑起来了
我把 1990 年 Amiga 版 Sid Meier's Pirates! 的四张软盘镜像交给一个编码智能体,请它把游戏移植到 Unity。干活的是 Claude Code 里的 Claude Opus 5.5。一个晚上之后,游戏已经可以从标题画面一路玩下去:决斗、城镇、在地图上航行,直到海战,用的都是原版的图像和声音。比速度更重要的是结果的检验方式:同一套决策脚本分别在 C# 移植版和运行于处理器模拟器中的原始机器码上执行。在 802 次运行、约 600 万行被比对的输出中,两个版本呈现给玩家的内容完全一致。
我在这里把这项工作的各个阶段依次写下来,因为它们合起来构成了一套方法,换一款游戏、换一个模型也能重复。文章最后我会展示按同一套流程完成的第二个项目:把 Centurion: Defender of Rome 从 DOS 移植到 JavaScript。两者都是用于学习的私人项目:原版游戏及其图像和声音仍受版权保护,我不会分发它们。

Unity 中的标题画面。图像是在运行时从游戏的原始文件中解码出来的。
从软盘镜像到反编译代码
智能体先从辨认文件入手。Amiga 的软盘镜像(ADF)是扇区的原始拷贝,所以几十行 Python 就足以把内容解包出来。游戏程序原来是一个带覆盖模块(overlay)的文件:核心常驻内存,另外三个代码段按需从磁盘读入。免费的反编译器 Ghidra 并不内置对这种格式的支持。智能体写了一个脚本,把所有代码段按固定地址排布到同一个镜像里,并把 45 处指向覆盖模块的间接跳转改成普通调用。这样一来,反编译器就把约 630 个函数看成了一个完整的程序。
游戏数据带来了一个意外。扩展名为 .rez 的文件采用的是 Macintosh 电脑的资源布局,因为 Amiga 版本是从 Mac 移植过来的。识别出格式之后,智能体提取了 210 段文本、82 个字符串列表、六个时代的城镇数据、图像、动画、精灵图(sprite)和声音。事实证明,文本是最好的代码地图:传给“显示文本”例程的字符串编号,直接说明了某个函数负责游戏的哪个画面。
十三个智能体读代码,九个智能体重写代码
反编译出来的代码接近 2.9 万行,函数和变量都没有名字。主智能体把它切成每段约 2000 行的片段,并把每一段连同同一份书面任务说明交给一个单独的智能体。每个智能体都要把自己那一段里所有函数的描述写进文件:精确的公式、常量、随机数范围、所显示文本的编号,以及读取和写入的全局变量。任务说明里有一句话,后来证明很关键:错误的公式比标明的空缺更糟糕。因此智能体要标出自己没能确定的地方,并到汇编里去核对含糊之处。反编译器在这里丢失了许多函数参数,因为 35 年前的编译器使用的是 16 位的 int 类型。
第二阶段沿用了同样的分工。九个智能体把游戏规则重写成 C#,每个负责各自的地址范围。最后得到约 1.6 万行代码,而游戏状态被放进一个字节数组,寻址方式与原版的内存完全相同。这看上去不太优雅,却消除了一整类错误。老代码会随意让结构体相互重叠,还会从数组起始位置的前一个字节开始读取。建立在内存镜像上的移植版可以逐行翻译并与汇编对照,常量表也留在代码去找它们的位置。绘图、声音和操控则被智能体换成了对一个接口的调用,由引擎另行实现。
判定基准:处理器模拟器里的原始代码
阅读反编译代码并不能证明移植版的行为与原版一致。于是智能体搭建了一个比对工具。Motorola 68000 处理器的原始机器码运行在 Unicorn 模拟器中。系统例程,也就是菜单、文本窗口、读取操控输入和绘图,被替换成了 Python 函数。这些函数按照一个简单的决策生成器回答游戏提出的每一个问题,并记录游戏显示的全部内容。C# 移植版则通过一个在 Unity 之外运行的“脚本玩家”得到完全相同的决策序列。最后把两份记录逐行比对。文本里包含金币、船员人数、日期和城镇名称,所以记录一致是对规则的有力检验。
第一次完整测量的结果是 802 次运行中有 800 次一致。两处偏差出自同一个原因:浮点运算。游戏使用的是一个软件实现的库,它的舍入方式与今天的处理器不同。它算出的 81 的平方根是 8.999999999999998,而游戏会把结果截断为整数,于是在某些金币与船员的比例下,移植版里的船员士气会高出一点。修正的办法是把这个库的例程逐条指令重写:1572 条指令、43 个例程,以及由原始代码算出的 21,701 个测试向量,没有任何差异。改完之后,全部 802 次运行都一致;另外还有 120 次运行,在决斗、航行以及海战和陆战中逐帧比对了游戏状态。
比对还暴露了 1990 年原版游戏自身的两个 bug:从斜向攻击城镇时通过空指针读取地图,以及船员降到零时的除零。在这些地方,移植版必须选定一种行为,而比对工具有一个开关,可以让原版走同一条路。结果也有它的边界。这个工具不比对绘图和声音,而一些少见的路径,比如结婚、集齐藏宝图或最高的头衔,测试得很少。
呈现层,以及 1990 年没有的新增功能
玩家看得到的那一层,是智能体在 Unity 里用代码搭建的,没有使用现成的场景。图像和动画从原始资源解码,精灵图取用当前画面调色板里的颜色,音乐则通过模拟游戏的四通道音序器渲染成了音频文件。智能体从命令行操控正在运行的游戏,截取屏幕画面并查看它们。这样暴露出了几个任何规则测试都抓不到的错误,比如浏览器版本没有声音,原因是音频转换需要 ffmpeg 程序,而系统里没有装。

在地图上航行。背景由 160 乘 200 格的地形网格和原版图块生成。
第一轮试玩之后,我提了三项修改。关于宝藏船队抵达日期的提问,在原版中是防拷贝措施,需要纸质说明书;现在问答保留了下来,但“船长笔记”里会给出正确答案。城镇列表原先只显示城镇的数据,因为游戏默认玩家手里有印刷的地图;现在所选城镇会标在加勒比海地图上。决斗画面增加了一张表格,列出双方的兵力、士气和武器,排布方式与原版相同。这些新增功能可以关闭,比对测试是在关闭它们的情况下运行的,这样记录仍然与 1990 年的代码一致。

原版中的决斗。截图来源:OpenRetro Game Database。

移植版中的同一画面。表格里的数字是从决斗状态中实时读取的。

相对原版的新增功能:在加勒比海地图上标出所选城镇。
Centurion:同一套流程,换成 JavaScript,并扩展了玩法
第二个项目是 Centurion: Defender of Rome,一款 1990 年的 DOS 游戏,被移植到了 JavaScript 和 Canvas 元素上,不借助模拟器。步骤是一样的,只是名字不同。可执行文件得先解包,因为它是用 EXEPACK 工具压缩过的。游戏的五个模块(陆战、角斗场、战车竞速、海战和片头)放在一个覆盖模块文件里,共用同一个数据段。战役状态运行在这个数据段 64 KB 的实时副本上,因为游戏引擎依赖 16 位溢出、整数除法和有符号字节,而 JavaScript 的浮点数本身并不会重现这些行为。存档与原版格式逐字节一致,这一点由测试来验证。

浏览器中的 Centurion 标题画面,使用原版图像。
在这里,我比忠实移植走得更远。我加了一个扩展规则的开关,默认关闭:军团合并和同一行省驻扎多个军团、轻装的投矛兵(velites)、战象,以及从海上登陆。这个移植版的代码出自与 Pirates 不同的模型:最多的是 Gemini 3.8 Flash,还有一部分是通过 Codex 运行的 OpenAI 的 gpt-6.1-sol。这样选是因为手边正好有这些工具,而不是出于对模型的比较。

选项菜单。第一项用来开启原版中没有的扩展规则。

带有竞技菜单的行省画面。
Gemini 在 Centurion 上遇到的问题
最费时间的,是那些反编译代码或文件名所暗示的内容与游戏实际行为不符的地方。下面是项目笔记里的五个例子:
- 没有数据的覆盖模块。 游戏的五个模块放在同一个文件
OVL0.OVL里,被加载到同一个地址,但它们全都使用共用的数据段。因此,单独在 Ghidra 里打开覆盖模块的代码,看不到任何字符串或表。脚本把每个覆盖模块与数据段拼接起来,生成五个合成的可执行文件,直到这时反编译器才把小游戏的代码与战役的数据结构关联起来。 - 被征服行省的边界。 按照鼠标可点击区域的地图来勾勒行省,得到的是棱角分明的线条,与手绘地图不相称。代码揭示的是另一种机制:在地图图像里,每个行省都有一条宽度为一个像素的深色隐藏路径,而数据中的一张表为 27 个行省各给出一个起点。游戏从起点开始,把这条路径重新涂成帝国的颜色,移植版也照此办理。
- 倒退的帧。 原版的游戏循环以 9–12 Hz 的频率运行,而浏览器每秒绘制 60 次。有三处,精灵图会瞬间倒退一帧。片头里的地球仪是靠在 10 步之内移动 14 种调色板颜色来旋转的,但表中第一步的值是零;如果每一步都移动调色板,大陆每转一圈就会顿挫一下。在步兵大队的行军和角斗士的突刺中,必须精确还原顺序:先是中间帧,然后才改变位置。
- 有误导性的文件名。 位图
BB1.CBM和BB2.CBM看上去像是战斗横幅,可是画在屏幕顶部之后,它们遮住了整个战场。代码只在电脑没有 VGA 显卡时才加载它们:这是给较老的 EGA 和 CGA 显卡准备的全屏背景。在同一个例程里还发现,战前部署分为两个阶段。选择阵型会替换可用战术的列表,而坎尼战术要在选了楔形阵之后才会出现。 - 几乎听不见的音乐。 解码出 AdLib 声卡驱动的 17 首乐曲和 24 种乐器之后,旋律是对的,但播放的电平只有约 −55 dB。对驱动的反汇编表明,每奏一个音符,它都会从乐器中保存的衰减值里减去该音符音量的四分之一。一行代码找回了约 32 dB。第二个毛病出在浏览器这一边:音符非常短的时候,在 Web Audio API 中取消已排定的音量变化,会把仍在进行的起音抹掉,使音符变成无声。

战前部署的第二阶段:楔形阵可用的战术。
Circus Maximus 里的战车竞速是另一项单独的工作。那里的战车并不是一张图。如果为三种战车等级、四种车手颜色、四种动作、十六种过弯倾斜程度和三帧奔跑动画的每一种组合都准备单独的位图,就会有 2300 多帧,所以游戏的作者在内存里构建了一棵由 16 字节节点组成的树。每个节点指向同一层的下一个元素和自己的第一个子节点,并带有帧索引、各动画帧的偏移表和可见性标志。根节点是四匹马。战车车厢有一个额外的水平偏移,过弯的物理计算会往里面写入 0 到 15 像素的倾斜量,而车手是车厢的子节点,继承了这个位置。所以在弯道上,车手会随车厢一起倾斜,而马匹仍沿着赛道的轴线继续奔跑。车轴断裂和残骸翻滚的分支一直都存在,只是可见性标志处于关闭状态。
![]()
根据游戏数据还原出的战车精灵图树。示意图是在项目中用解码后的原版资源制作的。
赛道同样不是用图块搭出来的。游戏把保存的 72 对坐标展开成 368 个控制点,并把赛道横向的位置分成 16 条车道。弯道中的安全速度取决于车道,按(车道编号 + 15)× 4 计算:贴着内墙时为 60,靠外侧护栏时为 116–120。内侧车道路程最短,但以高于这个阈值的速度进入掉头弯,会使车厢倾斜,把战车甩向外侧,并触发一次翻车判定,超出得越多,翻车的可能性越大。速度条上的红色标记向玩家标示的就是这个阈值。

赛道的 368 个点,以及随车道变化的弯道安全速度。
除了游戏之外,这项工作还留下了什么
我把这套方法写成了一个 skill,也就是智能体在接到类似任务时加载的一组指令。它包含来自两个项目的三条原则。游戏规则要忠实移植,呈现层则用接口替换。移植版保留原版的内存布局。结果在通过与原始代码的比对之前不可信任。skill 里还收录了给分析智能体和移植智能体的任务说明,以及 Pirates 项目中的脚本。
最有意思的结论与玩家有关,而不是与智能体有关。谁有一款自己喜欢、却多年来总觉得缺点什么的游戏,如今不需要源代码,就能又快又便宜地按自己的想法把它改出来。在 Pirates 里,那道需要纸质手册才能回答的测验题现在会给出答案,还多了一张游戏原本没有的地图。在 Centurion 里,我加入了原版没有的规则:合并军团、战象和从海上登陆。游戏规则一旦变成了普通的 C# 或 JavaScript 代码,下一次改动就只是一次普通的代码修改,而不是给二进制文件打补丁。用同样的办法也可以把游戏搬到更快的引擎或别的平台上:Centurion 在浏览器里运行,Pirates 可以构建出 Linux 版和浏览器版。这套方法也并不只适用于老游戏。反编译器、处理器模拟器和记录比对对较新的程序同样有效;代码更多意味着工作量更大,而不是换一种做法。
资料来源
- 两个项目的自有材料:代码分析笔记、给智能体的任务说明、比对工具的报告(802 次运行、所发现差异的清单)、Centurion 移植版的说明及其规格文档。这些材料没有公开发布。
- National Security Agency,Ghidra,用于反编译两款游戏的逆向工程套件。
- Unicorn Engine,用于运行原始 68000 代码的处理器模拟器。
- OpenRetro Game Database,Amiga 版 Pirates! 的游戏页面,原版游戏截图的来源。
我把 AI 生成的内容用作日常学习过程的一部分。