PlayStation 2 游戏在浏览器中运行,无需模拟器。游戏的 Emotion Engine 机器码被提前翻译成 C++,编译为 WebAssembly,并与 PS2 操作系统和硬件的重新实现链接,使用 WebGPU 进行绘制。

Gran Turismo 4 running in a browser
观看完整录像(1:24)

这是以下项目的姊妹项目psp-web-recomp并遵循相同的方法。以这种方式启动的第一款游戏是 Gran Turismo 4,选择它是因为它是该系统上要求最高的游戏之一:它同时依赖两个向量单元、依赖精确的浮点行为来实现回放和驾照考试,还依赖将渲染图像读回作为调色板的图形技巧。它从启动到开场动画、菜单和比赛都能运行,带有音乐和音效,并可保存到浏览器中的记忆卡。一场比赛以全速运行,这个 PAL 版本为每秒 50 帧,在笔记本电脑集成 GPU 上的 Chrome 中以 PS2 两倍分辨率运行,上面的录像就是这样制作的。

God of War II 通过相同的脚本跟进。它不需要任何游戏专属代码,但发现了两个 Gran Turismo 4 从未触发的 bug,一个在重编译器中,一个在向量单元时序中,以及 PS2 输入/输出处理器的一些缺失部分。它现在可以从菜单运行到过场动画再到战斗,并在 Chrome 中以 PS2 分辨率在激烈战斗中保持每秒 60 帧。在两倍分辨率下,它以 48 到 60 帧运行,受限于笔记本电脑的集成 GPU。

此处不包含任何游戏数据。你提供自己拥有的游戏光盘镜像,此仓库中的脚本会在你的机器上将其转换为网页。

工作原理

重编译。 eerecomp读取游戏的可执行文件,沿着其控制流找到各个函数,并以 16 KiB 的客户代码为单位为它们生成 C++。输出保留了 R5900 的怪癖:128 位寄存器和作用于它们的多媒体指令、分支延迟槽、PS2 的浮点单元(没有无穷大和非规格化数,且舍入方式与 IEEE 不同),以及用作协处理器的向量单元 0。单元之间的跳转变成尾调用,线程切换会展开宿主栈并在客户地址处恢复,因此生成的代码永远不需要为每个客户线程维护一个栈。

一个小型 PS2 内核。操作系统在 EE 一侧由高级仿真来应对:线程、信号量、DMA 控制器和垂直消隐的中断处理程序、闹钟,以及连接主 CPU 与 I/O 处理器的 SIF 通道。I/O 处理器在 R3000 CPU 的解释器上运行游戏自带的 IRX 模块、声音驱动、光盘流式读取代码以及随光盘附带的控制器和记忆卡驱动,而 IOP 内核及这些模块调用的库则用 C++ 重新实现。光盘通过 HTTP Range 请求读取,因此只有可执行文件会被预先下载。

向量单元。PS2 游戏的大部分 3D 工作都发生在游戏上传到向量单元 1 的微程序中。它们以位精确的 PS2 浮点数学运行,并对 MAC、状态和裁剪标志的流水线延迟进行建模,因为游戏会围绕这些延迟来调度代码。测试运行期间见到的微程序会被提前编译为 SIMD C++;页面首次遇到的任何微程序会被即时翻译为 WebAssembly 模块,每个程序耗时几毫秒。

图形。Graphics Synthesizer 的 4 MB 显存位于 GPU 上,采用其原生的 swizzled 布局,绘制由 WebGPU 计算着色器完成:三角形被分箱到 tile 中,并按照 GS 自身的纹理、调色板、alpha 混合、深度和目标 alpha 测试规则进行光栅化。由于一切都留在 GS 内存中,渲染到纹理或将帧缓冲重新解释为调色板的效果,其工作方式与在主机上一致。内部渲染分辨率可以是原生分辨率的一倍、两倍或三倍。同一 GS 的软件光栅化器仍作为参照,用于与 GPU 输出进行对比。

线程。模拟运行在一个 worker 中,向量单元 1 拥有自己的 worker,就像它在主机上并行运行那样,而 GS 命令通过环形缓冲区发送到页面线程,由页面线程持有 WebGPU 设备。

声音和影片。SPU2 被重新实现,包含其 48 个 ADPCM 声部、ADSR 包络以及游戏用于音乐的流式 DMA,其输出通过共享内存发送到 AudioWorklet。影片由 IPU(PS2 的 MPEG-2 解码器)的一个实现来解码。

