Hacker News 中文摘要

RSS订阅

Show HN:PS2 游戏静态重编译为 WebAssembly -- Show HN: PS2 games statically recompiled to WebAssembly

PS2(PlayStation 2)游戏无需模拟器,在浏览器中运行:Emotion Engine 机器码被提前转为 C++,编译为 WebAssembly,并链接到用 WebGPU 绘制的 PS2 操作系统与硬件重实现。这是 psp-web-recomp 的姊妹项目;首个以此运行的游戏是 GT赛车4(Gran Turismo 4),它依赖双向量单元,以及精确浮点。游戏从启动到开场动画、菜单和比赛均可播放,带音乐音效并存入浏览器记忆卡;PAL 版在笔记本集成 GPU 上,以谷歌浏览器(Chrome)全速 50 帧每秒、两倍 PS2 分辨率运行。

正文

PlayStation 2 游戏无需模拟器即可在浏览器中运行。游戏的 Emotion Engine 机器码被提前翻译成 C++,编译为 WebAssembly,并与一套通过 WebGPU 绘制的 PS2 操作系统及硬件重新实现相连。

Gran Turismo 4 在浏览器中运行

观看完整录像(1:24)。

这是 psp-web-recomp 的姊妹项目,方法相同。以这种方式带起来的首款游戏是《Gran Turismo 4》,选它是因为它是该系统上最吃性能的游戏之一:同时依赖两个向量单元,依赖精确的浮点行为来实现回放和驾照考试,还依赖把渲染后的图像读回作为调色板的图形技巧。它能从开机播到开场动画、菜单和比赛,带音乐和音效,并保存到浏览器里的一张记忆卡。一场比赛以完整速度运行——这个 PAL 版本为每秒 50 帧——在笔记本集成 GPU 上以 PS2 两倍分辨率于 Chrome 中运行,上面的录像就是这样录的。

《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 计算着色器完成:三角形被分箱到瓦片中,并按照 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。对着色器做性能分析显示,逐三角形设置在一条各线程结果不同的分支之后加载,导致整个设置出现分歧;而且上传被切分成 1 MB 的块,阻塞了 Chrome 的页面线程。统一加载设置,并把每帧数据一次性写入,让三倍分辨率在同一块集成 GPU 上跑到了每秒 48 到 50 帧。

浏览器自身的调度也很重要。嵌套的 setTimeout 调用会被钳制到 4 ms,所以模拟循环改为通过 MessageChannel 让出;把模拟移进 worker,让页面线程只负责喂 GPU,这让一场原本在恒定 50 帧下游走于 40 到 50 帧之间的比赛稳定下来。

第二款游戏

《God of War II》通过 port.sh 从一个 ISO 变成一个网页,只做了通用改动;每一处改动都是第一个游戏根本没有触及的 bug 或缺失。

一开始是没声音。游戏的声音驱动靠 I/O 处理器的某个硬件定时器来推进它的 tick,而这个定时器没人实现,所以驱动从未混过任何采样。难度菜单之后的 intro 是一堆巨大的平面多边形;而当玩法快要开始时,游戏在渲染列表越过内存末尾、覆盖了它自己的垂直消隐处理程序后崩溃。借助硬件观察点回溯一个损坏的链表,问题追到重编译器:当分支是 16 KiB 单元的最后一条指令时,不采取分支的那条路径会在下一个单元的延迟槽处继续,而不是在其之后继续,于是把那条指令跑了两次,或者跑了本该被 branch-likely 跳过的指令。这修好了崩溃,游戏进入战斗,但屏幕部分区域仍有巨大多边形。它们来自本该被剔除的三角形。《God of War II》的微码在设置裁剪标志的 CLIP 之后第三条指令读取这些标志,而向量单元模型要到第四条指令后才让它们可见;《Gran Turismo 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 核笔记本上,《God of War II》从 ISO 到网页耗时三分半钟。

探索性运行会按开始键,然后每五秒按一次 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 渲染器。Windows 和 macOS 上的 Chrome 与 Edge 都有。在 Linux 上,Chrome 和 Chromium 默认关闭它:请打开 chrome://flags/#enable-unsafe-webgpu 和 chrome://flags/#enable-vulkan 并重启浏览器——页面在找不到可用适配器时会这样提示你。没有 WebGPU 时,软件渲染器会接管,它对菜单没问题,但对 3D 来说太慢。在带双 GPU 的笔记本上,在独显上绘制的 canvas 有时无法显示到屏幕;页面会尝试两个适配器,并回退到通过 2D canvas 拷贝帧。

键盘把正面按键映射为 Z(cross)、X(circle)、A(square)和 S(triangle),肩键映射为 Q、W、1 和 2,start 和 select 映射为 Enter 和 Backspace,方向键映射为箭头键,左摇杆映射为 I、J、K 和 L。手柄通过浏览器的 gamepad API 工作。它们的模拟扳机以 DualShock 2 的压感 L2 和 R2 传给游戏;在暴露振动接口的浏览器(Chrome 和 Edge 都有)里,带马达的手柄会在游戏要求时振动。手机有屏幕上的虚拟按键,F 切换全屏。分辨率选择器会以新尺寸重新加载页面。

在本地运行

用于探索性运行的 port.sh 会构建出 build/native-mygame/ps2run;在装了 SDL2 的情况下,它也能在窗口里玩游戏。它通过 Vulkan 上的 wgpu-native 绘制,并通过 SDL 播放声音。键盘按键沿用页面对按钮的映射,箭头键同时作为方向键和左摇杆;任何 SDL 能识别的游戏手柄也能用,包括 Steam Deck 自己的控制,且扳机压感和振动与浏览器中相同。

