主机模拟器这条路走了十几年,套路基本固定:给每个线程分配一块 guest 内存,遇到跳转就翻译指令,遇到系统调用就查表转发。慢,但通用。

AnyPS5 选了另一条路。它的 README 第一句话就交代了立场:把 PS5 可执行文件转换成目标系统的原生格式,配上自己重写的系统库,动态链接进去。没有模拟器,没有独立的运行时进程。这是重链接(relink),不是解释执行。

读它的源码目录,1931 个文件铺开来,能看出这不是玩具工程。我花了一晚上翻 core/relinker 和 core/libs/nid 两个目录,这里记录一下它是怎么把一份 PS5 ELF 变成 Linux 原生可执行文件的。

PS5 的导出表里没有符号名

这是理解整个项目的前提。

在普通 ELF 里,动态链接靠符号名匹配。你调用 printf,链接器在 .dynsym 里找到那个叫 printf 的表项,填上地址。名字就是键。

PS5 不用名字。它的每个导出函数有一个 NID(Name ID),是一个 11 字符的字符串,比如 7CxI50-xlCk、pMxXhNozUX8。你在反汇编里看到的调用目标全是这种乱码,没有任何语义信息。

那 NID 是怎么算出来的?core/libs/nid/src/NidCompute.cpp 只有 54 行,把答案写得很清楚:

constexpr uint8_t kNidSuffix[16] = {
    0x51, 0x8D, 0x64, 0xA6, 0x35, 0xDE, 0xD8, 0xC1,
    0xE6, 0xB0, 0x39, 0xB1, 0xC3, 0xE5, 0x52, 0x30
};

std::string ComputeNid(const std::string& symbolName, const std::string& libraryName) {
    std::vector<uint8_t> input(symbolName.begin(), symbolName.end());
    for (uint8_t b : kNidSuffix)
        input.push_back(b);

    const auto digest = Sha1(input);

    uint8_t reversed[8];
    for (int i = 0; i < 8; i++)
        reversed[i] = digest[7 - i];
    // ... base64 编码成 11 字符
}

流程拆开就是四步:

  1. 把函数名和那 16 字节固定后缀拼起来
  2. 对这串字节算 SHA-1
  3. 取摘要的前 8 个字节,反序排列
  4. 用一个自定义的 64 字符表(A-Za-z0-9+-,注意最后两个是 + 和 -,不是标准的 +/)做 base64,截到 11 个字符

所以 NID 不是随机数,它是函数名的单向哈希。这里有个关键的工程推论:你没法从 NID 反推出函数名,但你可以拿着函数名正推 NID。

AnyPS5 的整套符号解析就建立在这个不对称上。它维护着一份已知 PS5 系统函数的名字清单,逐个算 NID,建一张 NID 到函数的映射表。重链接时遇到一个 NID,查表就知道它对应哪个函数,然后把它接到自己重写的实现上。

这也是为什么项目的 TechnicalDebt.md 里到处出现 shadPS4 aerolib NID list、SharpProspero、OpenAGC、KytyPS5 这些名字。PS5 系统库有上万个导出函数,签名大多不公开,社区靠不同项目互相印证来逐步补齐这份清单。AnyPS5 新定义函数的依据,往往就是"shadPS4 的 NID 列表里它是这么命名的"加"SharpProspero 的绑定里参数是这个顺序"。两个独立来源对上了,才敢下笔。

Relinker 干的四件事

core/relinker 是重链接器的本体,命令行接口就一个:

relinker [options] <input.elf> <output>
relinker --windows source/input.elf app.exe      # 输出 PE
relinker --to-intel source/input.elf app.elf      # 兼容 Intel 主机

输入要求是一份干净的 PS5 ELF,旁边的 sce_module/ 或 prx/ 目录里放它捆绑的模块。输出的默认格式是 Linux ELF,即使文件名叫 .exe 也不会自动切换,要显式加 --windows。

它内部做的事,我按依赖关系理了一下:

第一件,重定位。 PS5 的 ELF 有自己的重定位类型和动态段结构。ElfPatcher.hpp 里把 Elf64_Ehdr、Elf64_Shdr、Elf64_Sym、Elf64_Rela、Elf64_Dyn、Elf64_Verneed 这些结构体全部重新定义了一遍,不是拿现成的 libelf,而是自己按字节布局解析。重链接要改写这些段的内容,塞进目标格式能理解的重定位项。

