开发者用 Claude Fable 5 在 Claude Code 中将 1993 年 Amiga 游戏 Babylonian Twins 移植到 Godot
将我1993年的Amiga游戏移植到Godot上,由LLM解析68000汇编代码
精选理由
作者亲历者复盘用 LLM 移植 68000 汇编的完整过程,给出可验证的字节级校验方法和多处 AI 出错的实例。
AI 摘要
作者让 Claude Fable 5 在 Claude Code 中分三步移植其 1993 年 Amiga 游戏:34,000 行 C++ 一个晚上迁入 Godot 4,72,758 行无注释 68000 汇编先用 vasm 重建出与发售版字节一致的二进制再移植,并把 1993 原作作为第二启动项嵌入新游戏。
正文 · AI 翻译
把我 1993 年的 Amiga 游戏移植到 Godot,让大语言模型阅读 68000 汇编代码
1993 年,在巴格达,我在一台 Amiga 500 上制作了一款名为 Babylonian Twins 的游戏:512KB 内存,没有硬盘,接在电视上使用。我当时是一名二十多岁的工科学生。纯 68000 汇编,每个精灵和每条扫描线都是手工编写。Murtadha Salman 负责美术,Mahir AlSalman 负责作曲。我们当时处于制裁之下。没有互联网,没有游戏开发资源,只有一本 Amiga 硬件参考手册,我直接用它来对硬件编程,每天还有几小时供电。由于内存小,需要不断更换软盘,加上 50°C 的夏天,我的磁盘驱动器坏了三次。
视频 · 前往原文观看
左图:1993 年,在 Amiga 上。右图:2026 年,同一个关卡入口。
在 Amiga 上,“手工编写”意味着游戏在运行期间不会向操作系统请求任何东西。启动时它会保存中断向量,关闭操作系统中断,然后接管整台机器:
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 list(Amiga 的可编程视频协处理器)驱动,并实时重写以处理精灵和天空颜色。瓦片移动通过直接写入 blitter 的寄存器并等待其完成标志来实现。操纵杆直接从硬件端口读取,开火按钮是 CIA 芯片上的一个引脚。操作系统只在关卡之间恢复运行,用于从磁盘加载下一关的文件,然后再次被关闭。
这是伊拉克制作的第一款商业游戏,而且在很长一段时间里,几乎没有多少人能玩到它。Commodore 公司倒闭了,制裁也吓跑了发行商,所以这款已经完成的游戏只能被搁置在架子上。2008 年,一个 Amiga 论坛从我哥哥上传到 YouTube 的视频里发现了这款游戏,并顺着线索找到我要走了磁盘;那个帖子至今还在。
这款游戏之前曾被移植过一次,是在 2010 年,由人工完成。同一个团队用从零编写的引擎为 iPhone 重新打造了它,大约 34,000 行 C++ 代码,花了数月时间,靠的是无数个夜晚和周末。Apple 和 Google 都对其进行了推荐,下载量超过了 200 万次。那个故事在这里。
这次移植不是我做的。我提出了这个要求,每晚试玩移植后的成果,指出哪里感觉不对,并做了几个需要有 1993 年亲历经验的人才能做的决定。文件格式和汇编代码的解读是 AI 的工作,如何把三十年前的代码迁移过来的决策也是 AI 做的,而且它的速度之快让我跟不上。这篇帖子是我几周后坐下来,仔细阅读对我的游戏所做的一切改动时的发现。其中有些地方是错的,而我几周都没有察觉。
为什么我又试了一次
我以前就试过这件事。大约一年前,我把同样的 Amiga 素材交给一个更早期的模型,请它理解我的二进制关卡地图。它最终做到了,但花了好几轮,而且我给了大量提示。
后来 Claude Fable 5 发布了,我把同样的文件交给了它。
这次测试是刻意为之的。我的猜测是,LLM 训练集中几乎没有 Amiga 汇编代码。如果模型更擅长推理而非记忆,那么在这里就会显现出来。
七月四日的周末快到了,所以我规划了三个步骤,每一步都以上一步成功为前提。
第一步,稳妥的请求:将我 2010 年写的引擎——那 34,000 行 C++ 代码——迁移到 Godot 4。这是对照组。
第二步,不合理的请求:原始的 72,758 行 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 启动结果。没有自动化的部分:现代移植版没有图像对比(它会截图,由我来查看),也没有任何检查能判断游戏手感是否合适。
第一步:一个晚上写完 34,000 行 C++ 代码
周三晚上,问的是稳妥的问题。时间戳,未删改:
22:23 Godot 4 project scaffold, asset sync, TMX level pipeline 22:44 both twins playable — collision, physics, camera, switching 23:19 all 38 entity types ported — full object roster live 00:35 full screen flow — menus, map, story, save, game flows 02:15 exporting to macOS, iOS and Android
从空项目到一个可玩的角色,只用了二十一分钟。那一晚它移动的每一行,都是我在2010年花了好几个月写下的代码。我上床睡觉时满心困惑。
在那之后,让手感变得舒服大约花了三天:跳跃弧线和蹦床的时机,以及奖励乱按的命中判定,分别在7月2日、3日和4日分批修好。
我不是一个人在测试。我十三岁的儿子和我一起玩了每一个版本。他一直都知道我做过这个游戏,这是他从小就知道的关于他父亲的事实,但他从没见过我开发它的过程。测试变成了一件我没计划过的父子之间的事,这也是整个项目里我最喜欢的部分之一。
相同的单位,相同的时钟频率
所有游戏状态都基于瓦片单位(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 六十次,得到的是一种摩擦力;每秒乘五十次,得到的又是另一种。把它移植到不同的时钟频率上,游戏里每一条加速度曲线都会改变。不会崩溃,只是永远感觉不对劲,而且你光看代码差异是发现不了的。在 60 Hz 下,这个常量可以原封不动地移植。这也是为什么 1993 年的重制版跑在 50 Hz,而现代版本跑在 60 Hz:两套手工调校的数字,各自只在自己的时钟频率下才是正确的。它把两个时钟都保留了下来。换作是我,可能会忍不住把它们统一成一个。
它没有使用 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 日(周日)下午,我交出了那个我真正想测试的东西。26 个文件、72,758 行代码,为只有 512 KB 内存的机器而写,作者是我,读者也是我,注释习惯完全不像会有人来看的样子。没有任何文档。2008 年迁移到现代存储时,所有长文件名都被截短了,于是每个 include 指向的都是已不存在的名字。五个关卡源文件之一,在一个数据表中间被截断了。没有其他副本。
在移植任何东西之前,它先让 1993 年的源码重新汇编成功——在 Apple Silicon Mac 上用 vasm——并且一直迭代到输出与当年发布的二进制文件逐字节一致为止。
14:34 import the Amiga sources, assets, references 14:49 vasm toolchain reproduces the shipped binaries byte-identically 15:20 disk images rebuilt 15:42 the rebuilt demo boots and plays in 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,752 字节中的 74,400 字节,余量为 352,而除了 1993 年的我之外,没有任何人检查过它。(SahamR-grb 将对象的行为附加为命名偏移量;saham 在阿拉伯语中是“箭”的意思。)ASM-One 的组织方式还可以将位置计数器向后移动,而 vasm 做不到这一点。第一个变通方案弄错了一种情况:在回绕块内的 `ds.b 800`,ASM-One 将其视为“跳过 800 字节”,却被写成 800 字节的零。文件中该点之后的所有内容,包括铜列表,都与发布二进制文件中的位置相差了 944 字节。游戏汇编成功并启动,但绘制了错误的内容。
即使在那之后,某些块仍然无法匹配,大约有 108 字节散布在变量区域中。这些字节解释了发布文件的来源。ASM-One 在内存中汇编,而游戏是通过在游戏运行后将内存保存出来而写入磁盘的。因此,发布文件是已经运行过的游戏的快照,而不是干净的汇编器输出。全新的汇编在这些变量中会有零,因为还没有任何东西设置过它们;而发布的磁盘上保存的是保存时机器上这些变量所持有的值。代码在读取它们之前会先写入它们,所以这些零是无害的。
当时我读到那一行,继续往下看,等待着真正的游戏。我花了好几周才意识到这是整个项目中最重要的东西,而且没有人要求过它。从那时起,关于这个游戏的每一个说法都可以通过比较字节来判定。我自己是不会这么做的。我已经有了二进制文件,而且在十八年里,从源码重新构建它似乎从来都不值得花一个下午的时间。
格式
对于每一种格式,它都会直接找到读取字节的代码,然后从那里反向推导。关卡加载器有 1,652 行没有注释的 68000 汇编代码,这正是我以前总是直接用十六进制编辑器的原因。
关卡
一个关卡就是一个瓦片网格:一长串数字,每个数字的意思是“把图片 47 放在这里”,用的是我自己 1993 年的私有布局。这正是老模型和我一年前艰难啃过的格式。
下面是构建一个关卡所用的全部瓦片,共 256 个,每个 16×16 像素,用于第一关:
以及第一关的一个切片,由这些瓦片拼合而成:
输入是一串没有文件头、也没有尺寸信息的数字,位于一个压缩块内。这一次我什么都没解释。它找到了绘制例程,读懂了网格的遍历方式,从文件其他位置的常量中推算出了宽和高,并且第一次尝试就为全部五个关卡生成了正确的地图。
然后它用自己提取的数据重新渲染了每个关卡,并将结果与我 2020 年截取的整关截图逐像素对比。凡是不匹配的地方,它都会去寻找原因,最终发现了两种铜色特效:天空渐变和水色循环。把这两项考虑进去之后:五张整关图像,零像素差异。仅第一关就有 600 个瓦片宽,即 9,600 像素。
地图单元格属性
绘制关卡只是地图单元功能的一半。每个单元是一个 16 位字,而画面只是其中较小的一部分:
one map cell, 16 bits:
bits 15..10 the property: what this square DOES (6 bits) bit 8 which of the two tile banks to use (1 bit) bits 7..0 which of the 256 tile pictures to draw (8 bits)
属性部分则是关卡中不可见的物理规则。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 将其印到单元格上,然后一个标志视图会标记所有携带所选编号的单元格。在撰写这篇文章时,我让模型运行地图编辑器并获取截图。它用现代汇编器汇编了 1993 年的源代码,将已发布的 Level 2 数据按编辑器期望的方式在内存中布局,并在模拟器中启动了结果。
我自己三十三岁时做的工具,正在编辑真实的第二关,开启了标志视图。当前标志读数为 0001,稳定不变,你可以站立的地面被标记出来,而你穿行其间的装饰物则没有被标记。面板上写着 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 是崩塌墙壁的例程,以相对于某个基础标签的偏移量附加,和之前那个射箭装置用的是同样的技巧。它后面的那些字是参数,含义由那个例程自己决定。文件中没有任何地方说明哪个字对应什么含义,因此它找到了那个每帧遍历这些表格的例程,让它来给这些字段命名,然后把全部五个关卡中的所有对象都转换成了世界坐标,并与渲染出的地图进行了核对。
GAME.S
大多数数据文件都会用存储在文件内部的密钥对其 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 都是天空,而图块美术里从来不会绘制它。图块图集将其渲染为透明,在它背后,铜色协处理器会在选定的扫描线上重新绘制背景色,以形成垂直渐变。这个渐变在关卡源码中只是一份普通的颜色列表。这就是第二关的整片天空:
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 个词,渲染后的效果。左侧是屏幕顶部。
第一次重建漏掉了这一点,色阶看起来没问题。很平,一种我说不上来的平。像素对比始终无法变绿,于是渐变又加了回去。
那个始终无法变绿的差异图:白色是第一次重建弄错的每一个像素,也就是铜色天空和水面的部分。
精灵表(Sprite sheet)的歧义
Amiga 精灵表是平面格式的(五个独立的 1 位位平面,按平面主序条带排列,外加一个透明遮罩),所有这些都是从绘制例程和 org 算术中推算出来的。形如 帧数 * 宽度 * 高度 * 2 * 5 的表尺寸存在歧义:那个 2 可能表示双倍宽度的帧,也可能表示两行堆叠,每行对应一个朝向。两种解读都能匹配文件中的每一个字节。它是两行朝向堆叠;这是我 1993 年时的选择。
两行堆叠,每行对应一个朝向,逐帧绘制,而非镜像翻转。
它标记出了这个歧义并提出了询问。
同一个双胞胎角色,同样的六帧:上方是 1993 年,下方是 2026 年。
那是最后一个格式。从那时起,1993 年的游戏以与 C++ 相同的方式进入了 Godot,行为用 GDScript 以原始的 50 Hz 重写。
第三步:新游戏里的旧游戏
这个贪心的请求只花了一个晚上,21:58 到 23:43。复古游戏作为访客运行,拥有自己的命名空间和场景宿主,引擎在进入时切换到 50 Hz,退出时切回 60。这很繁琐,而且是一口气完成的。我原本以为这个功能会耗掉一周时间然后被砍掉。正因如此,Steam 版本才内置了 1993 年的那款游戏。
两款游戏中的同一扇门,运行在同一个程序里。左:1993 年。右:2026 年。
你下载的游戏不包含任何 Amiga 代码。数据——压缩块、平面图形和音乐——曾由 Python 脚本在我的机器上一次性解码为普通的 PNG、WAV 和 JSON。行为逻辑(守卫如何巡逻、门何时打开)是用引擎自己的语言重写的。如果你想要原汁原味的东西,那就是本文末尾的免费磁盘镜像加一个模拟器。
哪里出了问题
守卫 bug
在第二关,你沿着一条走廊行走。左边是瀑布,前方是石柱。屏幕上没有敌人,没有任何东西靠近,你却受到了攻击。击中你的是一个手持长矛的士兵,他站在你上方十三格的位置,在一棵棕榈树旁的草台上,中间隔着坚实的岩石。
他是个守门人。他会推搡站在他脚下的人。在原版中,这个检查两侧都有围栏:
sub.w d1,d4 ; d4 = vertical distance to the kid cmp.w #4,d4 bpl Sg.Far ; 4 or more rows below? not my problem cmp.w #-2,d4 bmi SG.far ; too far above? also not my problem
移植版保留了下限,却丢掉了上限。原本只打算覆盖守卫脚下三行的一次推挤,现在却贯穿了他下方整列地图的高度,穿过地板,一直延伸到一条他根本不会出现的走廊里。
门卫本人,出自 1993 年的那张图纸。
小问题还有:每一关都有第二层图块,原版从不渲染它——那是门打开或假墙崩塌时才会露出的隐藏美术内容。如果“忠实”地渲染它,所有秘密通道从一开始就全部敞开了。如果让敌人先于玩家移动而不是后于玩家移动,一次蹦床跳跃就会被计算两次,直接弹到二十格高的空中。一扇门标注为“p1,p2,p3,p4”,意思是四只手掌都要按,却被读成了一把名字古怪的钥匙,于是教学关的出口永远打不开。音效本是单声道,循环长度却按立体声计算,导致每段声音播到一半就被切断重来。
代价最大的一个,是我在 1993 年版本里要求加入的功能:让双胞胎无论相隔多远都能互换位置。距离检测被删掉之后,雕像开始出错。我又把它放回例程里,得到了真正的答案——那和我预想的不一样:那个检测从来就不是距离限制。闲置的双胞胎会被作为雕像烙印在地图本身之中,而两座雕像叠在一起时,会互相吞噬对方的图块。守卫被放回去了,而我在 1993 年版本上砍掉了这个功能。
2010 年,我自己也犯过同样的错误,而且是慢慢地、花了几个月时间才犯下的。
然后它就这么上线了。
然后它完成了发布工作:以十一种语言、五种像素尺寸生成截图,制作预览视频、商店文案、六种形状的图标,并上传到三个在各方面都意见不一的应用商店。我以前发布过这款游戏,所以我知道这部分要耗费多少个夜晚。
截图直接来自游戏本身。它以每个商店所需的像素尺寸和语言启动真实游戏,让角色走到选定位置,拍摄截图,然后用游戏自带的字体绘制说明文字条。AI 从不渲染商店图片中的文字。说明文字必须是真实字体和真实翻译字符串,否则图片不会发布。有一次确实出了故障,俄语和韩语的说明文字变成了一排排空框,我在上传前就在输出文件夹里看到了。
我对 Steam 的不满在于它的表单字段非常多,比 Apple App Store 和 Google Play Console 多得多。此外,它没有 API 来简化元数据更新的流程。iOS 和 Android 有正规的 API,它就用这些 API。Steam 只有网页仪表盘,所以它驱动浏览器:商店页面字段、成就、试玩版清单、美术素材上传,逐一点击 Steamworks 界面。登录由我来做,凡是提交、发布、定价或上线的按钮都由我来点击。它负责填写表单。
它也会阅读我的评论。官方的 Google Play API 只提供最近七天的数据,这对一款有十五年评论历史的游戏来说毫无用处,所以它用公开爬虫抓取其余评论,一次一种语言。然后它阅读了所有评论,并列出哪些评论描述的是真实缺陷。我批准了这份清单。
其中有一条是 Google Play 上的一星评论,拼写很差,就是那种你会直接划过去的那种:
“第一关过不了门。门开了,但关卡不结束”
按字面理解,这是一份 bug 报告,而且它是对的。在我的游戏里,打开出口和穿过出口是两个独立的操作,而说明这一点的提示文字存在于全部十一种语言中,恰好放置在我十八个关卡中的两个关卡里。缺少该提示的关卡中,有一个是最后一个免费关卡。因此,这位正在决定这款游戏是否值得付费的玩家,站在一扇敞开的门前,却无从得知该怎么做,最终断定游戏坏了。
十五年来我从未发现这个问题,我的测试人员和两次重写版本也都没发现。是一个陌生人的一星评价让我注意到了它。修复以 2.0.3 版本在两个商店同时发布。我一直留着那条评价。
蹦床 bug
“蹦床感觉太高了。”这就是整份报告,来自深夜试玩该版本的我。各项常量都核对无误:对原版积分器进行二十行模拟,预测结果为 19.1 格,而实际版本测得 19.5 格。物理逻辑是对的。
问题出在输入语义上。2010 年的版本是事件驱动的,而且由于多年前我们为规避 tvOS 的一个怪癖而发布的临时方案,按住跳跃键会被读取为已松开,直到你再次物理按下。Godot 采用轮询方式获取输入,会持续报告按住状态。重现这一意外,正是高跳需要一次干脆利落、时机精准的新按压的原因——这也是游戏在手机上实际游玩的方式,也是我的双手所预期的操作。
2010 年的源代码并没有记录这一点,因为从源代码的角度来看,并没有发生任何异常。你必须当时在场,手里拿着手机,正在绕过一个电视里的 bug,才能明白发生了什么。
最初发布的版本
三十三年后,完整原版发布了,在 itch.io 上免费提供。可以在 FS-UAE、WinUAE 或真实硬件上启动它。Definitive Edition 现已登陆 iOS 和 Android,在 Steam 上有免费试玩版,完整版 Steam 发布(Windows、Mac、Linux)将于今年秋季上线,其中包含 1993 年的原版游戏,可作为第二个启动选项。
这次移植是由运行在 Claude Code 中的 Claude Fable 5 完成的;我来提问、试玩并做决定。这篇帖子也是同样的方式写成的。我把我移植时的笔记、我对老游戏关键部分(地图编码、对象表)的记忆,以及 Amiga 版本和移植版的代码仓库都交给了它,它写出了初稿。我花了一周时间逐行编辑。代码、时间戳和截图都是真实的。我最不确定的部分是那 108 个字节:模型告诉我,发布出来的文件是一次运行后保存的内存快照,而且代码在读取那些变量之前会先写入它们。我读到这里,就继续往下走了,自己从未核实过。
从提取的地图数据渲染出的五个关卡中,每个关卡各取一个切片。
《Babylonian Twins: Definitive Edition》将于今年秋季登陆 Steam——点击此处加入愿望单。免费试玩版现已推出 · 今日上线 iOS 和 Android · 1993 年原版 ADF 文件在 itch 上免费提供。
试玩《Babylonian Twins》→
原文
Original Title
将我1993年的Amiga游戏移植到Godot上,由LLM解析68000汇编代码
Source
Hacker News 热门(buzzing.cc 中文翻译)
Site
babyloniantwins.com
Author
rabahs
Published
2026-09-04 08:07