例如运行:build/native-mygame/ps2run games/mygame/disc/SLUS_123.45 --disc games/mygame/disc.iso --window --memcard mygame.ps2。

--fullscreen 以全屏启动,F11 来回切换。PS2_SCALE=2 或 3 会提高内部分辨率。和浏览器中一样,模拟、向量单元 1 和 GS 各跑在各自的线程上,这让《God of War II》在测试笔记本上以原生分辨率从每秒 43 帧提升到 60 帧;PS2_ONE_THREAD=1 则把一切放回单线程。记忆卡文件使用的布局与页面导出的卡相同。在 Steam Deck 上,可以把运行器作为非 Steam 游戏加入 Steam,尽管还没人在上面试过。

存档

页面在插槽 1 放一张记忆卡,并把它保存在你游玩所用地址对应的浏览器存储里,因此重新加载后进度仍在。游戏会像使用真实记忆卡那样格式化并使用它。Export card 会把它下载为 PCSX2 所用布局的 .ps2 文件;Import card 则把这类文件放回插槽,于是记忆卡可以在不同浏览器之间转移,或从 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 发布,见 LICENSE。runtime/src/ipu/tables.hpp 保留了 Play! 的 BSD 2-clause 声明。

评论

讨论气氛以惊叹和“想立刻玩到”为主:多数人称赞 PS2 静态重编译很酷,并希望它登陆 Steam Deck、浏览器等平台,或用于《Forza》等更多游戏。 分歧与隐忧集中在两点:有讽刺指出这类 recomp 其实仍模拟了 IOP 等核心组件,是否算真正重编译存疑;同时也有人担忧 AI 推动的重编译潮会让游戏更容易被窃取和免费分享,削弱工作室投入,并引出对 clean-room 合法路径的追问。

盛赞项目惊艳,认为无论是否用 AI 都很了不起

  • 不少人称项目非常酷、惊人;有人强调不管有没有 AI 参与都配得上“amazing”,也有人坦言不喜欢 AI 改变自己的职业,但仍承认从中得到了好东西。(较多人)
  • 需要引用时,只用一句中文意译: > 不管有没有 AI,这都很惊人。(@CodeCompost)

希望支持更多平台与游戏,并降低体验和转换门槛

  • 评论者希望项目覆盖 Steam Deck 及同类设备,也有人想要 GitHub Pages 托管以便直接在浏览器里玩;还有人希望 PSP 重编译兼容古老 GL 2.1 显卡(如 n270 上网本),并期待同类技术用于《Forza》等更多游戏;另有建议把编译器本身编译到 WebAssembly,让用户能从浏览器直接转换整个项目。(较多人)
  • 需要引用时,只用一句中文意译: > 非常酷,希望它能在 Steam Deck 及同类设备上运行。(@spwa4)

质疑所谓“重编译”仍模拟核心系统组件

  • 有人讽刺这又是“另一个仍然模拟核心系统组件的重编译”,并贴出运行时代码,指出项目仍包含 IOP CPU 的模拟,说明很多 recomp 并未彻底消除模拟,只是把部分代码重写或转译。(少数人)
  • 需要引用时,只用一句中文意译: > 啊,又一个仍然模拟核心系统组件的重编译。(@krystalgamer)

担心 AI 重编译潮助长盗版分享并削弱创作激励

  • 有人质疑,在 AI 使能的大量重编译出现后,游戏工作室投入心血、灵魂和资金做产品的动力还剩多少,因为作品很容易被“窃取”并免费分享;随后有人追问是否存在 clean-room(清洁室)游戏,反映出对合法重实现路径的疑问。(少数人)
  • 需要引用时,只用一句中文意译: > 当作品能轻易被“窃取”并免费分享,工作室还有什么动力投入心血、灵魂和资金?(@Elizer0x0309)

关注重编译后的性能与时序敏感硬件的正确性

  • 有人好奇静态重编译如何处理 PS2 的时序敏感硬件(如向量单元),以及在不逐周期模拟的情况下能接近多少正确行为;也有人称赞项目不错,并追问在更吃配置的游戏上的性能表现(提到 3DS 竟能承担 PS2 模拟)。(少数人)
  • 需要引用时,只用一句中文意译: > 很好奇在不逐周期模拟的情况下,静态重编译要如何处理向量单元这类时序敏感硬件,又能接近多少正确行为。(@danxbuild)

调侃《Gran Turismo》不该去掉漫长 CD 加载画面

  • 有人开玩笑反问谁会想玩没有漫长 CD 加载画面的《Gran Turismo》,调侃重编译可能把经典的长加载等待也“优化”掉,代表了一种对 PS2 时代加载屏的怀旧玩笑。(少数人)
  • 需要引用时,只用一句中文意译: > 谁会想玩没有漫长 CD 加载画面的《Gran Turismo》呢?(@eluusive)

有价值的补充

  • 有开发者做过类似的 PS Vita 移植:构建了运行在 WASM 中的 ARM-to-WASM 转译器,并转码 WebGPU 着色器、音频等;目前只测试了少数游戏,但在旗舰安卓手机的 Chrome 移动端已能相当好地运行,说明“转译器 + 资源转码”路线具备可复制性。(@kodablah)
  • 有建议把重编译编译器本身编译成 WebAssembly,这样工具能托管在任何地方,用户可直接从浏览器里转换整个项目,降低本地构建门槛。(@CodeCompost)