让它变快的经验教训

第一场比赛原生运行速度为每秒 3 帧,使用的是软件光栅化器和向量单元 1 的解释器。将 GS 迁移到 WebGPU 计算着色器并编译向量单元程序后,原生速度提升到约 70 帧,而剩余的大部分工作在于浏览器把时间花在了哪里,而不是模拟本身。

GPU 渲染器的第一个版本每帧提交约 650 次计算调度,因为每次纹理上传和调色板加载都会把它周围的三角形批次拆开。将上传提前到不依赖它们的三角形之前,且输出相同,使这一数字降至约 80。

Gran Turismo 4 在比赛的每一帧都会上传相同的 3 MB 纹理。GS 内存会在帧之间交换,因此这些上传不能简单地跳过,但在 GPU 上缓存最近上传内容的 16 MB 缓存,使其中大部分无需再次从 WebAssembly 跨越到 GPU 即可提供,从而将每帧发送的数据减少了约 60%。

在原生分辨率三倍的情况下,帧率降到了 23。对 shader 进行性能分析后发现,逐三角形的设置是在一个线程间存在分歧的分支之后加载的,这使得整个设置过程产生了分歧,并且上传被拆分成 1 MB 的片段阻塞了 Chrome 的页面线程。统一加载设置并将每帧数据一次性写入,使同一集成 GPU 上三倍分辨率的帧率提升到了 48 到 50 帧每秒。

浏览器自身的调度也很重要。嵌套的setTimeout调用被限制在 4 ms,因此模拟循环现在改为通过一个MessageChannel来让出,并将模拟移入 worker,这样页面线程只负责向 GPU 提供数据,稳定了一个原本在 40 到 50 帧每秒之间波动的竞态,使其恒定在 50。

第二款游戏

God of War II 从一个 ISO 变成了一个页面,通过port.sh仅做了通用性的改动,而每一处都是一个 bug 或缺口,只是第一款游戏恰好没有触发而已。

它始于沉默。游戏的音频驱动从 I/O 处理器的一个硬件定时器驱动其 tick,而没有任何东西实现过这个定时器,因此驱动从未混合出任何采样。难度菜单之后的过场是一堆巨大的扁平多边形,而当游戏即将开始时,游戏的渲染列表越过了内存末尾并覆盖了自己的垂直消隐处理程序,导致崩溃。用硬件观察点沿着损坏的链表回溯,最终指向了重编译器:当一个分支是 16 KiB 单元的最后一条指令时,不采用该分支的路径会在下一个单元中从延迟槽之后继续,而不是在延迟槽之后,导致该指令被执行两次,或者执行了 branch-likely 本应跳过的指令。这修复了崩溃,游戏进入了战斗,但屏幕的部分区域仍然覆盖着巨大的多边形。这些来自本应被剔除的三角形。God of War II 的微码在设置裁剪标志的CLIP之后三条指令才读取这些标志,而向量单元模型在四条指令之后才使其可见,Gran Turismo 4 的微码总是等待更久,因此不可能注意到这一点。

一路上它还需要一个类似 BIOS 提供的引导路径、一个从内存而非光盘加载的模块,以及暂存器 DMA 的目标链模式。

移植一款你自己的游戏

你需要 git、CMake、Ninja、一个 C++20 编译器(首选 clang,会自动选用)、Python 3,以及 7-Zip(如果你的光盘镜像是.7z格式)。所有内容都已在 x86-64 的 Linux 上运行过。首次运行会把 Emscripten SDK 安装到tools/emsdk,或使用EMSDK所指向的那个,并为无头测试运行器下载 wgpu-native。预计每款游戏需要约 300 MB 磁盘空间用于生成的代码和构建产物;光盘镜像本身是链接的,不会复制。

git clone https://github.com/snuri00/ps2-web-recomp.git
cd ps2-web-recomp
scripts/port.sh mygame "My Game.iso"
python3 scripts/serve.py build/web-mygame games/mygame/disc.iso    #http://localhost:8620/

