OpenRCT2 — 用逆向工程让 20 年前的游戏复活
待复核想象你有一本用只有作者能看懂的速记符号写成的菜谱(x86 汇编),做出来的菜很好吃但你没法改良它——不能加新调料、不能换炉灶、不能让两个人同时做这道菜。OpenRCT2 做的事就是:一群厨师花了十年时间,把这本天书菜谱逐页翻译成人人都能读的普通话(C++),翻译完之后不仅菜的味道一模一样,还能随意加新菜谱、换厨房、甚至开放厨房让朋友一起做。
具体来说,OpenRCT2 是对 2002 年经典游戏《过山车大亨 2》(RollerCoaster Tycoon 2)的完整开源重实现。原版由传奇程序员 Chris Sawyer 几乎全部用 x86 汇编手写,项目创始人 Ted “IntelOrca” John 在 2014 年启动逆向工程,和 600+ 贡献者一起把整个引擎用 C++20 重写。最终产物在行为上高度对齐原版,并加上原版做不到的能力:跨平台、高分辨率、多人在线、JavaScript 插件系统。GitHub 约 14k stars。
项目需要原版 RCT2 的游戏资产文件才能运行——这不是盗版,而是逆向工程只替换了引擎代码,美术资产仍受版权保护。你可以在 Steam 或 GOG 上花几十块买到原版。
不理解 OpenRCT2 的工程实践,下面这些事都没法解释:
- 为什么逆向工程是让老软件在新平台上复活的核心手段——原版 RCT2 绑死 Windows 32 位,换 Mac 或手机根本跑不了,只有完整理解内部逻辑才能移植
- 为什么一个人用汇编写出的商业游戏,600 人用 C++ 重写反而更好——汇编性能极致但改不动,C++ 牺牲一点性能换来可维护性和可扩展性
- 为什么 GameAction 模式是多人游戏同步的关键——所有玩家的操作必须可序列化、可重放、可回滚,否则世界状态会不一致
- 为什么老游戏引擎不适合直接”打补丁”而需要整体重写——x86 汇编的全局状态、固定内存布局、硬编码常量让局部修改像在钢筋混凝土里换电线
- 为什么 OpenRCT2 和 bevy 代表了游戏引擎的两个极端——Bevy 从零设计现代 ECS 架构,OpenRCT2 在遗留架构上做增量现代化,两条路都有价值
OpenRCT2 的架构围绕一个中央 Context 类展开,像交响乐团指挥一样协调所有子系统。理解它可以分成五个层次:
1. 逆向工程方法论(怎么翻译天书)
项目的前身和 OpenTTD 一脉相承——OpenTTD 是 Transport Tycoon 的开源重实现,Chris Sawyer 的两个游戏共享底层引擎。OpenRCT2 早期甚至复用了 OpenTTD 0.1 的窗口系统和图形渲染代码。逆向过程大致是:用 IDA 等反汇编工具把 .exe 拆成汇编指令,逐函数分析逻辑,用 C++ 重写等价代码,然后对比运行结果确保行为一致。这不是机械翻译——汇编没有变量名、没有函数签名、全是全局状态和魔法数字,理解”这段代码在做什么”本身就是最大的挑战。
2. 游戏状态(世界长什么样)
所有运行时数据集中在 GameState_t 结构体里:公园名称、资金、天气、日期、所有过山车、所有游客、整个地图。地图由 TileElement(瓦片元素)组成,每个瓦片可以叠放多个元素——地面、轨道、风景、围栏、建筑入口等。核心模拟以每秒 40 tick 运行,每个 tick 按固定顺序更新:日期推进、场景逻辑、气候变化、地图刷新、游客 AI、车辆物理、过山车状态、公园经营、最后处理排队中的玩家操作。
3. 过山车和车辆系统(核心玩法)
这是游戏最复杂的子系统。每个过山车(Ride)包含轨道布局、车辆编组、统计数据(刺激度、恶心度、兴奋度)、运营状态、等待队列等。车辆在轨道上的运动不是简单的曲线插值——它模拟了重力、摩擦力、空气阻力、链条提升力、刹车等物理效果。游客决定是否排队时会评估过山车的评分、票价、排队长度和自己的偏好,这套 AI 逻辑在原版汇编里就有几千行,逆向难度极高。
4. 多人游戏同步(原版没有的功能)
这是 OpenRCT2 最大的工程创新之一。设计采用 GameAction 模式:每个玩家操作(放置轨道、修改票价、雇佣员工)被封装成一个 GameAction 对象,包含操作类型和参数。客户端把 GameAction 发给服务器,服务器校验合法性后广播给所有客户端,所有客户端在同一个 tick 执行同一个操作。这保证了确定性同步——只要初始状态和操作序列一致,所有客户端的世界状态就一致。如果检测到不一致,服务器会触发全量状态同步来纠正。
5. 插件系统(让玩家扩展游戏)
OpenRCT2 内嵌了 Duktape JavaScript 引擎,玩家可以用 JS 编写插件来扩展游戏功能——自定义 UI 窗口、修改游戏逻辑、添加新的作弊选项等。插件通过注册回调来响应游戏事件(如游客进入、过山车完成一圈),运行在沙箱环境中不能直接操作内存。这和 minetest 用 Lua 做 Mod 系统的思路类似:引擎提供安全的脚本层,降低扩展门槛。
案例 1:GameAction 模式——怎么让两个人同时建公园
Section titled “案例 1:GameAction 模式——怎么让两个人同时建公园”// 简化版 GameAction:放置一段过山车轨道struct TrackPlaceAction : public GameAction { CoordsXYZ position; // 放在哪 TrackType trackType; // 什么类型的轨道段 RideId rideId; // 属于哪个过山车
// 服务器调用 Query() 检查:位置合法吗?钱够吗?不和别的东西重叠? GameActions::Result Query() const override; // 检查通过后,Execute() 真正修改游戏状态 GameActions::Result Execute() const override;};// 客户端:玩家点击放置 → 创建 TrackPlaceAction → 发给服务器// 服务器:Query() 通过 → 广播给所有客户端 → 所有人同时 Execute()关键点:Query/Execute 分离保证了”先检查再执行”,服务器可以拒绝非法操作(比如钱不够),而不会导致客户端状态不一致。
案例 2:TileElement 堆叠——一个格子里塞了多少东西
Section titled “案例 2:TileElement 堆叠——一个格子里塞了多少东西”一个地图格子(Tile)= 一个垂直的 TileElement 链表
地面 → 地表类型(草地/沙地/水面)+ 高度 ↓轨道 → 过山车轨道段 + 所属 Ride ID ↓风景 → 树木/长椅/垃圾桶 + 朝向 ↓围栏 → 方向 + 类型 ↓建筑入口 → 过山车入口/出口 + 朝向
遍历方式:从第一个 TileElement 开始,逐个 ++直到遇到 IsLastForTile() == true 的元素这种紧凑的链式存储让地图内存占用极低,但代价是插入/删除要移动后续元素。遍历时从首元素开始逐个前进,直到 IsLastForTile() 为真。
案例 3:最小 JavaScript 插件
Section titled “案例 3:最小 JavaScript 插件”// 放到用户数据目录 plugin/hello.js,启动时自动加载const window = ui.openWindow({ classification: "hello", width: 200, height: 80, title: "Hello OpenRCT2", widgets: [{ type: "label", text: "公园资金见控制台", x: 10, y: 20, width: 180, height: 20 }],});console.log("cash =", park.cash);逐部分解释:ui.openWindow 弹自定义窗;park.cash 读经营状态;脚本跑在 Duktape 沙箱里,不能直接改引擎内存。改逻辑不必重编译 C++。
去 openrct2.org 下预编译包,首次启动指定 Steam/GOG 原版 RCT2 路径即可。源码编译:cmake -B build && cmake --build build(需 C++20、SDL2 等;Windows 常用 vcpkg)。
-
以为”逆向工程”等于”反编译”:反编译器(如 Ghidra)能把二进制转成类似 C 的伪代码,但输出几乎不可读——变量名全丢了,控制流被优化扭曲了。OpenRCT2 的逆向是人工分析每个函数的行为,理解它的业务语义(“这个函数在计算过山车的兴奋度评分”),然后用 C++ 写出等价的、可读的实现。这是理解力密集型工作,不是工具密集型。
-
全局状态是最大的技术债:原版汇编大量使用全局变量和固定内存地址,逆向初期 OpenRCT2 也保留了很多全局状态。后来逐步重构成
GameState_t集中管理,但至今仍有一些遗留的全局变量没有完全清理。新手贡献者容易踩到”改了一个全局变量,另一个看似不相关的系统崩了”的坑。 -
混淆 RCT1 和 RCT2 格式:OpenRCT2 支持导入 RCT1 的存档(
.sc4/.sv4)和 RCT2 的存档(.sc6/.sv6),还有自己的原生格式(.park)。三种格式的数据布局完全不同,导入时需要做大量的字段映射和默认值填充。如果拿错了导入器处理文件,会得到完全混乱的公园数据。 -
确定性同步的脆弱性:多人游戏依赖所有客户端在相同输入下产生完全相同的输出。但浮点运算在不同平台上可能有微小差异(x87 vs SSE),所以 OpenRCT2 的物理计算全部使用整数定点数。一旦有人在某个代码路径里不小心用了浮点数,多人游戏就会出现”鬼畜”现象——客户端之间的世界状态逐渐分裂。
适用 vs 不适用场景
Section titled “适用 vs 不适用场景”适用:
- 学习大型逆向工程项目的组织方式——从汇编到 C++ 的完整翻译过程,600+ 贡献者的协作模式
- 理解 2D 等轴投影(isometric)渲染引擎的工作原理——OpenRCT2 的渲染管线是手写软件光栅化,不依赖 GPU,适合学原理
- 研究确定性游戏同步的实现——GameAction 模式是网络游戏同步的经典方案
- 学习遗留代码现代化——如何在不破坏行为的前提下逐步重构全局状态、引入类型安全、迁移到 C++20
不适用:
- 学习现代 3D 游戏引擎——OpenRCT2 是 2D 等轴投影、软件渲染,没有 GPU 加速管线,想学 3D 选 bevy 或 panda3d
- 从零学游戏开发——代码库约 40 万行 C++,充满逆向工程遗留的历史包袱,新手会被淹没
- 需要引擎级别的通用性——OpenRCT2 深度绑定过山车大亨 2 的游戏逻辑,不能拿来做别的游戏
- 学习 ECS 或现代架构模式——OpenRCT2 保留了原版的过程式风格,全局状态和巨型函数仍然很多
历史小故事(可跳过)
Section titled “历史小故事(可跳过)”Chris Sawyer 是个传奇。1990 年代他用 x86 汇编从零写了 Transport Tycoon,后来在同一套引擎基础上又写了 RollerCoaster Tycoon 1 和 2。他几乎独自完成了所有编程工作——菜单、渲染、物理模拟、AI、存档系统、场景编辑器,全部手写汇编。这在当时就已经是不可思议的壮举。RCT1 发布后成为全球销量冠军,为 Hasbro 赚了几亿美元。
2014 年,一个叫 Ted John(网名 IntelOrca)的开发者决定逆向 RCT2 的二进制文件并用 C++ 重写。项目早期极其困难——原版没有调试符号,没有源码,每个函数都得靠反汇编和反复测试来推断语义。社区逐渐壮大,累计 600+ 贡献者,到 2024 年基本完成了所有游戏逻辑的 C++ 替换。更有趣的是,OpenTTD(Transport Tycoon 的开源重实现)也走过同样的路,两个项目形成了一个”逆向 Chris Sawyer 全家桶”的生态。
-
逆向工程不是复制粘贴,而是理解力的极限测试——没有变量名、没有注释、没有文档,你必须从行为推断意图。这种能力在调试、安全审计、遗留系统维护中都极有价值。
-
确定性同步的代价是放弃浮点数——多人游戏需要所有客户端产生相同结果,浮点数的跨平台不一致性是地雷,整数定点数是唯一安全选择。这解释了为什么很多老游戏的物理看起来”生硬”。
-
GameAction 模式是网络同步的通用范式——把操作封装成可序列化的命令对象,服务器校验后广播,所有端同步执行。这个模式在数据库(WAL)、分布式系统(Raft log)、协同编辑(OT/CRDT)里都有影子。
-
遗留系统现代化不能一步到位——OpenRCT2 用了十年,从”用 C 包裹汇编”逐步演进到”纯 C++20”。每一步都保持游戏可玩,没有”停工重写两年再上线”。这种增量式迁移策略比推倒重来更安全。
核心在 src/openrct2/:Context(调度)、GameState(40 tick 主循环)、ride/、world/(TileElement)、network/(GameAction)、scripting/(Duktape)。UI 在 openrct2-ui/,无头服务在 openrct2-cli/。
- 官方网站:openrct2.org ——下载、文档、社区入口
- 架构全景:DeepWiki - OpenRCT2 Architecture ——代码级别的系统架构讲解
- 插件开发:Plugin API Reference ——JavaScript 插件 API 文档
- 原版汇编的传奇:RCT2 Was Built Entirely in Assembly ——Chris Sawyer 用汇编写游戏的故事
- 姊妹项目:OpenTTD ——Transport Tycoon 的开源重实现,同一脉络的逆向工程项目
- bevy —— 现代 ECS 游戏引擎,和 OpenRCT2 的过程式遗留架构形成鲜明对比
- minetest —— 同为用 C++ 重写/实现的开源游戏引擎,同样提供脚本扩展系统(Lua vs JavaScript)
- panda3d —— Python/C++ 3D 引擎,对比 2D 等轴投影 vs 3D 场景图两种完全不同的渲染路线
(暂无反向链接)