# 开发者用 Claude Fable 5 在 Claude Code 中将 1993 年 Amiga 游戏 Babylonian Twins 移植到 Godot

- 来源：Hacker News 热门（buzzing.cc 中文翻译）
- 作者：rabahs
- 发布时间：2026-09-04 08:07
- AIHOT 分数：77
- AIHOT 标记：精选
- AIHOT 链接：https://aihot.virxact.com/items/cmtm7yl5s01dbrobnjyyupz0q
- 原文链接：https://babyloniantwins.com/blog/porting-a-1993-amiga-game-to-godot

## 精选理由

作者亲历者复盘用 LLM 移植 68000 汇编的完整过程，给出可验证的字节级校验方法和多处 AI 出错的实例。

## AI 摘要

作者让 Claude Fable 5 在 Claude Code 中分三步移植其 1993 年 Amiga 游戏：34,000 行 C++ 一个晚上迁入 Godot 4，72,758 行无注释 68000 汇编先用 vasm 重建出与发售版字节一致的二进制再移植，并把 1993 原作作为第二启动项嵌入新游戏。

## 正文

把我 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》→
