Show HN:历经十年逆向工程,我让 DOS 版 Wing Commander 支持双人合作
Show HN: After a decade of reversing, I got co-op working in DOS Wing Commander
开发者 danielrh 用十余年逆向 DOS 版 Wing Commander 的内存结构,实现了双人合作模式,让玩家可以各驾一架战机并肩作战。他通过 DOSBox 内存快照对比定位到飞船坐标、速度与旋转向量所在地址,最终找到 0x3D 处的相机槽位等关键数据。该项目可在浏览器中直接游玩。
11年旅程:一起玩 DOS 版《银河飞将》
作者:danielrh
小时候,我和哥哥 Patrick 会一起驾驶我们的太空战机,一个人负责操控,另一个人负责激光和加力推进。我们一直梦想着能作为僚机合作游玩《银河飞将》系列,而不是像双头蛇一样挤在同一架战机里。
起初,我们在寻找自己身处何方的线索
2015 年 10 月 4 日,当我在 Dosbox 里玩经典的《银河飞将》时,我意识到整个内存大概都能装进那 640K 里——比尔·盖茨曾有一句被误传的名言说“对任何人来说都足够了”。如果我能转储内存并找出存放玩家和僚机 X、Y、Z 坐标的位置,我们就能让网络数据包来控制僚机。
原话是
“我得说,在 1981 年做那些决定时,我觉得自己为未来 10 年提供了足够的自由。也就是说,从 64K 到 640K 的跨越感觉能持续很长一段时间。嗯,结果并没有。”来源
这给我们提出了一个明确要攻克的问题:在不到一百万个字节中,我们如何定位那个位置?我决定通过尝试清空游戏中的其他实体来简化问题。静止的玩家不应该改变坐标。我们可以在静止时对内存做两次快照,以识别“背景”内存变动。然后向前猛冲再测量一次。如果我们不转向(鼠标已禁用),可能只会改变一个轴。下一个问题是找到一片空旷的太空区域。我们必须在不带僚机 Paladin 的情况下出发,因为他的位置会跟着我们移动。在一次悲惨的任务中,让邪恶的太空猫压倒了 Paladin,然后跑回飞船母舰“利爪号”,下一次任务就为穿越空旷太空的飞行做好了准备。我起飞后径直向前飞,直到所有东西都消失,然后用 DOSBox 的 MEMDUMPBIN 调试命令拍摄了内存快照。

现在我得找出在内存快照之间没有变化的东西——那时我的星际战机还在原始位置——以及当我的星际战机移动时发生变化的东西。
(快照原始 ⊙ 快照之后) ∧ (快照原始 ⊕ 快照移动后)
数据段中有 35 个不同的字节簇发生了变化。通过目视检查这些簇,并在我移动时寻找浮点数或小整数的变化,我将范围缩小到了十几个可能的位置。我还切换到追尾摄像机视角,这帮助我把范围缩小到了 13D3:A9C2。我用 DOSBox 调试器的 SM 命令在该地址添加整数,结果飞船猛地向前跳到了一个新位置。
这项技术还识别出了 CAE2 处的速度,因为当飞船停止时,有三个 32 位整数归零。

我用内存编辑器开着飞船到处跑,玩得很开心,并在利爪号和其他 NPC 飞船周围复现了这一效果。
然后我们寻找我们所面对的东西
我把它搁置了将近一年,但在2016年9月,我决定写一个小工具来自动比较DOSBox快照,而不是只使用vbindiff图形界面。方向性结果证明更加困难,所以我又把这个问题搁置了几个月,并在11月重新拾起。

