跳到正文
Hacker News· deadxwarrior·· 6 天前AI 评分62

ps2-web-recomp:把 PS2 游戏静态重编译为 WebAssembly 在浏览器运行

Show HN: PS2 games statically recompiled to WebAssembly

AI 导读

ps2-web-recomp 项目将 PS2 游戏的 Emotion Engine 机器码提前翻译为 C++,再编译成 WebAssembly,配合用 WebGPU 绘制的 PS2 操作系统与硬件重实现,在浏览器中无需模拟器运行游戏。

正文 · AI 翻译

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

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

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

《God of War II》通过相同的脚本跟进。它不需要游戏特定的代码,但发现了《Gran Turismo 4》从未触发的两个错误,一个在重编译器中,一个在向量单元时序中,以及 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 模块,每个程序耗时几毫秒。

图形。图形合成器的 4 MB 显存位于 GPU 上,采用其原生的 swizzled 布局,绘制由 WebGPU 计算着色器完成:三角形被分箱到瓦片中,并按照 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 次。

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

在三倍原生分辨率下,帧率降至 23。对着色器进行性能分析显示,每个三角形的设置是在一个线程间存在差异的分支之后加载的,这使得整个设置出现分歧,并且上传拆分成 1 MB 的块阻塞了 Chrome 的页面线程。统一加载设置并将每帧的数据一次性写入,使三倍分辨率在同一集成 GPU 上达到每秒 48 到 50 帧。

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

第二个游戏

《战神 II》通过 port.sh 从 ISO 变成页面,只做了通用更改,而每一项更改都是第一个游戏未曾触及的 bug 或缺口。

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

在此过程中,它还需要一条像 BIOS 提供的启动路径、一个从内存而非光盘加载的模块,以及暂存器 DMA 的目标链模式。

移植你自己的游戏

你需要 git、CMake、Ninja、一个 C++20 编译器(首选 clang,会自动选用)、Python 3,如果你的光盘镜像在 .7z 中还需要 7-Zip。所有内容都已在 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 核笔记本上,《战神 II》从 ISO 到页面只需三分半钟。

探索运行会每五秒按下开始键再按叉键,这能让大多数游戏通过标题画面,并像页面那样在 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,并将其保存在浏览器存储中,与您游玩的地址关联,因此进度在重新加载后仍会保留。游戏会像使用真实记忆卡一样格式化并使用它。导出记忆卡会将其下载为 PCSX2 使用的布局的 .ps2 文件,导入记忆卡会将此类文件放回插槽,因此记忆卡可以在浏览器之间迁移,或来自 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 声明。

来源:Hacker News · github.com