第二件,NID 解析与导入改写。 这是 core/libs/nid 的活。ElfNidPatcher.cpp 有 21KB,PeNidPatcher.cpp 有 13KB,分别处理 ELF 和 PE 两种目标的导入表。流程是:扫描输入 ELF 的导入表,每一条是一个 NID 引用,查表解析成真实函数名,再在输出格式里生成一条以函数名标识的导入项,指向 AnyPS5 自己提供的 libSce*.prx。

第三件,未使用导入的裁剪。 这就是命令行里那几个 unused-filter=0|1|2 的含义:

值 行为
0 保留全部 NID 引用(默认)
1 用控制流和 GOT 访问分析,过滤掉没用到的非 PLT 导入,保留 PLT
2 严格分析,同时压缩 PLT;遇到分析不了的情况直接报错

模式 1 靠"这段代码有没有真的跳到过 GOT 项"来判断导入是否死代码。模式 2 更激进,把 PLT 表项也一起重排。默认给 0 是有道理的,激进裁剪会让一些通过函数指针间接调用的导入被误删。

第四件,AMD 专有指令的下沉(lowering)。 这是我认为项目里技术含量最高的部分。

--to-intel:把 AMD 指令改写成 Intel 能跑的

PS5 用的是 AMD 的 Zen 2 架构。它的二进制里可能出现一批 Intel 上没有的指令:CLZERO、RDPRU、MONITORX、MWAITX、MCOMMIT,还有 SSE4a 扩展的 EXTRQ、INSERTQ、MOVNTSS、MOVNTSD。这些指令在 Intel 主机上会直接触发非法指令异常。

--to-intel 就是解决这个的。core/relinker/codegen 目录下是一整套 x86 指令解码器和改写器:

X64InstructionDecoder.hpp    /  X64InstructionRewriter.hpp
Amd64OnlyInstructionMatcher.cpp   (11KB)
ClzeroLowering.cpp           Sse4aLowering.cpp      (8KB)
Sha1Lowering.cpp   (7KB)     Sha256Lowering.cpp     (7KB)
ReciprocalLowering.cpp

工作原理是静态扫描代码段,逐条解码指令,匹配 AMD 专有条目,然后在原地替换成等价的 Intel 序列或一段 stub。几个具体的替换策略在 Amd64OnlySubstitutionTable.hpp 里能看到:

AMD64_STUB(LeaRspBelowRedZone, 0x48, 0x8D, 0xA4, 0x24, 0x70, 0xFF, 0xFF, 0xFF)
AMD64_STUB(Pause, 0xF3, 0x90)
AMD64_STUB(Nop7, 0x0F, 0x1F, 0x80, 0x00, 0x00, 0x00, 0x00)

Pause 很简单,F3 90 就是 PAUSE,Intel 上原生支持,直接用。NOP 的 1 到 7 字节变体也都备齐了,因为原地替换时新序列长度必须和原指令严格对齐,多出来的字节用对应长度的 NOP 填。

有些指令没法直接等价替换,就得下沉成一段展开的 stub。CLZERO(清空一整条 cache line)在 Intel 上要拆成一串 SIMD 存储;SHA1RNDS4、SHA256RNDS2 这些 AMD 的 SHA 扩展指令,要展开成普通的移位、异或、加法序列来实现同样的轮函数。Sha1Lowering.cpp 和 Sha256Lowering.cpp 各有 7KB,基本就是在用标量指令重新实现一遍哈希轮运算。

改动之前,扫描器要先收集所有分支目标(_collectBranchTargets),确保指令边界不会和跳转目标冲突。改错一个字节,整个代码段就崩了。

这套东西的边界也写得很直白:不支持的指令,或者改写后产生的不可达 stub,直接抛异常,不会给你生成一个能编译但会崩溃的二进制。

着色器:从 RDNA2 汇编到底层 Vulkan

游戏渲染绕不开 GPU 指令。PS5 的 GPU 是 RDNA2,着色器是编译好的 GCN 汇编。要转换到原生平台,就得把它重新编译成目标 GPU 能执行的指令。

core/shader/recompiler 是一条完整的编译流水线:

  • RdnaDecoder 解码 RDNA2 标量/向量指令
  • ControlFlow/ 从指令流重建控制流图,GraphBuilder.cpp 有 36KB,Structurizer.cpp 有 67KB,负责把 flat 的跳转结构还原成带分支和循环的中间表示
  • IntermediateRepresentation/ 是 IR 层,IrOpcode.hpp 定义了全套操作码
  • 后端把 IR 生成 SPIR-V,用 Spirv-Tools 校验(需要编译时开 ANYPS5_ENABLE_SPIRV_TOOLS)

