把我 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》→
Porting my 1993 Amiga game to Godot, with an LLM reading the 68000 assembly
In 1993, in Baghdad, I built a game called Babylonian Twins on an Amiga 500: 512KB of RAM, no hard drive, plugged into a TV. I was an engineering student in my twenties. Pure 68000 assembly, every sprite and every scanline by hand. Murtadha Salman drew the art and Mahir AlSalman composed the music. We were under sanctions. No internet, no game development resources, just one copy of the Amiga Hardware Reference Manual, which I used to program the hardware directly, and electricity a few hours a day. The constant floppy disk swapping (because of the small memory) and the 50°C summers killed my disk drive three times.
Left: 1993, on the Amiga. Right: 2026, the same gateway.
On the Amiga, “by hand” means the game doesn’t ask the operating system for anything while it runs. At startup it saves the interrupt vectors, switches the OS interrupts off and takes the whole machine:
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” is my comment, from 1993. From that point on the display is the game’s own copper list (the Amiga’s programmable video coprocessor), rewritten on the fly for sprites and sky colours. Tiles move by writing the blitter’s registers directly and waiting on its done flag. The joystick is read straight from the hardware port, and the fire button is one pin on a CIA chip. The OS comes back only between levels, to load the next level’s files from the disk, and then it’s switched off again.
It was the first commercial game made in Iraq, and for a long time a game very few people got to play. Commodore collapsed and sanctions scared off publishers, so the finished game sat on a shelf. An Amiga forum found it in 2008 from my brother’s YouTube uploads and hunted me down for the disks; the thread is still there.
The game has been ported once before, by hand, in 2010. The same team rebuilt it for the iPhone on an engine written from scratch, about 34,000 lines of C++, over months of nights and weekends. Apple and Google featured it, and it reached over two million downloads. That story is here.
I didn’t do this port. I asked for it, played the result every night, said what felt wrong, and made the few decisions that needed somebody who was there in 1993. The file formats and the assembly reading were the AI’s work, and so were the decisions about how to carry thirty-year-old code across, and it went faster than I could follow. This post is what I found when I sat down weeks later and read what had been done to my own game. Some of it was wrong, and I didn’t notice for weeks.
Why I tried again
I’d tried this before. About a year ago I gave an earlier model the same Amiga material and asked it to make sense of my binary level maps. It got there in the end, but it took several rounds and a lot of hints from me.
Then Claude Fable 5 shipped, and I gave it the same files.
The test was deliberate. My guess was that there is little Amiga assembly code in LLM training sets. If the model was better at working things out rather than recalling them, this is where it would show.
The July 4th weekend was coming up, so I planned three steps, each one conditional on the previous working.
Step one, the safe ask: my own 2010 engine, the 34,000 lines of C++, moved into Godot 4. This was the control.
Step two, the unfair ask: the original 72,758 lines of 68000 assembly, for a machine that had gone out of production, with no comments to speak of and nothing in common with the C++. Rebuild that in Godot too, at the Amiga’s original 50 Hz.
Step three, the greedy ask: put the second one inside the first, so buying the modern game gets you the 1993 original as a second thing you can launch.
All three worked. The level format that had taken several rounds and my corrections a year earlier came out in a single pass, with no hints from me.
How it was run
I ran it in Claude Code, so it had a terminal and my filesystem. It could edit files, run the assembler, build the game, launch the game and read what came back. When I say below that it rebuilt my 1993 binaries and checked them, it did that by running vasm and diffing the output.
Early on it added a set of command-line flags to the game so it could play without me:
--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 Which turns “does the jump feel right” into something a machine can read:
drive[btw_jump:2.2] pos=(25.44, 24.04) vel=(0.00, -14.51) ground=false apex_y=22.48 It also had two headless checks it could run before showing me anything: one that compiles every script, and one that builds every level and reports failures. On the Amiga side it drove the real toolchain, vasm to assemble and FS-UAE to boot the result. What wasn’t automated: there was no image comparison on the modern port (it took screenshots, I looked at them), and nothing checked whether the game felt right.
Step one: 34,000 lines of C++ in an evening
Wednesday night, the safe ask. Timestamps, unedited:
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 Twenty-one minutes from empty project to a playable character. Every line it moved that night was a line I’d written, over months, in 2010. I went to bed confused.
Getting it to feel right took about three days after that: jump arcs and trampoline timing, and hit detection that rewards mashing, fixed in batches on July 2nd, 3rd and 4th.
I wasn’t testing alone. My thirteen-year-old son played every build with me. He’s always known I made this game, it’s a fact about his father he grew up with, but he’d never seen me working on it. The testing turned into a father-and-son thing I didn’t plan, and it’s one of my favourite parts of the whole project.
Same units, same tick
All the gameplay state lives in tile units (1.0 = one 48px tile), and the update runs at a fixed 60 Hz, because the 2010 iOS build ran at 60 Hz. That matters because the original applies drag multiplicatively, every frame:
static const float GROUND_DRAG_FACTOR = 0.85f;
this->velocity.x *= GROUND_DRAG_FACTOR; // every tick! Multiply by 0.85 sixty times a second and you get one amount of friction; multiply fifty times a second and you get another. Port it to a different tick rate and every acceleration curve in the game changes. Nothing crashes, it just feels wrong forever, and you won’t find it by reading the diff. At 60 Hz the constant transplants verbatim. This is also why the 1993 rebuild runs at 50 Hz and the modern one at 60: two sets of hand-tuned numbers, each only correct at its own tick. It kept both clocks. I’d have been tempted to tidy them into one.
It didn’t use CharacterBody2D
Godot ships CharacterBody2D and move_and_slide(), and every tutorial tells you to use them. The port used neither for the player. The original has its own hand-written movement code, and rebuilding that on somebody else’s physics would feel slightly wrong in ways that are miserable to track down. The player is a plain Node2D, and the 150-line collision routine came across line for line, including the fudge numbers I picked by feel fifteen years ago and the comments I wrote to my future self:
# 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! Nothing tidied up the stray 0.49. There are no tests and no docs; those comments are the spec.
Step two: the 68000 assembly
By Sunday afternoon, July 5th, I handed over the thing I actually wanted to test. 72,758 lines across 26 files, written for a machine with 512 KB of memory, by me, for me, with the commenting habits of somebody who never expected another person to read it. No documentation. A 2008 transfer to modern storage had shortened every long filename, so every include pointed at names that no longer existed. One of the five level source files is cut off partway through a data table. There’s no other copy.
Before porting anything, it made the 1993 sources assemble again, using vasm on an Apple Silicon Mac, and kept going until the output was byte-identical to the binaries that shipped.
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 Fifteen minutes from a folder of files to the first rebuild that matched the shipped bytes. I wrote these in ASM-One, whose dialect differs from vasm’s in ways that change the bytes: ASM-One encodes cmp #4,d0 as CMPI, vasm picks a different, equally valid encoding, so telling it not to optimise is necessary and not sufficient. Rather than edit my sources it wrote a preprocessing pass that bridges five such differences, and rebuilt the broken filename mapping file by file.
The expensive one was org. With no linker and no relocation, the level source lays out the Amiga’s memory by hand, address by address:
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 The level-one map uses 74,400 of those 74,752 bytes, a margin of 352, and nothing checked it except me, in 1993. (SahamR-grb attaches an object’s behaviour as a named offset; saham is Arabic for arrow.) ASM-One’s org can also move the location counter backwards, which vasm can’t. The first workaround got one case wrong: a ds.b 800 inside a rewound block, which ASM-One treats as “skip 800 bytes”, was written out as 800 bytes of zeros. Everything after that point in the file, the copper list included, sat 944 bytes away from where the shipped binary had it. The game assembled and booted, and drew the wrong thing.
Even after that, some chunks still wouldn’t match, by about 108 bytes scattered through the variable area. Those bytes explained where the shipped files came from. ASM-One assembles into memory, and the game got onto disk by saving that memory out, after the game had been run. So the shipped files are a snapshot of a game that had already been running, not clean assembler output. A fresh assembly has zeros in those variables, because nothing has set them yet; the shipped disk has whatever they held on the machine when it was saved. The code writes them before it reads them, so the zeros are harmless.
At the time I read that line, moved on, and waited for the actual game. It took me weeks to see that this was the most important thing in the project, and that nobody had asked for it. From then on, every claim about this game could be settled by comparing bytes. I wouldn’t have done it myself. I already had the binaries, and in eighteen years rebuilding them from source never seemed worth an afternoon.
The formats
For every format it went to the code that reads the bytes and worked backwards from that. The level loader is 1,652 lines of uncommented 68000, which is why I’d always reached for a hex editor instead.
The levels
A level is a grid of tiles: a long list of numbers, where each number means “put picture 47 here”, in my own private 1993 layout. This is the format the older model and I had ground through a year earlier.
Here is the full set of tiles a level is built from, 256 of them, 16×16 pixels each, for level one:
And a slice of level one, assembled from those tiles:
The input is a list of numbers with no header and no dimensions, inside a compressed chunk. This time I didn’t explain anything. It found the drawing routine, read how the grid was walked, worked out the width and height from constants elsewhere in the file, and produced correct maps for all five levels on the first attempt.
Then it re-rendered each level from its own extracted data and compared the result, pixel by pixel, against full-level captures I had made in 2020. Where they didn’t match, it went looking for the cause and found two copper effects: the sky gradient and the water colour cycle. With those two accounted for: five full-level images, zero differing pixels. Level one alone is 600 tiles wide, 9,600 pixels.
Map cell properties
Drawing the level is only half of what a map cell does. Each cell is one 16-bit word, and the picture is the smaller part of it:
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) The property is the level’s invisible physics. 1 is solid ground. 2 and 3 can be climbed. 10 to 13 all mean “this hurts”, four codes because knockback needs a direction. 14 kills outright. 63 is a door. None of this is written down anywhere. It was recovered because two routines read the same word and each one reveals its own half: the draw loop masks off the low byte, and the collision check does the opposite:
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 hands the painful cases to a label called rmhEnjury, which is 1993 me spelling “injury”.
Those bits were painted in an editor. Before the game could be built I had to build the tool that builds it: MEDITOR.S, 1,254 lines of assembly, dated by its own header, in my 1993 English:
; ***********************************************************************
; * 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 *
; *********************************************************************** Four days in February 1993. Paint tiles with the mouse, pick a property number on the panel’s CURRENT FLAG counter, stamp it onto cells with PUT FLAG, and a flag view marks every cell carrying the selected number. While writing this post, I asked the model to run the map editor and get a screenshot. It assembled the 1993 source with a modern assembler, laid the shipped Level 2 data out in memory where the editor expects it, and booted the result in an emulator.
My own tool at thirty-three years old, editing the real Level 2, flag view on. CURRENT FLAG reads 0001, solid, and the ground you can stand on is marked while the decoration you walk through isn’t. The panel says 1994: the panel artwork is a separate bitmap file the editor loads, and the copy that survived is a later one than the February 1993 code.
The other name on the panel, Udai, was my partner in Mesopotamia Software, which is what we called ourselves. He was building a game of his own at the time. I wrote the editor, for both of us, but its design was worked out between us so one tool could serve both games. His game was never finished.
Object tables
Enemies aren’t in the map. The world is stored one screen at a time, 25 tiles by 20, and every screen has a small table of the objects on it. My 1993 comments explain the markers:
; $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 An enemy is a row of words: a marker, a frame, a position inside its screen, then its behaviour. hiddenwallR-lrb is the crumbling-wall routine, attached as an offset from a base label, the same trick as the arrow thrower earlier. The words after it are parameters that mean whatever that routine wants them to mean. Nothing in the file says which word is which, so it found the routine that walks these tables every frame and let it name the fields, then converted every object in all five levels to world coordinates and checked them against the rendered maps.
GAME.S
Most of the data files scramble their 16-byte headers with a key stored inside the file, a 1993 trick to keep disk editors out. The retail loader, GAME.S, has no unscrambling step at all. It read that as a clue: GAME.S was written before the scrambling was added, so it is an older file. That clue is what later let it recover the lost two-disk retail set, from a sector map inside that same file.
The doors aren’t in the map
I was sure they were.
Load a level’s tile map and there are holes where every door should be, with no door tile in them, open or closed. An 18-byte object record stamps them onto the map at runtime, a 1×4 tile column, from a table:
closed $528 $53C $550 $564 ; solid, blocks the way
open $129 $13D $151 $165 ; passable — exactly one sheet-column right The map data says there’s no door. The level code says there is. For thirty-three years I’d have told you the map is the source of truth and doors are map data, and I’d never have looked. It held both facts, found the routine that reconciles them, and came back with the design: doors are drawn by code at runtime; they were never painted into the map in the editor. That’s why the obvious port of the level data produces a tower with doorways full of sky.
The copper sky
In every level, colour index 31 is the sky, and nothing in the tile art ever paints it. The tile atlas renders it transparent, and behind it the copper repaints the background colour on chosen scanlines to make a vertical gradient. The gradient sits in the level source as a plain list of colours. This is the entire sky of the second level:
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 Read down the list and the sky goes from pale blue to warm near the horizon.
The same 24 words, rendered. Left is the top of the screen.
The first rebuild missed it, and the levels looked fine. Flat, in a way I couldn’t name. The pixel comparison refused to go green, and the gradient went back in.
The diff that would not go green: white is every pixel the first rebuild got wrong, the copper's sky and water.
Sprite sheet ambiguity
Amiga sprite sheets are planar (five separate 1-bit bitplanes in plane-major strips, plus a transparency mask), and all of that was worked out from the draw routines and the org arithmetic. Sheet sizes of the form frames * width * height * 2 * 5 are ambiguous: that 2 could mean double-width frames, or two stacked rows, one per facing direction. Both readings fit every byte in the file. It’s two facing rows; that was my choice in 1993.
Two stacked rows, one per facing direction, drawn frame by frame, not mirrored.
It flagged the ambiguity and asked.
The same twin, the same six frames: 1993 above, 2026 below.
That was the last format. From there the 1993 game went into Godot the same way the C++ had, behaviour rewritten in GDScript at the original 50 Hz.
Step three: the old game inside the new one
The greedy ask took one evening, 21:58 to 23:43. The retro game runs as a guest, with its own namespace and scene host, and the engine switches to 50 Hz on the way in and back to 60 on the way out. It’s fiddly, and it was done in one sitting. I’d assumed the feature would eat a week and get cut. It’s the reason the Steam version ships with the 1993 game inside it.
The same doorway in both games, running in the same program. Left: 1993. Right: 2026.
The game you download contains no Amiga code. The data, the packed chunks and planar graphics and the music, was decoded once, on my machine, by Python scripts, into ordinary PNG, WAV and JSON. The behaviour (how a guard patrols, when a door opens) was rewritten in the engine’s own language. If you want the real thing, that’s the free disk image at the end of this post and an emulator.
Where it was wrong
The guard bug
In level 2 you walk along a corridor. Waterfall to your left, stone pillar ahead. No enemy on the screen, nothing approaching, and you take a hit. What hit you was a spear-carrying soldier standing thirteen tiles above you, on a grass ledge next to a palm tree, with solid rock in between.
He’s a doorman. He shoves whoever stands at his feet. In the original that check is fenced on both sides:
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 The port kept the lower bound and dropped the upper one. A shove meant to cover the guard’s own three rows now ran the whole height of the map column beneath him, through the floor, into a corridor he doesn’t appear in.
The doorman himself, from the 1993 sheet.
Smaller ones: every level has a second tile layer the original never renders; it’s the hidden artwork revealed when a door opens or a fake wall crumbles. Render it “faithfully” and every secret passage stands open from the start. Move enemies before players instead of after, and a trampoline jump gets counted twice, twenty tiles into the air. A door listed "p1,p2,p3,p4", meaning all four palms, was read as a single key with a strange name, and the tutorial exit never opened. A sound-loop length computed as stereo when the effects are mono cut every sound off halfway and restarted it.
The one that cost the most was a feature I asked for in the 1993 build: let the twins swap places at any distance. The proximity check came out, and the statues started corrupting. It went back into the routine and came out with the real answer, which wasn’t what I expected: that check was never a distance limit. The idle twin is stamped into the map itself as a statue, and two statues stamped on top of each other eat each other’s tiles. The guard went back in, and I killed the feature on the 1993 build.
I made the same kind of mistake myself in 2010, slowly, over months.
Then it shipped it
Then it did the release work: screenshots at five pixel sizes in eleven languages, a preview video, store text, icons in six shapes, uploaded to three stores that disagree about everything. I’ve shipped this game before, so I know how many evenings that part costs.
The screenshots come out of the game itself. It launches the real game at each store’s pixel size, in the language it needs, walks the character to a chosen spot, takes the shot, then draws the caption band with the game’s own fonts. AI never renders the text in a store image. The captions are real fonts and real translated strings, or the image doesn’t ship. Once it did break, and the Russian and Korean captions came out as rows of empty boxes, which I saw in the output folder before uploading.
My complaint about Steam is that it has many fields in its forms, many more than the Apple App Store and the Google Play Console. In addition, it doesn’t have an API to make the process of metadata updates easy. For iOS and Android there are proper APIs and it used them. Steam has a web dashboard, so it drove the browser: store page fields, achievements, the demo checklist, artwork uploads, clicking through Steamworks. I do the login, and I press anything that submits, publishes, prices or releases. It fills in the forms.
It reads my reviews too. The official Google Play API only gives you the last seven days, which is useless for a game with fifteen years of reviews, so it pulls the rest with the public scraper, one language at a time. Then it read all of them and listed which ones described real defects. I approved the list.
One of them was a one-star review on Google Play, bad spelling, the kind you scroll past:
“cant get through door on level one. opens but level dowsnt end”
Read literally, it’s a bug report, and it was right. In my game, opening the exit and walking through it are two separate actions, and the prompt that says so exists in all eleven languages, placed in exactly two of my eighteen levels. One of the levels missing it was the last free one. So the player deciding whether this game is worth paying for was standing in front of an open door with no way to know what to do, and concluded the game was broken.
I never found that in fifteen years, and neither did my testers or two rebuilds. It took a stranger’s one-star review. The fix went out as 2.0.3 on both stores. I keep that review.
The trampoline bug
“The trampoline feels too high.” That was the whole report, from me, playing the build at night. The constants checked out: a twenty-line simulation of the original’s integrator predicted 19.1 tiles, and the build measured 19.5. The physics was right.
It was input semantics. The 2010 build was event-driven, and because of a workaround for a tvOS quirk we shipped years ago, a held jump button read as released until you physically pressed again. Godot polls input, and kept reporting the hold. Reproducing that accident is what makes the high bounce need a fresh, well-timed press, which is how the game played on a phone, and what my hands were expecting.
The 2010 source doesn’t record this, because from the source’s point of view nothing unusual is happening. You’d have to have been there, holding the phone, working around a bug in a television.
The original, released
After thirty-three years, the full original is out, free on itch.io. Boot it in FS-UAE, WinUAE, or on real hardware. The Definitive Edition is on iOS and Android now, has a free demo on Steam, and the full Steam release (Windows, Mac, Linux) lands this fall, with the 1993 game inside it as a second launch option.
The port was Claude Fable 5 running in Claude Code; I asked, played, and decided. This post went the same way. I gave it my notes from the port, what I remember about the key parts of the old game (the map encoding, the object tables), and the repos for both the Amiga version and the port, and it wrote a first draft. I spent a week editing it line by line. The code, timestamps and screenshots are real. The part I’m least sure of is the 108 bytes: the model told me the shipped files were a memory snapshot saved after a run, and that the code writes those variables before it reads them. I read that, moved on, and have never checked it myself.
One slice from each of the five levels, rendered from the extracted map data.
Babylonian Twins: Definitive Edition comes to Steam this fall — wishlist it here. Free demo available now · live today on iOS and Android · the original 1993 ADF is free on itch.
Play Babylonian Twins →