1993年,巴格达。我在一台 Amiga 500 上做了一款游戏,叫《巴比伦双子》(Babylonian Twins)。512KB 内存,没有硬盘,直接插在电视上。那时我二十几岁,还是个工科学生。纯 68000 汇编,每一个精灵图、每一条扫描线,全靠手写。美术是 Murtadha Salman 画的,音乐由 Mahir AlSalman 谱曲。当时我们身处制裁之中:没有互联网,没有任何游戏开发资料,手头只有一本《Amiga 硬件参考手册》——我就靠它直接对硬件编程;每天供电也只有几个小时。内存太小,软盘得不停地换来换去,加上 50°C 的酷暑,把我的软驱给毁了。
左边:1993年,Amiga 上的画面。右边:2026年,同一道城门。
在 Amiga 上,所谓「手写」,意思是游戏一旦跑起来,就不再向操作系统要任何东西。启动时它先保存中断向量,关掉操作系统的中断,然后接管整台机器:
「Get the system from the AMIGA」是我1993年写下的注释。从这一刻起,画面就由游戏自己的 copper list(Amiga 那颗可编程视频协处理器的指令列表)来接管,为了精灵图和天空的颜色,它会被实时改写。图块的移动,是直接写 blitter 的寄存器、再等它的完成标志位。手柄读的是硬件端口,开火键就是 CIA 芯片上的一根引脚。操作系统只在关卡之间回来一趟,从磁盘里读下一关的文件,读完再次关掉。
它是伊拉克第一款商业游戏,也在很长一段时间里,几乎没人玩得到。Commodore 倒闭了,制裁又把发行商吓跑了,做完的游戏就这么被搁在架子上。直到2008年,一个 Amiga 论坛从我弟弟传到 YouTube 的视频里翻出了它,然后满世界找我要那几张软盘;那个帖子到现在还在。
这款游戏,终于面世了。1993年的原版磁盘可以在 itch.io 上免费拿到;而本文要讲的重制版——终极版(Definitive Edition)——现已登陆 iOS 和 Android,今年秋天还会上 Steam。
这款游戏此前还被手工移植过一次,在2010年。还是原班人马,为 iPhone 从零写了一套引擎重做了它,约 34,000 行 C++,用掉了好几个月的夜晚和周末。苹果和谷歌都给了推荐位,下载量超过两百万。那段故事在这里。
这个移植,不是我做的。我只负责提要求、每晚把成品玩一遍、指出哪里不对劲,再拍板那几个必须由「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 输出。
一上来它就给游戏加了一组命令行参数,好让它不用我在场也能自己玩:
于是「这一跳手感对不对」,就变成了机器能读懂的东西:
它手里还有两个无头(headless)检查,可以在拿给我看之前先跑一遍:一个编译所有脚本,一个构建所有关卡并报告失败。Amiga 这边,它直接驱动真实工具链——vasm 负责汇编,FS-UAE 负责启动结果。没自动化的部分是:现代版移植没有图像比对(它截图,我用眼睛看),也没有任何东西能检查这游戏「手感对不对」。
周三晚上,我提了个稳妥的要求。时间戳原样奉上,未做任何删改:
从空项目到一个能跑能跳的角色,只用了 21 分钟。那晚它搬动的每一行代码,都是我 2010 年花了好几个月一行行敲出来的。我带着满脑子问号睡了。
但要让手感对味,之后还花了大约三天:跳跃弧线、蹦床时机,还有那种「越按越爽」的判定手感,分批在 7 月 2 日、3 日、4 日修好。
测试不是我一个人。我 13 岁的儿子陪我玩了每一个版本。他一直知道这游戏是我做的——这是他从小就熟悉的、关于父亲的一个事实,但他从没见过我动手做它。测试意外变成了一件父子共同参与的事,这是整个项目里我最喜欢的部分之一。
所有玩法状态都用「格子单位(tile units)」存储(1.0 = 一个 48px 的格子),更新逻辑固定跑在 60 Hz,因为 2010 年那版 iOS 就是 60 Hz。这一点很关键,因为原版的阻力是「每一帧乘一次」的:
一秒钟乘 60 次 0.85,是一种摩擦力;一秒乘 50 次,就是另一种。换个 tick 频率移植过去,游戏里每一条加速度曲线都会变。什么都不会崩,它只是从此手感就不对了,而且你翻 diff 是翻不出来的。在 60 Hz 下,那些常数可以原封不动地移植。这也是为什么 1993 年那版重建跑在 50 Hz、现代版跑在 60 Hz:两套手工调出来的数值,各自只在自己的 tick 频率下才成立。它把两套时钟都保留了下来。换我,多半会忍不住把它们「整理」成一套。
Godot 自带 CharacterBody2D 和 move_and_slide(),每篇教程都叫你用它们。这次移植,玩家角色一个都没用。原版有自己手写的移动代码,把它架在别人的物理引擎上,手感会有那么一点不对——而这种不对,查起来能让人生不如死。玩家就是一个朴素的 Node2D,那 150 行碰撞逻辑一行不差地搬了过来,包括我 15 年前凭手感拍出来的那些「凑数」数字,以及我写给未来的自己的注释:
没有人去「顺手收拾」那个孤零零的 0.49。这里没有测试,也没有文档;那些注释就是规格说明。
7月5日周日下午,我把真正想拿来测试的东西交了出去。26个文件,72758行代码,写给一台只有512 KB内存的机器,我写的,只给我自己看——注释习惯完全是那种压根没想过会有第二个人来读的风格。没有文档。2008年那次转存到现代存储介质的操作,把所有长文件名都截短了,于是每一条include指向的名字都已经不存在了。五个关卡源文件里,有一个在某张数据表中间就被硬生生截断了。而这些,没有第二份备份。
在动手移植任何东西之前,它先让1993年的这套源码重新能汇编,用的是Apple Silicon Mac上的vasm,而且一路死磕,直到输出跟当年发行的二进制文件逐字节完全一致。
从一堆文件到第一次重建产物与发行字节完全吻合,只花了十五分钟。这些代码我当年是用ASM-One写的,它的方言跟vasm有差异,而且这些差异会改变字节:ASM-One把cmp #4,d0编码成CMPI,vasm则会选另一种同样合法的编码,所以「关掉优化」这一步是必要的,但远远不够。它没有去改我的源码,而是写了一个预处理环节,桥接了五处这类差异,再逐个文件把断掉的文件名映射重建起来。
真正难啃的是org。既没有链接器(linker),也没有重定位(relocation),关卡源码是手工排布Amiga内存的——一个地址一个地址地排:
第一关的地图用掉了74752字节里的74400字节,余量只有352,而这件事除了1993年的我,没有任何东西检查过。(SahamR-grb把一个对象的行为挂成一个具名偏移量;saham在阿拉伯语里是「箭」的意思。)ASM-One的org还能把位置计数器往回倒,而vasm做不到。第一版变通方案搞错了一个情况:一个位于回退块内部的ds.b 800,在ASM-One里是「跳过800字节」的意思,结果被写成了800个零字节。文件里那个位置之后的一切,包括copper list在内,全都跟发行版二进制里的位置偏了944字节。游戏能汇编、能启动,画出来的东西却是错的。
即便解决了这些,还是有一些块对不上,大约108个字节,零散分布在变量区里。而正是这些字节,揭示了当年发行文件的来历。ASM-One是把程序汇编进内存的,而这个游戏之所以能上盘,是在游戏跑过一遍之后,把那块内存原样存了出来。所以发行文件是一个「已经运行过的游戏」的快照,而不是干净的汇编输出。全新汇编出来的产物,那些变量位置是零,因为还没有任何东西给它们赋过值;发行盘上则是保存那一刻机器里它们恰好持有的内容。代码在读这些变量之前会先写它们,所以那些零无伤大雅。
当时我扫了一眼那句话,翻过去了,接着等真正的游戏跑起来。过了好几个星期我才反应过来:这才是整个项目里最重要的东西,而且根本没人要求它这么做。从那一刻起,关于这款游戏的任何说法,都能靠比对字节来一锤定音。这活儿换我自己是不会干的。二进制文件我早就有了,十八年里,从源码重新构建一遍,我从来没觉得值得搭上一个下午。
每遇到一种格式,它都直奔读取字节的那段代码,然后倒推回去。关卡加载器是 1652 行不带任何注释的 68000 汇编——这也正是我一直宁可掏出十六进制编辑器的原因。
一个关卡就是一张图块网格:一长串数字,每个数字的意思是「把 47 号图片放这儿」,用的是我 1993 年自创的私有布局。这个格式,我和上一代模型一年前就死磕过一轮。
下面是构成一个关卡的全部图块,共 256 块,每块 16×16 像素,来自第一关:

