开发者用 Claude Fable 5 在 Claude Code 中将 1993 年 Amiga 游戏 Babylonian Twins 移植到 Godot
将我1993年的Amiga游戏移植到Godot上,由LLM解析68000汇编代码
作者让 Claude Fable 5 在 Claude Code 中分三步移植其 1993 年 Amiga 游戏:34,000 行 C++ 一个晚上迁入 Godot 4,72,758 行无注释 68000 汇编先用 vasm 重建出与发售版字节一致的二进制再移植,并把 1993 原作作为第二启动项嵌入新游戏。
作者亲历者复盘用 LLM 移植 68000 汇编的完整过程,给出可验证的字节级校验方法和多处 AI 出错的实例。
把我的 1993 年 Amiga 游戏移植到 Godot,让一个大语言模型来读 68000 汇编
1993 年,在巴格达,我在一台 Amiga 500 上做了一款叫 Babylonian Twins 的游戏:512KB 内存,没有硬盘,接在一台电视上。那时我是个二十多岁的工程系学生。纯 68000 汇编,每一个精灵、每一条扫描线都是手工写的。Murtadha Salman 负责美术,Mahir AlSalman 负责作曲。我们当时处于制裁之下。没有互联网,没有任何游戏开发资料,只有一本 Amiga Hardware Reference Manual,我用它直接对硬件编程,每天只有几个小时有电。因为内存太小而不断换软盘,再加上 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 都曾将其列为推荐,下载量超过了两百万次。那段故事在这里。
这次移植不是我做的。是我提出来的,我每天晚上都玩移植后的结果,指出哪里感觉不对,并做了少数几个需要 1993 年亲历者才能拍板的决定。文件格式和汇编代码的解读是 AI 的工作,关于如何把三十年前的代码迁移过来的决策也是 AI 做的,而且它的速度之快我跟都跟不上。这篇文章,是我几周后坐下来,读了一遍它对我自己的游戏做了什么之后,所发现的东西。其中有些地方是错的,而我好几周都没有察觉。
我为什么又试了一次
我以前试过这件事。大约一年前,我把同样的 Amiga 素材交给了一个更早的模型,让它解读我的二进制关卡地图。它最终搞明白了,但经历了好几轮,还需要我给了很多提示。
后来 Claude Fable 5 发布了,我把同样的文件交给了它。
这个测试是刻意设计的。我的猜测是,LLM 训练集中几乎没有 Amiga 汇编代码。如果模型更擅长推理出结果而非回忆已有内容,那么这里正是能体现出来的地方。
7 月 4 日的周末即将到来,所以我规划了三个步骤,每一步都以上一步成功为前提。
第一步,安全的要求:把我自己 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 日分批修好。
我不是一个人在测试。我十三岁的儿子和我一起玩了每一个构建版本。他一直都知道我做了这个游戏,这是他从小就知道的关于他父亲的一个事实,但他从没见过我实际做它。测试变成了一件我没计划过的父子活动,这也是整个项目里我最喜欢的部分之一。
同样的单位,同样的 tick
所有游戏状态都以 tile 为单位(1.0 = 一个 48px 的 tile),更新以固定的 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日周日下午,我交出了我真正想测试的东西。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 的 org 还可以将位置计数器向后移动,而 vasm 做不到这一点。第一个变通方案弄错了一种情况:一个位于回退块内部的 ds.b 800,ASM-One 将其视为“跳过 800 字节”,却被写成了 800 字节的零。文件中该点之后的所有内容,包括 copper list,都与已发布的二进制文件中的位置相差 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 sideCheckrmh 把那些会造成伤害的情况交给一个名为 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 数据按编辑器预期的位置布置到内存中,并在模拟器里启动了结果。

