CrazyTaxi 制作记:用 Three.js 重建世嘉 Dreamcast 经典之作
CrazyTaxi 是用 Three.js 重建的世嘉 Dreamcast 版《Crazy Taxi》。车辆、交通、镜头、乘客和菜单全都在一个小型解释器中运行游戏自身的 SH-4 代码,并与反编译结果逐位一致。Agent! 用三天、157 次提交完成了它。可在 ct.gokart.games 上游玩。
立即游玩:ct.gokart.games。 只需一个支持 WebGL 的浏览器,可以用键盘或 Xbox(或其他标准)手柄来玩。
CrazyTaxi 用 Three.js 重建了世嘉 Dreamcast 版 Crazy Taxi(USA,MK-51035)。第一次提交是在 10 月 22 日。到 10 月 24 日晚上,它已经有 157 次提交:第一天 93 次,第二天 41 次,第三天 23 次。这一切都由 Agent! 编写,每次一个经过测试的提交,而提交信息读起来就像实验记录本,几乎每一条都写着地址、数量和检查结果。
这不是照着截图做出来的翻版。屏幕上的几乎所有东西,以及它的大部分行为,都来自光盘本身。
第一天:把游戏从光盘里取出来
第一天的大部分时间都花在了提取上。仓库里没有任何游戏数据。你需要自己转储光盘,然后用一条命令把所有内容取出来:
python3 tools/extract_all.py '<Crazy Taxi (USA).7z or .chd>' --output local-assets
这条命令背后是 tools/ 中的 17 个转换器,每一个都分三步提交:工具本身、它的测试,以及一份记录发现内容的文档。它们依次处理:
- 光盘:从原始高密度轨道中提取并校验哈希。
extract_chd.py用自带的解码器(包括一个 FLAC 解码器)读取 CHD v5 镜像,并重建 CD 扇区纠错码。它的输出与.7z转储逐字节一致。 - 纹理和精灵:TEXDC 纹理库,以及精灵库,后者的调色板是从可执行文件的 SH-4 加载代码中还原出来的。
- 模型和城市:POLDC 模型和 BINC 城市档案,转换为带纹理的 glTF。
- 角色和动作:按照游戏自身的绘制例程组装,包括关键曲线动作及其所需的双骨骼 IK。
- 交通车辆和出租车:根据 USA 版车辆表和出租车表构建,每种交通车辆颜色都是一个 glTF 材质变体。
- 碰撞、路线、场景物体、乘客、停靠点、目的地图片和回放,每一项都与可执行文件自身的表格进行核对。
- 音频:ADX 音乐和语音,以及 DTPK 音效库,其播放速率和乐器程序按照 Dreamcast 声音驱动的方式读取。
完整运行一次会在大约两分钟内写出 4,744 个文件(255 MB)。
运行游戏自身的代码
当天晚上,Three.js 版游戏开工了,先有了城市、一辆占位用的出租车和一个手动调校的追尾镜头。
不到一小时后,提交 02bf19e 就丢掉了占位用的物理。src/sh4.js 是一个小型 SH-4 解释器(SH-4 是 Dreamcast 的 CPU),src/dc-car.js 用它直接从游戏的 1ST_READ.BIN 运行 init_CarMain 和 exec_CarMain。它们在光盘的路线点和碰撞数据上运行,这些数据被加载到原本的 Dreamcast 地址。绘制、声音和震动被打桩或捕获,Three.js 负责绘制游戏要求绘制的一切。
游戏的其余部分一个系统接一个系统地搬进了解释器:
- 交通:每辆汽车、火车、缆车和信号灯都是一个游戏事件,由游戏自身的
trafficControl和nlExecuteEvent运行。出租车撞上交通车辆,交通车辆也会撞回来。 - 镜头:
camUpdate的追尾镜头,以及倒计时期间绕着出租车的俯冲镜头。Three.js 只是直接采用游戏的视图矩阵。 - 乘客:谁在哪里等车、上车、乘车过程,以及下车时的评价和时间奖励。
- 菜单:标题画面、主菜单、选项、记录和司机选择。它们全都是游戏代码在绘制精灵,由
src/menu-view.js把它们放到 canvas 上。
每一项都与反编译项目自带的 SH-4 模拟器进行了核对。tools/car_trace.py 会录下一整单行程:倒计时、接客、驾驶、下车。在两座城市中,出租车、镜头、每辆交通车辆、游戏时钟和每位乘客的每一帧都逐位一致。
这项检查抓到了一个真正的 bug。游戏数学运算中有两个不同的部分结果对不上,直到把解释器的 64 位浮点移动指令修正为按照 SH-4 实际使用的顺序存储寄存器对,问题才解决。反编译项目的模拟器也在 car_trace.py 中打上了同样的补丁。
Crazy Box
第二天,Crazy Box 登场了:游戏的迷你游戏,全部 16 个都由 Init_DCmini 和 execDCmini 运行。游戏的每个新部分都是用同样的方法找到的:在可执行文件中找到例程,在解释器中运行它,然后捕获它绘制的内容:
- Crazy Bowling 的球瓶来自
ExecSetObject。撞到它们时会飞出去,游戏会统计数量。 - 绿色引导箭头来自
ExecArrow,在视图空间中绘制,再放回世界中。 - TRY AGAIN 窗口就是游戏自身的
windowTryAgain,连菜单光标也包括在内。 - 通关记录由游戏自身的
CheckRanking保存,并解锁后面的关卡。存档与 Dreamcast 备份的 0x25B0 字节完全相同,保存在localStorage中。
这项工作中的一个发现:名为 carRumble 的例程不只是负责震动。它还负责 Crazy Dash。把它打桩掉时,Crazy Jump 最远只能到 132 m。让它正常运行后,在 GO 时冲刺可以达到 173 m。
60 fps 与高清纹理
做对之后,还得做快。城市一开始是 13,779 个独立网格,浏览器每帧要发出 3,000 到 4,500 次绘制调用。在一个 Auto-Pilot 分支上,src/static-batch.js 合并了静态世界。全部 374 张城市纹理被放进一张图集,网格按 4,000 单位的单元格合并。这样每帧降到大约 300 次绘制调用。在 Mac 应用中以 2560 × 1920 运行时稳定在 60 fps(最低 58),之前是 37 到 47。
按 H(或手柄上的 Y)可以开启高清纹理包。tools/match_texture_pack.py 为游戏中的每张纹理重新计算 Flycast 的自定义纹理哈希,从而把一个 Flycast 纹理包映射到游戏 2,090 张纹理中的 1,793 张上。开启纹理包后,批处理后的城市会使用四倍大小(8192 × 8192)的图集进行绘制。发布版以 KTX2 格式提供这些纹理:51 MB,而纹理包的 JPEG 和 PNG 共 551 MB。
第三天的其余打磨:
- TAA:抖动的帧混合进重投影的历史帧(
src/taa.js),叠加在 4× MSAA 之上,远处的城市不再闪烁蠕动。 - 不再闪烁:重叠的共面细节,比如人行道上的建筑阴影,被拉向视点方向,不再争夺深度。
- 更小的下载量:音乐和语音改用 Opus 和 AAC(约 150 MB 降到 37 MB),其余内容使用 Brotli(传输量从 103 MB 降到 28 MB)。
- 新功能:支持 Xbox 手柄,城市小地图会用绿色显示前往目的地的最短道路路线,Arcade 规则下还有下车时间奖励。
- 桌面应用:一款已签名并经过公证的 Mac 应用,以及 Windows 和 Linux 启动器,全部运行 ct.gokart.games 上的网页版。
如何检查
这一切都不是靠信任。反编译轨迹样本逐帧检查游戏逻辑。每次修改后,无头 Chrome 都会在两座城市各跑一单行程并统计错误。tools/draw-order-check.mjs 用自动驾驶驱动出租车,把每一帧与一个深度正确的参考进行比较:修复绘制顺序之前,95 个样本中有 66 个出错,修复之后 565 个中 0 个出错。tools/mac-probe.sh 在无人值守的情况下驱动 Mac 应用,每秒输出 fps 和绘制调用次数。
试试看
- 游玩: ct.gokart.games
- 源代码: AgentiLoop/CrazyTaxi。要在本地运行,你需要自己的 Crazy Taxi(USA)光盘、Node.js 和 Python 3。
在乘客旁边、在他们的圆圈内停车即可接客。然后跟着箭头前往图片中的地点,并在那里停车。
CrazyTaxi 是一个独立的粉丝项目,与世嘉无关,也未获得世嘉认可。Crazy Taxi 是世嘉的商标。仓库中不包含任何游戏数据。