以及用这些图块拼出来的第一关的一小段:

输入是一串数字,没有文件头,没有尺寸信息,还塞在一个压缩块里。这一次我什么都没解释。它自己找到了绘制例程,读懂了网格是怎么遍历的,从文件别处的常量里推算出宽和高,然后一次成功,跑出了全部五个关卡的正确地图。
接着,它用自己提取出的数据把每一关重新渲染了一遍,再和我 2020 年做的整关截图逐像素比对。对不上的地方,它去追原因,揪出了两处 copper 特效:天空渐变和水面调色循环。把这两处算进去之后——五张整关图像,零像素差异。光是第一关就有 600 个图块宽,也就是 9600 像素。
画出关卡,只是一个地图单元格干的一半活儿。每个单元格是一个 16 位字,而图片信息只占其中较小的那部分:
属性位是这一关看不见的物理规则。1 是实心地面。2 和 3 可以攀爬。10 到 13 都表示「这会掉血」,之所以要四个编码,是因为击退需要方向。14 是直接致死。63 是门。这些东西哪儿都没写下来。它们能被还原出来,是因为有两个例程读的是同一个字,而每个例程各自暴露了它的那一半:绘制循环把低字节屏蔽掉,碰撞检测则反过来:
Checkrmh 把「会掉血」这几种情况交给一个叫 rmhEnjury 的标签处理——那是 1993 年的我把 injury 拼错了。
这些属性位是在一个编辑器里刷上去的。想构建这款游戏,我得先把构建它的工具造出来:MEDITOR.S,1254 行汇编,日期就写在它自己的文件头里,用的是我 1993 年的英文:
1993年2月的四天。用鼠标刷地砖,在面板的 CURRENT FLAG 计数器上选一个属性编号,再用 PUT FLAG 把它盖到格子上,然后旗标视图会把所有带这个编号的格子标出来。写这篇文章时,我让模型跑一下地图编辑器,截个图。它用现代汇编器把1993年的源码汇编出来,把随游戏发行的第2关数据按编辑器期望的样子布进内存,然后在模拟器里把成品启动了起来。