我自己的工具,三十三年前写的,正在编辑真正的 Level 2,flag 视图已打开。CURRENT FLAG 显示为 0001,实心的,你可以站立的地面被标记出来,而你可以穿过的装饰物则没有。面板上写着 1994:面板图像是编辑器加载的一个独立位图文件,而幸存下来的那一份比 1993 年 2 月的代码还要晚。
面板上的另一个名字,Udai,是我在 Mesopotamia Software 的合伙人——我们当时就是这么称呼自己的。他那时正在构建自己的游戏。编辑器是我写的,为我们俩而写,但它的设计是我们共同敲定的,这样同一个工具就能同时服务于两款游戏。他的游戏从未完成。
对象表
敌人不在 map 里。世界是按一屏一屏存储的,25 个 tile 宽、20 个 tile 高,每一屏都有一张小表记录其上的对象。我 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 个词,渲染出来。左侧是屏幕顶部。
第一次重建漏掉了它,色阶看起来还行。平坦,一种我说不上来的感觉。像素比对始终不肯变绿,于是渐变又被加了回去。

那个不肯变绿的差异图:白色是第一次重建中每一个出错的像素,也就是铜色的天空和水面。
精灵图集的歧义
Amiga 的精灵图集是平面式的(五条独立的 1-bit 位平面,以平面为主的条带排列,外加一个透明遮罩),而这一切都是从绘制例程和 org 运算中推导出来的。形如 frames * width * height * 2 * 5 的图集尺寸存在歧义:那个 2 可能意味着双倍宽度的帧,也可能意味着两行堆叠、每个朝向方向一行。两种解读都符合文件中的每一个字节。答案是两行朝向;这是我在 1993 年做出的选择。

两行堆叠,每个朝向方向一行,逐帧绘制,而非镜像。
它标记出了这个歧义并进行了询问。

同一个双胞胎,同样的六帧:上方是 1993 年,下方是 2026 年。
那是最后一种格式。从那里开始,1993 年的游戏以和 C++ 相同的方式进入了 Godot,行为用 GDScript 以原始的 50 Hz 重写。
第三步:新游戏里的旧游戏
这个贪心的需求只花了一个晚上,从 21:58 到 23:43。复古游戏作为客体运行,拥有自己的命名空间和场景宿主,引擎在进入时切换到 50 Hz,退出时切回 60 Hz。这很繁琐,但一次就做完了。我本以为这个功能会耗掉一周然后被砍掉。正是它让 Steam 版本内置了 1993 年的那款游戏。

两款游戏中同一扇门,运行在同一个程序里。左:1993 年。右:2026 年。
你下载的游戏不包含任何 Amiga 代码。数据、打包的数据块、平面图形和音乐,都是在我的机器上用 Python 脚本一次性解码成普通的 PNG、WAV 和 JSON。行为逻辑(守卫如何巡逻、门何时打开)则用引擎自己的语言重写。如果你想要原汁原味的东西,那就是本文末尾的免费磁盘镜像和一个模拟器。
哪里出了问题
守卫 bug
在第 2 关,你沿着一条走廊行走。左边是瀑布,前方是石柱。屏幕上没有敌人,也没有任何东西靠近,你却挨了一下。打到你的是一个持矛士兵,站在你上方十三个格子的位置,在一棵棕榈树旁的草地平台上,中间隔着坚硬的岩石。

他是个门卫。他会推开站在他脚下的任何人。在原版中,这个检测两侧都有边界限制:
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,它也用上了。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 上有免费 demo,完整 Steam 版本(Windows、Mac、Linux)将于今年秋季推出,其中还内置了 1993 年的原版游戏作为第二个启动选项。
这次移植是由在 Claude Code 中运行的 Claude Fable 5 完成的;我提出要求、试玩,然后做决定。这篇文章也是同样的流程。我把自己关于这次移植的笔记、我对老游戏关键部分(地图编码、对象表)的记忆,以及 Amiga 版和移植版的仓库都给了它,它写出了初稿。我花了一周时间逐行编辑。代码、时间戳和截图都是真实的。我最没把握的部分是那 108 个字节:模型告诉我,发布出去的文件是一次运行后保存的内存快照,而代码会在读取那些变量之前先写入它们。我读了这段话,继续往下走,自己从未核实过。

从五个关卡中各取一个切片,由提取出的地图数据渲染而成。
Babylonian Twins: Definitive Edition 将于今年秋季登陆 Steam——点此加入愿望单。[ 免费 demo 现已开放 · 今日已在 iOS 和 Android 上线 · 1993 年的原版 ADF 在 itch 上免费。
来源:Hacker News 热门(buzzing.cc 中文翻译) · babyloniantwins.com