开发者用 Claude Fable 5 将 33 年前的 MC68000 汇编游戏一个晚上移植到 Godot,验证了 AI 辅助代码移植的可行性。
1993 年,我在巴格达用一台 Amiga 500 制作了一款名为 Babylonian Twins 的游戏:512KB 内存,没有硬盘,接在电视上。那时候我二十出头,是个工程专业学生。每一帧精灵、每一行扫描线,全是手写的纯 68000 汇编。Murtadha Salman 绘制美术,Mahir AlSalman 作曲。我们活在制裁里。没有互联网,没有游戏开发资源,只有一本 Amiga 硬件参考手册,我就是靠它直接编程硬件的,每天只有几小时供电。不断换软盘(因为内存太小)和 50°C 的夏天让我的磁盘驱动器坏了三次。
左:1993 年,在 Amiga 上。右:2026 年,同一个关卡。
在 Amiga 上,"手写"意味着游戏运行时不向操作系统请求任何东西。启动时它保存中断向量,关闭 OS 中断,独占整台机器:
move.l #$dff000,a0 ;Base for hardware registers
lea save(pc),a1 ;Get the system
move.w #$4000,intena(A0) ;from the AMIGA
"Get the system from the AMIGA"是我1993年写的注释。从那一刻起,显示画面就是游戏自己的 copper 列表(Amiga 的可编程视频协处理器),在运行时为精灵和天空颜色动态重写。瓦片移动通过直接写 blitter 的寄存器并等待其完成标志来实现。操纵杆直接从硬件端口读取,扳机按钮是 CIA 芯片上的一个引脚。操作系统只在关卡之间回来加载下一关的文件,然后又关闭了。
这是伊拉克制作的第一款商业游戏,而且很长时间里只有很少人玩到这款游戏。Commodore 倒闭了,制裁吓跑了发行商,所以成品游戏只能放在架子上。2008 年,一个 Amiga 论坛从我哥哥的 YouTube 上传视频中找到了它,追着我要磁盘;帖子还在那里。
这款游戏之前已经移植过一次,是手写的,2010 年。同一个团队用我从头写的一个引擎为 iPhone 重制了它,大约 34000 行 C++,花了几个月的夜晚和周末。Apple 和 Google 推荐了它,下载量超过了两百万次。那个故事在这里。
这次移植不是我做的。我提了需求,每晚玩结果,说哪里感觉不对,然后做那些必须由亲历1993年的人来做的决定。文件格式和汇编代码解析是 AI 做的,如何把三十年旧代码迁移过来的决策也是 AI 做的,速度快到我跟不上。这篇文章是我几周后坐下来读我自己的游戏被做了什么时发现的东西。其中有些是错的,但我好几周都没注意到。
我之前试过。大约一年前,我给一个更早的模型同样的 Amiga 素材,让它理解我的二进制关卡地图。它最后做到了,但花了好几轮和我大量的提示。
然后 Claude Fable 5 发布了,我给了它同样的文件。
这个测试是有意设计的。我猜测在 LLM 训练集中几乎没有 Amiga 汇编代码。如果模型擅长推理而不是回忆,它会在这个地方表现出来。
七月四日周末快到了,所以我计划了三步,每一步都依赖前一步成功。
第一步,安全的请求:我自己的 2010 年引擎,那 34000 行 C++,迁移到 Godot 4。这是对照组。
第二步,不公平的请求:原始的 72758 行 68000 汇编,面向一台早已停产的机器,没有像样的注释,和 C++ 毫无共同之处。用它重建到 Godot,保持 Amiga 原生的 50 Hz。
第三步,贪婪的请求:把第二个放进第一个里,这样买现代版游戏就送你1993原版作为可以启动的第二个东西。
三步都成了。那个关卡格式一年前花了好几轮和我修正才出来,这次一次通过,没有任何提示。
我在 Claude Code 里运行它,所以它有终端和我的文件系统。它能编辑文件、运行汇编器、构建游戏、启动游戏并读取返回结果。当我说它重建了我的1993二进制文件并校验了它们,它是通过运行 vasm 和 diff 输出来做到这一点的。
早期它给游戏添加了一套命令行参数,这样它就能不依赖我自动运行:
--level=<name> load a level directly
--pose=<spec> put the twins at exact positions
--drive=<spec> press buttons on a script, frame by frame
--probe dump switch / gate / door / key state
--screenshot=<path> render a frame and quit
这就把"跳跃感觉对不对"变成了机器能读的东西:
drive[btw_jump:2.2] pos=(25.44, 24.04) vel=(0.00, -14.51) ground=false apex_y=22.48
它还有两个可以在给我看任何东西之前运行的自动化检查:一个编译所有脚本,一个构建所有关卡并报告失败。在 Amiga 端它驱动真实的工具链,用 vasm 汇编并用 FS-UAE 启动结果。没被自动化的:现代移植版没有图像对比(它截图,我看),也没有任何东西检查游戏感觉对不对。
第一步:一个晚上 34000 行 C++
周三晚上,安全的请求。时间戳,未编辑:
22:23 Godot 4 项目脚手架,资源同步,TMX 关卡管线
22:44 两位主角可玩——碰撞、物理、摄像机、切换
23:19 全部 38 种实体类型移植——完整对象列表上线
00:35 全屏流程——菜单、地图、剧情、存档、游戏流程
02:15 导出到 macOS、iOS 和 Android
从空项目到可玩角色只用了 21 分钟。那天晚上它移动的每一行代码都是我在 2010 年花了好几个月写的。我带着困惑去睡了。
之后让它感觉对用了大约三天:跳跃弧线和蹦床时序,以及奖励连按的碰撞检测,在 7 月 2、3、4 日分批修复。
我不是独自测试的。我十三岁的儿子每晚都和我一起玩每个版本。他一直知道这是我做的游戏,这是他成长中关于父亲的一个事实,但他从没看过我做这个。测试变成了我计划之外的父子活动,这是整个项目我最喜欢的部分之一。
同一单位,同一 tick
所有游戏状态都生活在瓦片单位里(1.0 = 一个 48px 瓦片),更新以固定 60 Hz 运行,因为 2010 年的 iOS 构建以 60 Hz 运行。这很重要因为原版每帧乘法应用阻力:
static const float GROUND_DRAG_FACTOR = 0.85f;
this->velocity.x *= GROUND_DRAG_FACTOR; // every tick!
每秒乘以 0.85 六十次得到一种摩擦力;每秒乘五十次得到另一种。移植到不同 tick 率游戏里所有加速曲线都会改变。不会崩溃,只是永远感觉不对,而且你读 diff 找不出来。在 60 Hz 下常数原样移植。这就是为什么 1993 年重建版以 50 Hz 运行而现代版以 60 Hz 运行:两套手调数字,每套只在自己的 tick 下正确。它保留了两个时钟。我本来会想把它们合并成一个。
它没用 CharacterBody2D
Godot 自带 CharacterBody2D 和 move_and_slide(),每个教程都告诉你用它。移植版对玩家两者都没用。原版有自己的手写移动代码,在别人的物理系统上重建感觉会有一些很难追踪的不对劲。玩家是一个普通 Node2D,150 行的碰撞例程逐行移植过来,包括我十五年前凭感觉选的凑数和我写给未来自己的注释:
# Add 0.5 because we want the character's feet to be in the middle of the tile.
var bottom := pos.y + dim.y / 2 + 0.5 + i + fraction
if int(bottom) == int(pos.y + dim.y / 2 + 0.49):
continue
var right := pos.x
var left := pos.x - dim.x / 4 # asymmetric probes!
没有任何清理那个 0.49。没有测试也没有文档;那些注释就是规格说明。
第二步:68000 汇编
到周日(7月5日)下午,我交出了我真正想测试的东西。72758 行横跨 26 个文件,为一台 512KB 内存的机器写的,由我为我自己写的,注释习惯是从来不指望另一个人来读它。没有文档。2008 年转移到现代存储时缩短了每个长文件名,所以每个 include 都指向不再存在的名字。五份关卡源文件之一在数据表中途被截断了。没有别的副本。
在移植任何东西之前,它先用 vasm 在 Apple Silicon Mac 上让 1993 源码重新能汇编出来,并持续工作直到输出与发货的二进制文件字节完全一致。
14:34 导入 Amiga 源码、资源、参考文献
14:49 vasm 工具链字节级重现发货的二进制文件
15:20 磁盘镜像重建
15:42 重建的演示在 FS-UAE 中启动并可玩
从一文件夹文件到第一次重建匹配出厂字节,只用了十五分钟。我用 ASM-One 写的,它的方言与 vasm 的不同,会影响字节输出:ASM-One 把 cmp #4,d0 编码为 CMPI,而 vasm 选了另一种同样有效的编码,所以告诉它不要优化是不够的。它写了一个预处理通道来桥接这五处差异,而不是编辑我的源码,还逐文件重建了损坏的文件名映射。
代价最高的是 org。由于没有链接器也没有重定位,关卡源码是手工逐地址布局 Amiga 内存的:
org $6a000 ; this section lives at address $6a000
Mapadd:
incbin"btwins:binary/L1/Map1.b" ;Game Map
org mapadd+73*1024 ; skip to 73 KB past the map's start
GLBtable:
dc.w $3333,50,20,100 ; one object record begins
dc.w SahamR-grb,26 ;Routine,Length
...
org glbtable+2*1024 ; the object table gets exactly 2 KB
第一关地图用了 74,400 / 74,752 字节,留了 352 字节的余量,除了我之外没人检查过,1993 年那时候。(SahamR-grb 把一个对象的行為作为命名偏移量附加上去;salam 在阿拉伯语里是箭的意思。)ASM-One 的 org 还可以向后移动位置计数器,这是 vasm 做不到的。第一个变通方案有一个 case 出错了:一个 rewound 块里的 ds.b 800,ASM-One 把它当作"跳过 800 字节",而输出的是 800 字节的零。从那之后文件中所有内容,包括 copper 列表,都比出厂二进制偏了 944 字节。游戏能编译能启动,但画出来的东西不对。
即便如此,有些数据块仍然不匹配,变量区里散落着约 108 字节的差异。这些字节解释了出厂文件来自哪里。ASM-One 是汇编到内存里的,游戏写入磁盘是把这块内存保存出去,而且是在游戏运行之后。所以出厂文件是游戏已经运行过的快照,不是干净的汇编输出。新鲜编译出来的变量里全是零,因为还没有东西设置过它们;出厂磁盘里是保存时那些变量在机器上的值。代码在读取它们之前会先写入,所以这些零是无害的。
当时我读到那行就过去了,等着看真正的游戏。等了好几周才意识到这是整个项目里最重要的事,而且没人要求过。从那以后,关于这个游戏的每个结论都可以通过字节比对来确认。我自己是不会去做的。我已经有二进制文件了,十八年来从源码重建它们从来没觉得值得花一个下午。
对于每种格式,它都找到读取字节的代码,然后从那里反向推导。关卡加载器是 1,652 行没有注释的 68000 代码,这就是我以前总是直接拿十六进制编辑器的原因。
关卡是一张网格瓦片:一个很长的数字列表,每个数字的意思是"在这里放图片 47",是我自己 1993 年的私有布局。这就是我和那个更早的模型一年前啃过的格式。
以下是构成一个关卡的全套瓦片,第一关共 256 个,每个 16×16 像素:
以及由这些瓦片拼出的一部分第一关:
输入是一个没有头部、没有尺寸信息的数字列表,包裹在一个压缩块里。这次我什么都没解释。它找到了绘图例程,读懂了网格是如何被遍历的,从文件中其他地方的常量推导出宽高,第一次尝试就为全部五关生成了正确的地图。
然后它用自己的提取数据重新渲染每一关,并将结果逐像素比对 2020 年我做的那几张全关截图。不匹配的地方,它去找到原因并发现了两个 copper 效果:天空渐变和水色循环。把这两种计算进去之后:五张全关图像,零个不同像素。仅第一关就宽 600 块瓦片,9,600 像素。
绘制关卡只是地图单元格功能的一半。每个单元格是一个 16 位字,而图片只是其中较小的部分:
一个地图单元格,16 位:
位 15..10 属性:此方块的行为 (6 位)
第 8 位 使用两块瓦片库中的哪一块 (1 位)
位 7..0 绘制 256 张瓦片图片中的哪一张 (8 位)
属性是关卡中不可见的物理逻辑。1 是实心地面。2 和 3 可以攀爬。10 到 13 都表示"这会伤人",四种代码是因为击退需要一个方向。14 直接致死。63 是门。没有任何地方写过这些。是两个例程读取同一个字才恢复出来的:绘图循环遮掉低位字节,碰撞检测则做相反的事:
move.w (a1),d6 ; the same cell
and.w #$fc00,d6 ; keep the top 6 bits
lsr.w #2,d6
lsr.w #8,d6 ; d6 = the property, 0..63
bsr cbCheck ; 2 or 3? you can climb this
bsr Checkrmh ; 10..13? this hurts, and from which side
Checkrmh 把需要处理伤害的情况交给一个叫 rmhEnjury 的标签,这是 1993 年的我拼写"injury"的方式。
这些位是在编辑器里涂上去的。游戏要能构建,得先构建构建它的工具:MEDITOR.S,1,254 行汇编,有自身头部标注的日期,用我 1993 年的英语写成:
; ***********************************************************************
; * This Program was written in four days *
; * 1993-2-8/7/6/5 *
; * I made it to help me to make a map to my first serious *
; * Game *
; ***********************************************************************
1993 年 2 月用了四天。用鼠标画瓦片,在面板的 CURRENT FLAG 计数器上选一个属性编号,用 PUT FLAG 盖到单元格上,flag view 标记出所有带有选中编号的单元格。写这篇文章时,我让模型运行地图编辑器并截了张图。它用现代汇编器汇编了 1993 年的源码,把出厂的第二关数据布局到编辑器期望的内存位置,然后在模拟器里启动结果。
我的工具三十三年后仍在用,编辑真实的第二关,flag view 开着。CURRENT FLAG 显示 0001,solid(实心),能站的地板被标记了而能穿过的装饰物没被标记。面板上写的是 1994:面板美工是一张分离的位图文件,编辑器会加载它,存活下来的副本是比 1993 年 2 月代码更晚的版本。
面板上的另一个名字 Udai,是我当年在 Mesopotamia Software 的搭档,这就是我们给自己取的名字。他当时在开发自己的游戏。我写了编辑器给我们俩用,但设计是我们商量出来的,这样一个工具可以服务于两个游戏。他的游戏从未完成。
敌人不在地图里。世界按屏幕存储,每屏 25 块瓦片宽、20 块高,每个屏幕都有一张小表记录上面的对象。我 1993 年的注释解释了这些标记:
; $1111=this is a Screen but it contain nothing or(End of Screen)
; $2222=this is an object but do not draw it (dead)go to next
; other=this is an object,draw it and go to the next
Scr0: dc.w $3333,50,23,17 ; a live object: frame, then x, y
dc.w hiddenwallR-lrb,20 ; its behaviour: a routine, as an offset
dc.w 0
dc.w 0
dc.w 7
dc.w 10 ; parameters only that routine understands
dc.w $3333,50,12,14
dc.w GreatTR-LRb,16,GkeyT-GTT,1
dc.w $1111 ; end of this screen
一个敌人是一行字:一个标记、一帧、在屏幕内的位置,然后是它的行为。hiddenwallR-lrb 是"倒塌墙壁"例程,作为相对于基标签的偏移量附加上去,和前面箭射手的做法一样。后面的字是参数,含义由那个例程自己决定。文件中没有任何地方说明哪个字是什么,所以它找到了每帧遍历这些表的例程,让它来命名这些字段,然后把所有五关中的每个对象转换为世界坐标,与渲染后的地图逐一比对。
大多数数据文件都用存在文件内部的一个密钥来扰乱其 16 字节的头部,这是 1993 年防止磁盘编辑器偷看的把戏。零售加载器 GAME.S 完全没有解扰乱步骤。我把这当作一条线索:GAME.S 是在扰乱被加入之前写的,所以是个更老的文件。这条线索后来让它从同一个文件内部的扇区映射恢复了丢失的两张盘零售套装。
门不在地图里
我一直以为在。
加载一个关卡的瓦片地图,门应该在的地方全是空洞,里面没有门瓦片,不管是开是关。一个 18 字节的对象记录在运行时将它们盖到地图上,一个 1×4 的瓦片列,来自一张表:
closed $528 $53C $550 $564 ; solid, blocks the way
open $129 $13D $151 $165 ; passable — exactly one sheet-column right
地图数据说没有门,关卡代码说有一扇。三十三年来我会告诉你地图是真理的来源,门属于地图数据,我根本不会去深究。它同时保留了两个事实,找到了协调它们的例程,然后给出了设计结论:门是在运行时由代码绘制的;在编辑器中它们从未被画进地图里。这就是为什么直接移植关卡数据会产生一个门洞里全是天空的塔楼。
在每个关卡中,颜色索引 31 是天空,但图块美术中没有任何东西会绘制它。图块图集将其渲染为透明,而铜片(copper)在选定的扫描线上重新绘制背景色来构成垂直渐变。渐变色在关卡源文件中以一个简单的颜色列表形式存在。以下是第二关的完整天空数据:
backgndcol:
col1: dc.w $09FF,$09FF,$09FF,$09FF,$09EF,$09EF,$0ADF,$0ADF
dc.w $0ACF,$0ACF,$0ABF,$0BBF,$0BBF,$0CBF,$0CBF,$0DCF
dc.w $0DCF,$0ECF,$0DCF,$0DCF,$0CCF,$0CCF,$0CDF,$0CDF
顺着列表往下看,天空从淡蓝色渐变到接近地平线处的暖色调。
同样的 24 个词,渲染后的结果。左边是屏幕顶部。
第一次重建遗漏了这一点,关卡看起来没问题。只是很平,平到我无法形容。像素对比拒绝变绿,渐变才被加回去。
那个不肯变绿的 diff:白色是第一次重建所有搞错的像素,铜片的天空和水。
Amiga 精灵表是平面的(五个独立的 1 位位平面,以平面主序的条带排列,外加一个透明蒙版),所有这些都从绘图例程和 org 算术推导出来了。frames * width * height * 2 * 5 这种形式的精灵表大小是歧义的:那个 2 可能意味着双宽帧,也可能意味着两行堆叠,每行代表一个朝向。这两种解读都符合文件中的每一个字节。实际上是两行堆叠,每行代表一个朝向;这是我在 1993 年的选择。
两行堆叠,每行代表一个朝向,帧逐帧绘制,不是镜像的。
它标出了这个歧义并提出了询问。
同一组精灵,同样的六帧:上面是 1993 年,下面是 2026 年。
这是最后一种格式。从那里开始,1993 年的游戏进入 Godot 的方式与 C++ 相同,行为在原来的 50 Hz 下用 GDScript 重写。
这次贪婪的需求花了一个晚上,从 21:58 到 23:43。复古游戏作为客体运行,有自己的命名空间和场景宿主,引擎在进入时切换到 50 Hz,退出时切回 60 Hz。很繁琐,但一气呵成了。我原本以为这个功能会花上一周然后被砍掉。它就是 Steam 版本把 1993 年游戏打包在内的原因。
同样的门道,两个游戏同时运行,在同一个程序里。左边:1993 年。右边:2026 年。
你下载的游戏里没有 Amiga 代码。数据——压缩块、平面图形和音乐——在一次解码中,由我的机器上的 Python 脚本转换成普通的 PNG、WAV 和 JSON。行为(卫兵如何巡逻、门何时打开)用引擎自己的语言重写了。如果你想玩原版,那就是这篇帖子末尾提供的免费磁盘镜像加模拟器。
在第二关,你沿着走廊走。左边是瀑布,前方是石柱。屏幕上没有敌人,没有东西在靠近,你却中招了。击中你的是一个扛矛的士兵,他站在你上方 13 格的地方,在草地岩壁旁的一棵棕榈树边,中间还隔着实心岩石。
他是个门卫。他会把站在他脚下的人推开。在原版中,那个检查被上下界限制:
sub.w d1,d4 ; d4 = 到小孩的垂直距离
cmp.w #4,d4
bpl Sg.Far ; 在下方 4 格或更远?不是我该管的
cmp.w #-2,d4
bmi SG.far ; 太靠上了?那也不是我的事
移植版保留了下界,丢掉了上界。一次本应覆盖守卫自身三格范围的推人,现在波及了他所在列的整张地图高度,穿过地板,推进了一条他根本不会出现的走廊。
门卫本人,来自 1993 年的精灵表。
还有一些小问题:每个关卡都有一个原版从未渲染的第二图块层;那是门打开或假墙崩塌时才显现的隐藏美术。"忠实"渲染它的话,每个秘密通道从一开始就敞开着。在玩家之前而非之后移动敌人,一次蹦床跳跃会被计算两次,弹到二十格高的空中。一个标记为 "p1,p2,p3,p4" 的门意思是四棵棕榈树全要,被读成了一个名字奇怪的单把钥匙,导致教程出口永远打不开。音效循环长度在单声道效果的情况下被当作立体声计算,把每个音效切成一半然后重启。
代价最高的是一个我在 1993 年构建时要求的功能:让双胞胎可以在任意距离下互换位置。近接检查被移除了,雕像开始出现数据损坏。它回到例程里得出了真正的答案,这不是我预期的:那个检查从来不是距离限制。空闲的双胞胎是以雕像的形式压印在地图本身上的,两个叠压的雕像会吃掉彼此的图块。守卫被放回去,我在 1993 年构建版上取消了这个功能。
2010 年我自己也犯了同样的错误,慢慢地,花了几个月。
然后它做了发布工作:五个像素尺寸、十一种语言的截图,一个预告视频,商店文案,六个形状的图标,上传到三个对所有事情都有分歧的商店。我之前已经发行过这个游戏,所以我知道那部分要花多少个晚上。
截图从游戏本身产出。它以每个商店要求的像素尺寸、在需要的语言下启动真正的游戏,让角色走到选定的位置,拍摄,然后用游戏自己的字体绘制说明带。AI 从不在商店图片中渲染文字。说明文字是真正的字体和真正的翻译字符串,否则图片不予发货。有一次确实出问题了,俄语和韩语的说明文字出来是一排空框,我在上传前的输出文件夹里看到了。
我对 Steam 的抱怨是它的表单有很多字段,比苹果 App Store 和 Google Play Console 多得多。此外,它没有 API 让元数据更新过程变得简单。对于 iOS 和 Android 有正经的 API,它用了。Steam 只有网页仪表盘,所以它驱动了浏览器:商店页面字段、成就、试玩演示清单、美术作品上传、在 Steamwo