我自己写的工具,三十三岁高龄,正在编辑真正的第2关,旗标视图打开。CURRENT FLAG 显示 0001,实心;能站上去的地面被标了出来,而能穿过去的装饰物没有。面板上写着1994:面板美术是编辑器另外加载的一个独立位图文件,而幸存下来的那份,比1993年2月的代码要晚。
敌人不在地图里。世界是一屏一屏存的,25×20格地砖,每屏都带一张小表,记录这屏上有哪些对象。我1993年写的注释解释了这些标记:
一个敌人就是一行字(word):一个标记、一个帧、它在本屏内的位置,然后是它的行为。hiddenwallR-lrb 是那段碎墙例程,以相对某个基准标签的偏移量挂上去——跟前面那个扔箭的家伙是同一个套路。后面的字是参数,含义完全由那段例程说了算。文件里没有任何地方标明哪个字是什么,所以它去找到那段每帧遍历这些表的例程,让例程自己给字段命名,然后把全部五关里的每一个对象都换算成世界坐标,跟渲染出来的地图逐一对照。
大多数数据文件都会用存在文件内部的密钥把自己16字节的头打乱,这是1993年防人用磁盘编辑器乱翻的招数。零售版加载器 GAME.S 里压根没有解扰这一步。它把这一点读成了线索:GAME.S 是在加扰机制加上去之前写的,所以它是个更老的文件。正是这条线索,让它后来从同一个文件里的一张扇区表出发,把遗失的双盘零售版给找了回来。
加载一关的地砖地图,你会发现每扇门该在的地方都是窟窿,里面没有门的地砖,开的关的都没有。运行时由一条18字节的对象记录,按一张表把它们盖到地图上,一列1×4的地砖:
地图数据说:这儿没门。关卡代码说:有门。要是搁在过去三十三年,我会拍着胸脯告诉你——地图才是唯一的真相,门就是地图数据,我压根不会去看第二眼。可它把这两个自相矛盾的事实都攥在手里,翻出了那段调和二者的例程,然后带回了真正的设计:门是代码在运行时画出来的;它们从来就没有在编辑器里被画进地图。这才是为什么,你老老实实照搬关卡数据,移植出来的塔楼门洞里塞满了天空。
每一关里,颜色索引 31 都是天空,而图块美术从来不去涂它。图块图集把它渲染成透明,透明的背后,copper 在选定的扫描线上重刷背景色,拼出一条竖直渐变。这条渐变就明明白白躺在关卡源码里,是一串朴素的颜色列表。下面这些,就是第二关的整片天空:
顺着列表往下读,天空就从淡蓝一路走到地平线附近的暖色。
同样这 24 个字(机器字),渲染出来的样子。左边是屏幕顶端。
第一次重建漏掉了它,关卡看上去也没毛病。就是平,平得我说不出哪儿不对。像素比对死活不肯变绿,于是渐变又被放了回去。
那张怎么都不肯变绿的 diff:白色的部分,全是第一次重建做错的像素——copper 画的天空和水面。
Amiga 的精灵图(sprite sheet)是平面式的(五张独立的 1 位位平面,按 plane-major 分条排列,外加一张透明遮罩),而这一切,全是从绘制例程和 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。至于行为逻辑(守卫怎么巡逻、门什么时候开),是用引擎自己的语言重写的。你要是想玩原汁原味的,本文末尾有免费的磁盘镜像,配个模拟器就行。
第 2 关,你走在一条走廊上。左边是瀑布,前面是石柱。屏幕上没有敌人,也没有任何东西靠近——然后你就挨了一下。打你的,是一个拿长矛的士兵。他站在你头顶上方十三格的位置,脚下是草地平台,旁边一棵棕榈树,中间还隔着实心岩石。
他是个「门卫(doorman)」。谁站在他脚下,他就推谁。在原版里,这个判定是两头都设了边界的:
而移植版只保留了下界,把上界丢了。本来只覆盖守卫自己那三行的推撞判定,现在顺着他底下那一整列地图往下贯穿,穿过地板,一路捅进一条他根本不会出现的走廊里。
这就是门卫本尊,出自 1993 年的那张图集。
更小的坑还有一堆:每一关都有第二层图块(tile),原作从来不渲染它——那是藏起来的美术资源,只有开门或假墙碎裂的瞬间才露脸。你要是「忠实」地把它渲染出来,所有暗道从开局就大敞四开。再比如,把敌人的移动放在玩家之前而不是之后,蹦床跳就会被算两次,人直接弹上二十格高。还有一扇门写着 "p1,p2,p3,p4",意思是四棵棕榈树全都算数,结果被当成了一个名字很怪的单一钥匙,教学关的出口死活打不开。音效循环长度按立体声算,可音效其实是单声道,于是每个声音都在半路被掐断,然后重头再来。
代价最大的那次,是我在 1993 版里点名要的一个功能:让双胞胎在任意距离都能换位。距离检测被拿掉了,然后雕像开始损坏。它又回到那段例程里重跑一遍,给出了真正的答案——而这答案出乎我的意料:那个检测压根就不是什么距离限制。待机的那个双胞胎会被直接「盖章」进地图本身、变成一座雕像,而两座雕像叠在同一个位置,就会互相吞掉对方的图块。防护判断被加了回去,1993 版的这个功能,我砍了。
同样性质的错误,我自己在 2010 年也犯过一次——只不过是慢慢犯的,前后拖了好几个月。
接着它把发布这摊活儿也干了:十一种语言、五种像素尺寸的截图,一支预览视频,商店文案,六种形状的图标,再上传到三家在所有细节上都各执一词的商店。这游戏我以前上架过,所以我很清楚——光这一步,得搭进去多少个晚上。
截图全是游戏自己跑出来的。它按每个商店要求的像素尺寸启动真实游戏,切到对应语言,把角色走到指定位置,咔嚓一张,再用游戏自带的字体画上字幕条。商店图里的文字,AI 一个字都没渲染过。字幕必须是真字体、真翻译串,否则这张图就别想上线。有一次真出岔子了,俄语和韩语的字幕全变成一排排空方框——上传前我在输出文件夹里就看见了。
我对 Steam 的不满是:表单字段太多了,比苹果 App Store 和 Google Play Console 多得多。更要命的是,它没有 API 来简化元数据更新。iOS 和安卓那边有正经 API,它就直接调。Steam 只有网页后台,那就开浏览器硬刚:商店页字段、成就、试玩版清单、素材上传,一路点穿 Steamworks。登录我来,凡是涉及提交、发布、定价、上线的按钮,我来按。填表交给它。
它还会读我的评论。Google Play 官方 API 只给你最近七天——对一款有十五年评论积累的游戏来说,等于没用——所以剩下的它用公开爬虫一种语言一种语言地拉。然后它把评论全读了一遍,列出哪些是在描述真实缺陷。名单由我拍板。
其中一条是 Google Play 上的一星差评,错别字连篇,那种你会直接划走的:
「第一关的门过不去。开了但是关卡不结束」
照字面读,这是一份 bug 报告——而且他说得对。在我的游戏里,「打开出口」和「走出去」是两个独立动作,说明这一点的提示文案十一种语言全都有,但只放在我十八个关卡中的两关里。而缺提示的关卡之一,正是最后一个免费关。也就是说,那个正在权衡「这游戏值不值得掏钱」的玩家,站在一扇敞开的门前,完全不知道该干什么,于是得出结论:这游戏是坏的。
十五年了我都没发现,我的测试员没发现,两次重写也没发现。最后是一个陌生人的一星差评把它揪出来的。修复以 2.0.3 版本推送到了两个商店。那条评论我一直留着。
「蹦床感觉太高了。」整份报告就这一句,我自己写的,晚上试玩构建版本时。常数没问题:用二十行代码模拟原版的积分器,预测是 19.1 格,实测构建版本是 19.5 格。物理是对的。
问题出在输入语义上。2010 年那版是事件驱动的,而我们多年前为了绕开 tvOS 的一个怪毛病做过一处 workaround——结果就是:跳跃键你一直按着,系统却读成「已松开」,非得你手动再按一次才算数。Godot 走的是轮询输入,会老老实实一直报告「按住」状态。把那次「意外」原样复刻回来,高跳才需要一次全新的、掐准时机的按压——这才是当年手机上的手感,也是我的手记住的那个手感。
这事儿在 2010 年的源码里一个字都没写。因为站在源码的角度看,什么反常的事都没发生。你得亲身在场,手里攥着那台手机,去绕开一台电视机上的 bug。
三十三年后,完整的原版终于放出来了,在 itch.io 上免费拿。用 FS-UAE、WinUAE 启动,或者直接上真机跑都行。「决定版(Definitive Edition)」现已登陆 iOS 和 Android,Steam 上有免费试玩,完整版(Windows、Mac、Linux)今年秋天上线——1993 年的原版就装在里面,作为第二个启动选项。
这次移植是 Claude Fable 5 在 Claude Code 里跑出来的;我负责提问、试玩、拍板。这篇文章也是同样的流程。我把移植过程中的笔记、我对老游戏关键部分的记忆(地图编码、对象表)、以及 Amiga 版和移植版这两个仓库一并喂给它,它写出了初稿。我花了一周,一行一行地改。代码、时间戳、截图都是真的。我最没底的是那 108 字节:模型告诉我,随游戏发布的那些文件是某次运行后保存下来的内存快照,而且代码在读这些变量之前会先写它们。我看完就翻篇了,从没自己去核实过。
五个关卡各取一片,由提取出的地图数据渲染而成。
更新时间:2026-09-09
本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828
© CopyRight All Rights Reserved.
Powered By 71396.com 闽ICP备11008920号
闽公网安备35020302034844号