为了找到旋转的内存位置,我需要更仔细地查找。碰巧的是,这些向量由4字节标量组成,其中256表示1.0,有效的正向量位置在0到255之间。这意味着大多数小旋转只改变一个字节。然而,那天幸运女神对我微笑,因为当时看起来像是程序员的玩笑。Wing Commander为实体属性保留了64个槽位。用十六进制表示,这将跨越从0x00到0x3F的数字。然而程序员决定使用0x3F作为临时用途,并将摄像机放在0x3D。由于3D图形仍处于起步阶段,程序员可能认为每次需要操作3D摄像机时写入0x3D是个大笑话。这使得一个临时变量距离摄像机变量两个槽位。而向上和向前也彼此相邻。这提供了一个很好的宽阔区域,我可以在其中定位那些单字节变化。我理解的第一个字节是右向量的y;我围绕它放置的打印窗口恰好覆盖了摄像机的向上副本,这就是覆盖实验开始的地方。此外,我翻滚了飞船,触发了符号扩展,导致所有3个相邻字节从0x00切换到0xff。在这两个事实之间,我注意到了十六进制转储中的临时字节和摄像机字节。写入它们没有帮助,因为它们每一帧都被覆盖,但幸运的是我也向下一个向量写入了一些额外字节,即forward[0]。这导致了星场的明显偏移,意味着我现在可以开始调查代码了。

我在这些地址上设置了内存观察点,并追踪了由每艘飞船物理函数调用的向量加法函数,该函数由每帧函数调用,最终到达主循环。
现在是时候在IDA中定位字节序列并开始注释游戏了。Wing Commander多人游戏现在只是重复这些技术,并弄清楚如何从另一艘飞船的输入来操纵一艘飞船。
然后我们搜索了如何行动
2016年12月,我原本以为仅靠对全局内存的写入就能驱动《银河飞将》,但这个幻想破灭了。要呈现激光射击或热寻导弹的细节,仅靠地址来操控实在太过复杂。我们需要借用原游戏中的函数本身,才能获得最精确的体验。最初的一个想法是“引导”程序计数器到正确的位置,但我们需要一种方法来插入参数并从原处返回。此外,许多函数位于各种覆盖模块中,调用它们需要间接寻址。看来Borland为每个编译单元(C++文件)设置了不同的覆盖模块,并且它们会根据玩家之前的路径动态加载到不同位置。
Patrick和我必须设计一套良好的跳板系统,以便用正确的参数调用函数。我们选择了13D3:0187处的“Loading WING COMMANDER”字符串,并最终选用13D3:0395“Sorry, an error has occured while %s”[原文如此]来承载跳板。
我们会让DOSBox将CS:IP压入栈,然后将指令指针重定位到上面的字符串处。
00 terminates any string pointing here. pushf; pusha the game is mid-frame; leave registers as we found them xor si,si; push si argument: ship 0. Later thunks use mov si,imm16 for each argument call far 12D7:0156 through the stub, so it works wherever the overlay is loaded pop si; popa; popf cdecl: caller cleans the arguments retf back to the CS:IP DOSBox pushed
我们欣喜地注意到,这使激光炮激活了。
2017年3月,我们在主循环中找到了一个可预测的位置(0560:20E3)来挂钩,这样就不必担心处于游戏中不可预测的部分;7月,我们定位到了do_damage函数,并阻止游戏以副玩家身份调用它。
然后我们尝试从远处汇集各个字段
2017年8月,我们编写了网络协议,这是一个简单的TCP客户端/服务器架构,使用protobuf游戏消息。每一帧,主机都会发送每艘飞船的位置,以及开火事件、消失和伤害。客户端会回复自己的位置、是否发射了激光,或者是否发生了灾难性事件(弹射、着陆、死亡)。
每个玩家将自己的飞船作为实体0,另一名玩家作为实体1,这在接收来自另一模拟器的数据包时需要交换。
这些数据包直接修改内存,或被添加到该帧必须发生的事件任务队列中。
我们从一个非常简单的跳板开始:
pushf pusha nop popa popf retf
当Borland的覆盖加载器换入一个代码页时,它会遍历栈上保存的BP帧链,并修补任何指向覆盖模块的返回地址。 手工制作的存根没有帧,因此遍历会读到垃圾数据,游戏会随机时刻崩溃。从10月到12月,这始终是我们的心头之患。
解决办法是让跳板看起来像一个Borland函数:
push bp mov bp, sp nop (0x90) ; welcome to the 0x90's pop bp retf
我们配置DOSBox,使其在程序计数器到达nop时注意到,并从应发生事项的任务队列中注入其调用。任务队列基于在多玩家场景中使游戏正常运行所必需的事项。
2017年10月,我们注意到临时对象在不同玩家那里会位于不同的槽位。借助DOSBox提供的新钩子,我们还能判断函数何时返回,从而记录下新飞船或投射物被生成到了哪个数字槽位。
临时对象有各自的生成规则,并且可以占用61个实体槽位中的共享区域。我们修改了临时对象的放置位置,将它们限制在槽位12到15,以保持多人游戏中的位置一致。
12月,我们决定修改客户端,让它存储服务器飞船ID到本地ID的映射,这样就不再需要强制生成函数返回。这使得数据包能够在本地ID和主机ID之间进行转换。终于,小行星在多人游戏中开始正常工作了。
2018年,我们专注于爆炸、导弹和自动驾驶的动画制作。
对成功与失败条件的探索仍在继续
2019年6月,我们注意到任务胜利的计算既不稳定又繁琐。我们决定进行二分查找,以定位实际胜利点计数的位置。我们通过网络复制了整个64K数据段,然后缓慢地将其对半切分。经过多次二分后,我们定位到了13D3:D248处的80字节,其中保存着任务树。到了10月,我们找到了位于13D3:C280的总胜利点计数器,以及位于13D3:C255和C256的任务编号,这使得多年之后我们能够通过下拉菜单选择要执行的任务。
以及最终组合词语的乐趣
2020年初,我们找到了播放通讯文本的函数。这使我们能够通过网络发送聊天信息,并将输入的聊天直接接入系统,使其通过僚机的VDU播放。这提供了一种极具沉浸感的聊天方式,让我们想起了游戏中一些狂乱的场景。