port.sh接收一个光盘镜像(ISO,或包含 ISO 的 ZIP 或 7z 压缩包)并完成全部工作。它读取光盘SYSTEM.CNF中指定的引导程序,把其机器码翻译成 C++,然后无头运行游戏几次。静态分析无法看到游戏通过指针到达的每一个函数,因此每次运行都会记录下它找不到的地址,脚本会把这些地址添加为入口点并重新翻译,直到某次运行不再出现新的地址为止。这些运行还会记录游戏上传的向量单元微程序,这些微程序也会被提前编译。最后它把页面构建到build/web-mygame。在 8 核笔记本上,God of War II 从 ISO 到页面只需三分半钟。

探索性运行每五秒按一次 start 再按一次 cross,这能让大多数游戏通过标题画面,并像页面那样在第 1 插槽放入一张空记忆卡,从而沿着玩家会走的路径前进。--press 900:cross用你自己的输入替换掉这些,--frames设置每次运行的时长。最后一次运行的帧会落在games/mygame/shots,这是查看一款新游戏进展到哪里的最快方式。scripts/build_web.sh mygame只重新构建页面。

请相应地调整预期:目前只跑通了两款游戏。另一款游戏很可能会停在某个系统调用或某个尚无人实现的 IOP 库函数上,运行器会将其记录为[sys] unhandled syscall或[iop] missing <library> #<n>,或者用到了渲染器尚未处理的某个 GS 特性。

浏览器

该页面需要 WebGPU 才能使用 GPU 渲染器。Chrome 和 Edge 在 Windows 和 macOS 上已支持。在 Linux 上,Chrome 和 Chromium 默认关闭:请开启chrome://flags/#enable-unsafe-webgpu和chrome://flags/#enable-vulkan然后重启浏览器,正如页面在找不到可用适配器时告诉你的那样。没有 WebGPU 时,软件渲染器会接管,这对菜单来说没问题,但对 3D 来说太慢了。在配备两块 GPU 的笔记本电脑上,在独立 GPU 上绘制的画布有时无法显示在屏幕上;页面会尝试两个适配器,并回退到通过 2D 画布复制帧。

键盘将正面按钮映射为 Z(叉)、X(圆)、A(方)和 S(三角),肩键映射为 Q、W、1 和 2,开始和选择映射为 Enter 和 Backspace,方向键映射为箭头键,左摇杆映射为 I、J、K 和 L。手柄通过浏览器的 gamepad API 工作,手机获得屏幕控制,F 切换全屏。分辨率选择器会以新尺寸重新加载页面。

存档

页面会在插槽 1 中放入一张记忆卡,并将其保存在浏览器存储中,与你游玩的地址相关联,因此进度在重新加载后仍会保留。游戏会像使用真实记忆卡一样对其进行格式化和使用。导出记忆卡将其下载为.ps2PCSX2 使用的布局格式的文件,并且导入记忆卡将这样的文件放回插槽,因此记忆卡可以在浏览器之间迁移,或来自 PCSX2。?nocard启动时插槽为空。

托管

页面使用 SharedArrayBuffer 来支持其线程,因此必须以跨源隔离的方式提供服务,并带有Cross-Origin-Opener-Policy: same-origin和Cross-Origin-Embedder-Policy: require-corp。scripts/serve.py会发送这两个标头,并支持光盘流式传输所依赖的 Range 请求。任何能访问服务器的人都可以加载游戏,所以请自己保留。

法律

此仓库仅包含原创代码以及遵循其自身许可证的第三方代码。它不包含任何游戏代码或数据。生成的 C++ 和构建出的 WebAssembly 是游戏可执行文件的翻译,因此它们属于游戏的所有者:请将它们保留在你自己的机器上,不要发布它们。使用你拥有的游戏的光盘镜像。Gran Turismo、God of War 和 PlayStation 是 Sony Interactive Entertainment 的商标;此项目与 Sony、Polyphony Digital、Santa Monica Studio 或任何游戏发行商均无关联,也未获得其认可。

致谢

MPEG 解码器的 VLC 表改编自Play!(BSD 2-clause,声明见runtime/src/ipu/NOTICE)。PCSX2、Play! 和 ps2sdk 头文件是这里模拟的大部分硬件和系统行为的参考。wgpu-native 和 Dawn 在原生环境和浏览器中提供 WebGPU,Emscripten 则负责构建 WebAssembly。

许可证

本仓库中的代码在 MIT 许可证下提供,详见LICENSE。runtime/src/ipu/tables.hpp保留了 Play! 的 BSD 2-clause 声明。