1998 年 9 月 2 日,Origin Systems 随《Ultima Online:Second Age》扩展包发布了一款特殊的独立演示程序 ——UoDemo.exe。这个仅 30MB 左右的可执行文件内部封装了完整的客户端与服务器代码,虽然地图被精简为 Ocllo 岛屿,但底层运行的是 1998 年 6 月 2 日从生产服务器提取的真实游戏逻辑。这一转瞬即逝的 Demo 版本在此后二十余年间成为全球 MMO 服务器模拟器的参考范本,却从未有人完整逆向其二进制代码。2026 年 5 月,经过十年断断续续的努力,开发者 draxinar 终于完成了约 5000 个函数的彻底反汇编与 C99 重写,为我们揭开了这款 25 年前 MMO 服务器的技术内幕。
二进制分析的方法论
逆向工程如此庞大的二进制文件需要极其严谨的工程化流程。draxinar 采用 radare2 作为反汇编引擎,配合从 UO 客户端 1.25.37(该版本附带 C++ 符号信息)的实验性 Linux 移植版中提取的符号名,逐步还原了服务器端的函数命名空间。每一个函数都经过手工翻译为 C99 代码,保持与原始 x86 机器码完全相同的控制流结构、struct 布局和分支逻辑。翻译完成后,重新使用 radare2 对 C 代码编译出的二进制进行反汇编,与原始二进制进行逐指令对比,只有完全匹配才能标记为完成。这种近乎偏执的验证机制确保了重构代码与原始实现的行为一致性。
原始 UoDemo.exe 由 Microsoft Visual C++ 5.0(Visual Studio 97)编译,面向的是 C++98 标准之前的 C++ 方言。这意味着代码中不存在模板、异常处理或运行时类型识别等现代特性,全部依赖虚函数表实现多态。类层次结构的确定是整个逆向工程的基石:基类 CEntity 仅占用 0x10 字节,其子类 CResourceEntity 扩展到 0x1C 字节,继续继承形成 CItem(0x50)、CContainer(0x5C)、CMobile(0x37C)直至 CPlayer(0x458)。这种逐层扩展的内存布局直接反映了原始开发者的面向对象设计思路,也为后续所有函数翻译提供了上下文锚点。
网络协议的分层设计
UO 的网络协议采用经典的客户端 - 服务器持久连接模型,区别于现代 MMO 常见的无状态 HTTP 接口。客户端登录后建立的 TCP 连接贯穿整个游戏会话,服务器通过维护套接字与玩家实体的映射关系来处理数据包路由。协议栈的底层是固定长度的包头,包含数据包长度、序列号和加密校验字段;上层则是变长的消息体,使用基于类型的解码器分发到不同的处理函数。
值得注意的是,原始 Demo 服务器仅支持客户端 1.25.33,而完整的重构版本实现了对从 1.25.30 到 5.0.9.1(2007 年 3 月 27 日)的所有版本兼容。这其中涉及五种完全不同的加密机制,每一种都必须在客户端二进制中单独逆向。服务器端需要根据客户端版本协商选择合适的加密算法,并在握手阶段完成密钥交换。这种版本分层策略在当时的商业游戏中相当普遍,但如此细粒度的协议兼容性在开源社区中仍属罕见。
数据包类型涵盖了登录认证、角色移动、物品操作、 NPC 交互、世界状态同步等几乎所有游戏功能。每一个数据包都对应服务器端的一个处理函数,函数签名通常包含客户端标识、数据缓冲区和上下文参数。由于原始代码大量使用内联展开以优化性能,许多微小的操作被直接编码在调用站点,这给逆向工程带来了额外的挑战 —— 必须准确识别这些内联模式并通过辅助函数抽象,同时保证辅助函数的展开结果与原始机器码完全一致。
游戏状态机的实体层次
实体系统是 MMO 服务器的核心,UO 采用了典型的层次化继承结构来管理游戏世界中的所有可交互对象。最底层的 CEntity 仅包含全局唯一标识符和基础属性标志;CResourceEntity 添加了资源相关的数据字段,如刷新时间戳和消耗计数;CItem 进一步扩展了物品特有的属性,包括物品类型、数量、耐久度和所属容器;CContainer 继承自 CItem,额外维护了一个动态增长的子对象列表,用于实现背包、箱子等容器功能。
CMobile 代表所有具有移动能力的实体,包括玩家角色和 NPC。其 0x37C 字节的内存布局中包含了坐标信息、移动目标、动画状态、战斗属性和 AI 状态机引用。CPlayer 作为 CMobile 的子类,进一步添加了账号关联、技能进度、背包配置和社交关系等玩家特定数据。这种继承层次的设计使得服务器可以用统一的指针类型引用不同层级的实体,在需要时通过虚函数表动态分派到具体实现。
虚函数表的布局是逆向工程中的关键线索。draxinar 通过分析二进制中的虚函数调用模式,确定了各个 vtable 槽位的语义:vtable [0x18] 对应 IsPlayer 方法,用于区分玩家角色与其他移动实体;vtable [0xD0] 对应 IsMobile 方法,判断实体是否具有移动能力;vtable [0xE4] 对应 IsNPC 方法,识别非玩家控制的角色。这些方法在服务器端的 AI 决策、权限检查和状态同步中频繁被调用,其返回值的正确性直接影响游戏逻辑的执行路径。
持久化与生态系统的重建
原始 Demo 的数据持久化采用了简化的文件格式,地图仅覆盖 Ocllo 岛屿,大量生产服务器特有的数据结构被 stub 或删除。最显著的例子是 spawn 系统和 decay 系统:相关代码完整存在于二进制中,但没有任何调用站点指向它们,很可能是在 Demo 发布时被刻意禁用的。通过孤立逆向这些函数并重新接入调度系统,可以恢复其完整功能。类似地,原始的动态门、告示牌、装饰物、传送器、陷阱、箱子和生成点数据也仅存在于部分区域,需要编写完整的工具链来解析和扩展这些数据格式。
最令人惊喜的发现是传说中的 ecology 系统仍然存在于代码深处。这个由 Raph Koster 设计的生态系统包含捕食者、猎物和清道夫三种角色类型,理论上可以实现狼追兔子、乌鸦觅食等动态生态行为。虽然原始 Demo 发布时该系统已被禁用,但 draxinar 成功重新接入了调度逻辑,使得这一消失二十余年的特性得以重现。当然,由于原始资源数据文件的缺失,完整的资源生产和消耗系统无法精确还原。
现代平台的适配考量
原始二进制为 32 位 x86 架构,而重构项目默认编译为 64 位。这一转换并非简单的指针宽度调整,因为类继承层次中嵌入的 struct 布局必须保持二进制兼容。当 CMobile 指针被传递给期望 CContainer 指针的函数时,继承自 CContainer 的字段必须位于 CMobile 内存布局的正确偏移位置。64 位平台上的指针宽度增加会改变这些偏移量,因此代码中刻意插入了一些填充字段,以确保在 32 位和 64 位两种编译模式下,虚函数表和继承字段的布局都能与原始二进制匹配。
客户端加密支持的扩展同样涉及底层协议的适配。五种不同的加密机制分别对应 UO 发展的不同历史时期,从最初的简单 XOR 到后来的复杂流密码,每种机制都需要在服务器端准确模拟客户端的加密行为。加密协商发生在登录流程的早期阶段,服务器必须根据客户端发送的版本号和加密标志选择正确的算法参数,否则后续所有数据包都会因解密失败而无法处理。
实践启示与参数参考
对于试图复现类似 MMO 服务器架构的开发者而言,UO Demo 的逆向成果提供了若干可落地的技术参数。在网络层面,建议采用基于 TCP 的持久连接模型,包头使用 2 字节长度字段加 1 字节序列号加 4 字节校验的结构,消息体根据类型字段分发到独立处理函数。在实体管理层面,应设计类似的层次化继承结构,确保基类指针可以在不进行类型转换的情况下调用子类特有方法,这通常需要虚函数表的精心布局。在持久化层面,可以参考 UO 的分片存储策略,每个地图区域对应独立的数据文件,玩家数据定期 checkpoint 到内存快照。
该项目的测试服务器目前运行于 uo.serpent-isle.com,任何人都可以加入体验这个忠于 1998 年原始实现的复刻版本。代码和数据已完全开源,任何人都可以查阅那 5000 个逐指令验证过的函数实现,以及其中标注的原始 bug 和修复补丁。这不仅是游戏史上的一次重要抢救,也为当代分布式系统开发者提供了一扇观察上世纪末 MMO 架构设计的独特窗口。
资料来源:draxinar.github.io 项目发布的《Reverse-engineering the 1998 Ultima Online demo server》