2020年的一些收尾工作让游戏达到了可玩状态,但我们对于如何真正与朋友分享这份乐趣已经无计可施。指望人们找到那款老游戏的副本、编译或下载一个奇怪的DOSBox版本、互相分享公网IP、打穿防火墙然后一起游玩,遗憾的是这太难实现了。
我们进行过几次家庭通关,但游戏一直搁置在GitHub上,基本处于闲置状态。
而在2026年,机器辅助开发帮助我们一同欢笑
直到现在。随着WebRTC的普及、emscripten的稳健支持,以及我们构建的一个名为lobbylink的新系统,时机似乎已经成熟,可以重启这个项目并更广泛地分享它。
lobbylink
我构建了https://github.com/danielrh/lobbylink,这是一个简单的golang大厅系统,仅依赖一个websocket库,用于协调点对点WebRTC连接。它可以在任何小型服务器上运行,只需让玩家通过WebRTC和共享的房间代码相互连接。服务器有一个静态的来源允许列表,可以让几乎任何语言的浏览器或客户端软件通过房间代码连接进来。房间代码用于定位具有相同房间代码的其他玩家,他们会收到该房间所有加入者的通知。服务器还运行coturn并在加入回复中生成TURN凭证,这样处于严格防火墙后的玩家也能加入。
我将传输层移植到了 lobbylink,并让一个 LLM 配置构建脚本,使其以 emscripten 为目标,并获取 Wing Commander 的 GOG Inno Setup 安装包,将其解压后用作 emscripten 的数据文件。这些结合起来,使得可以从 GOG 下载 Wing Commander,将其拖放到客户端浏览器存储中,输入房间代码,然后开始游戏。
该游戏可在 https://fireflyk64.github.io/dosbox/ 游玩,模拟器位于 https://github.com/danielrh/wcmulti。
作为额外福利,我还让一个 LLM 添加了语音聊天功能,使用第二个 WebRTC 对等连接来传输音频,以便在游戏中进行实时语音交流,如果人们愿意的话。这样我们就能一起飞行、一起欢笑。
来源:Hacker News · fireflyk64.github.io