TechnicalDebt.md 里记了一个很典型的表现差异。如果目标环境不支持 VK_KHR_fragment_shader_barycentric 扩展,重编译器会丢弃 v_interp_p1_f32 指令,而 v_interp_p2_f32 会退回去读 Vulkan 插值出来的属性值。对于只用 v_interp 的普通像素着色器,结果一样;但如果着色器拿重心坐标去做别的事,拿到的值就是错的。这种地方往往是画面出现细微瑕疵的根源,而且极难定位。

系统库重写:宁抛异常,不静默打桩

模拟器最坑的一点是"看起来能跑"。一个函数没实现,打桩返回个成功,游戏继续跑,跑到某个关键路径突然崩,或者更糟——存档写坏了。

AnyPS5 对这件事的态度写在 TechnicalDebt.md 的开头:每一个阶段的每一个函数,要么做它该做的事,要么抛异常。 std::runtime_error,what() 打到 stderr,进程终止。

这份文档本身值得一读,因为它把"哪些地方破例了"一条条列出来。目前唯一的例外是一批对话框类库:

  • libSceSaveDataDialog、libSceMsgDialog、libSceCommonDialog、libSceErrorDialog、libSceLoginDialog:状态机照跑,但不显示任何 UI,登录对话框直接返回"用户取消"
  • libSceHmd:只实现"头显未连接"这条路,初始化成功但查询一律返回 NotDetected
  • libSceAudio3d:不出声,sceAudio3dPortPush 只是按帧节奏排队,不产生音频
  • libSceHttp:没有任何请求会真正到达网络

这些破例的选择很务实。对话框不显示不影响游戏逻辑,头显没接就当没接。但音频、网络这些会改变游戏行为的,宁可报错也不能假装成功。

重写粒度细到什么程度?看 libSceAgc(PS5 的图形命令缓冲库)的目录结构就懂了:DcbDraw/、DcbFlow/、DcbState/、Command/、Patch/、Predication/、Shader/ 分门别类,Command/src/Memory.cpp 9KB、Packet.cpp 8KB,连 ShaderFusion.cpp(着色器融合,18KB)都实现了一份。这不是"能跑就行"的桩,是照着硬件行为重写。

目前的实际状态

项目在 README 里挂了三张自动生成的进度图(系统库覆盖率、着色器覆盖率、进度地图),并且很诚实地标注了一句:系统库百分比指的是"项目目前已知的函数"里已实现的比例,不是 PS5 全部系统函数。已知集合本身还在涨。

兼容性列表就一款游戏:

游戏 ID Windows GTX 1050 Ti / i5-7500 Intel HD 620 / i5-7200
Dreaming Sarah PPSA02929 可玩 60 FPS 36 FPS

一款 2D 平台跳跃游戏,中端独显上稳定 60 帧。这是很好的成绩,但离"跑 3A"还很远。HN 上那条标题说的 87%,指的就是这张进度图里的系统库覆盖率,且要按上面那句注释来理解。

构建层面的坑也在文档里:Windows 上需要特定版本的 MinGW(MinGW-w64 GCC 15.2.0,x86_64-ucrt-posix-seh),而且即使 prx 库编译出来了,运行时还得把 libgcc_s_seh-1.dll、libstdc++-6.dll、libwinpthread-1.dll 放在旁边。

一个做底层的视角

AnyPS5 值得关注的不是"能不能玩 PS5 游戏"这个结果,而是它的方法论:把跨平台问题拆成重链接问题,而不是执行问题。

模拟器要维护一整套 guest CPU/内存/内核模型,任何一处和硬件行为不一致都会变成诡异的兼容性 bug。重链接器换了个思路,直接产出目标平台的原生代码,指令是原生执行的,没有翻译层。代价是静态改写必须足够彻底——AMD 专有指令要重写,着色器要重编译,系统库要逐个重写。一旦某个环节分析不了,就只能报错,没有"先跑起来再说"的退路。

这种"要么正确,要么显式失败"的工程纪律,比它现在能跑几款游戏更值得学。

附录:关键文件索引

如果你也想翻源码,这几个入口性价比最高:

  • core/libs/nid/src/NidCompute.cpp:54 行,NID 算法全部
  • core/libs/nid/src/ElfNidPatcher.cpp:ELF 导入表的 NID 解析与改写
  • core/relinker/codegen/include/codegen/x86/Amd64OnlySubstitutionTable.hpp:指令替换表
  • core/relinker/codegen/src/Amd64OnlyConverter.cpp:指令扫描与原地改写
  • docs/dev/TechnicalDebt.md:所有未实现/未验证的函数清单,信息密度极高
  • docs/user/USAGE.md:relinker 全部命令行选项与运行时目录布局

仓库:github.com/boykopovar/AnyPS5(GPL-2.0)。