跳转至

架构

Tears of the Kingdom 的 Switch 版本如何变成原生 Windows x86-64 程序。它所依赖的上游项目列在 README 的 Credits 部分。

起点

两个 MIT 项目分别覆盖了问题的一半:

LAS Recomp MacNativeGames
游戏 Link's Awakening Tears of the Kingdom 1.0.0–1.4.3
CPU ARM64 提升为 C,在 x86-64 上运行 原始 ARM64 代码直接在 Apple 芯片上运行
操作系统边界 IPC 服务和 SVC ~163 个 nn::* SDK 函数按符号替换,38 个 SVC
图形 自有的 Vulkan NVN 层、自有的 Maxwell 翻译器 NVN 前端 + Metal 4 后端、Ryujinx 翻译器
对我们有用的 提升器、NEON 到 C 的降级、Windows 内存层、测试 所有 TotK 专属内容:主机行为、NVN 前端、版本表、补丁、着色器列表

MacNativeGames 不能按原样在 x86-64 上运行:它的 CPU 路径直接执行 ARM64 代码。LAS Recomp 的主机端只覆盖 Link's Awakening 所调用的内容。这个移植项目从 LAS Recomp 取 CPU 一半,从 MacNativeGames 取游戏一半,并补上两者都没有的部分:一个能扩展到 TotK 规模的提升器,以及一个用于 NVN 前端的 Vulkan 后端。

流水线

1. 输入

  • 基础版本 1.0.0(0100F2C0115B6000)和一个累积更新(0100F2C0115B6800),NSP 或 XCI,以及用户主机的密钥。
  • ExeFS 和 RomFS 提取:MacNativeGames 的 Python 提取器(Python 3.14 用于 compression.zstd,.venv-py314)。
  • 更新合并:位于 1d64a83 的 hactool,配合 MacNativeGames 的 BKTR 补丁,由 tools/build_hactool.py 构建。
  • 验证:MacNativeGames 的 expected-v*.json 对 ExeFS、RomFS 和字体的哈希。
  • 主要目标版本:1.4.3,即最后一个更新;性能工作针对它进行。它替换了之前更新的全部三个模块(新的 SDK 20.5.22、RELR 重定位、音频渲染器 REV15),并使用 --version v143、recomp/patches-v143.txt 构建。1.2.1 是同一构建路径上的第二个目标;1.0.0、1.1.2 和 1.2.1 的 sdk、subsdk0 和 rtld 逐字节相同,因此其中只有 main 不同。存档在两者上都能加载。
  • Windows 驱动流程:tools/extract.py 运行随附的提取器(tools/vendor/macnativegames/),并通过其 main 的 SHA-256 识别更新。这里的 hactool 没有 Darwin 的 funopen,因此基础 Program NCA 在 BKTR 合并之前从 NSP 中复制出来(需要约 17 GB 临时空间)。

2. CPU:将 ARM64 提升为 C

  • 模块:main、sdk、subsdk0。rtld 被丢弃,链接由主机端完成,和 MacNativeGames 的做法一样。
  • 基础:LAS Recomp 的手写解码器(lifter.py)和 NEON/FP 降级(simd_c.py、simd.h),MIT。输出:每个客户机函数对应一个 C 函数,BL 使用直接调用,间接分支使用以地址为键的表。
  • 为 TotK 规模重写:
  • 生成器在并行进程中运行,每个 worker 的内存有上限;
  • 跳转表由我们自己的符号化传递解析,而不是 Unicorn,从而避免 GPL 代码进入随附的生成器;
  • 支持 LDXP/STXP(128 位独占对);
  • 不再对 main 施加 64 MB 限制;加载 subsdk*;
  • NZCV 和每个函数中使用最多的六个通用寄存器保留在 C 局部变量中,而不是 Cpu 中(recomp/flags.py、recomp/regs.py):寄存器在它们被修改的地方写回,在调用、SVC 和退出之前,并在它们之后重新加载。调用只重新加载 X0-X18 和 X30,因为 AAPCS64 要求被调用者保留 X19-X29 和 SP;gen.py --no-callee-saved-trust 也会重新加载这些寄存器。
  • 覆盖判定器:MacNativeGames 的 NSO 重写器列出了游戏代码触及操作系统的每个位置(SVC、TLS 读取、计数器读取、x18、独占区间、跳转表):1.4.3 的 main 中有 68,968 个位置。提升器必须处理每一个。
  • 内存:客户机地址等于主机地址,全部低于 2^39(IPC 编码 39 位地址)。堆、栈和别名区域以 MEM_WRITE_WATCH 保留,因此图形后端可以询问 CPU 自上次上传以来写了哪些页面(runtime/memory.c)。
  • 间接分支通过 rt_dispatch(runtime/dispatch.c)进行,它在地址表中查找目标并尾调用它。
  • 测试:LAS Recomp 的差分测试框架,每一个被提升的指令类别都对照 Unicorn 检查(仅测试用,从不链接进游戏)。

3. 主机端

  • 来自 MacNativeGames 的可移植 C 代码:线程与同步、IPC、内存服务、fs/RomFS、时间、账号、applet、音频渲染器和效果。Apple 相关部分(mach VM、CommonCrypto、AudioToolbox、GameController)被替换为 Win32 和 SDL3。
  • SDK 边界在 nn::* 函数级别,与 MacNativeGames 一致。只有 audren:u 和 sm: 是真正的 IPC 服务。
  • 窗口、键盘和鼠标:Win32(gfx_window.c、input_win.c)。手柄和音频输出:SDL3。语音:libopus(BSD)。影片:SDK 的 movie::Decoder 被替换为基于 libvpx 的主机端 VP9 解码器(BSD,静态;runtime/ov_movie.c),因为来自 MSYS2 的 FFmpeg 是 GPL 构建。
  • zstd:1.4.3 的大多数 RomFS 文件是 zstd 帧。zstd 补丁集将游戏的两个解码器入口点指向主机端的 libzstd(ov_zstd.c、TOTK_ZSTD_NATIVE);被提升的解码器耗时几乎是其三倍。
  • 设置和菜单:每个键及其默认值都放在一张表中(cfg.c),从 totk.ini 和 TOTK_* 变量读取,并在游戏内菜单更改时写回。菜单和性能覆盖层使用 Dear ImGui,绘制到交换链图像中(runtime/ui/);其文本位于 ui_text.c(英文、俄文)。
  • 主机端对 OS 的调用通过 runtime/platform/(platform.h、win32.c)进行,因此 Linux 或 macOS 主机只需新增一个文件,而不用改动其余文件。拆分尚未完成:tools/check_platform.py 列出了其他文件中剩余的 Win32 用法。

线程与核心

每个客户机线程都是一个主机线程。TotK 假设 Horizon 的调度器:三个应用程序核心,一个线程只在其亲和性掩码内的核心上运行,更高优先级的线程会从较低优先级线程那里抢占核心。游戏会无锁地维护每核心数据并依赖这一行为,因此主机端对其建模(runtime/kernel.c):

Three virtual cores. 客户线程仅在持有核心时运行客户代码;其他线程在按优先级排序的列表中等待。 - 抢占。当等待线程在它可能使用的核心上具有比持有者更好的优先级时,恰好一个持有者被标记。持有者在下一个 SVC 和每次间接调用时检查该标志(rt_dispatch:当没人等待时两次加载),在内核锁下再次检查更好的线程是否仍在等待,然后才移交其核心。间接调用检查来自 SavageWindRecomp 的 BotW 移植;仅在 SVC 检查时,更好的线程等待了长达 26 ms。 - 异步抢占(来自 MacNativeGames 8bf0893,除非 TOTK_ASYNC_PREEMPT=0 否则开启)。游戏代码可以运行毫秒级时间,既无此也无彼(TOTK_CORE_DIAG=1 测量它):一个旋转了 250 us 的等待者挂起持有者,如果其主机 rip 位于提升代码中且主机深度为 0(不在主机调用或 SVC 内),则将其移动到 kern_ap_stub,它保存易失寄存器并在那里放弃核心。客户互斥锁的持有者以其最佳等待者的优先级计数,而处于 SDK 自旋锁(TLR+0x100 disable_count)内的持有者不会被抢占:其中断标志被设置,切换发生在 SVC 0x36 处。TOTK_ASYNC_PREEMPT=2 保持这两者而不中断。中断是 Win32(SuspendThread、SetThreadContext);另一个主机需要一个信号处理器,如 MacNativeGames 所做的那样。 - 主机工作归还核心。队列提交、呈现、GPU 等待、着色器翻译、nn::fs 调用和 NVN 内存池设置在没有核心的情况下运行(NVN_BLOCKING、kern_core_away),因为 Switch 在 IPC 中阻塞这些线程,所以阻塞在驱动程序或文件上的线程不会阻塞客户线程。 - 自旋等待。只有另一个线程才能结束的循环(1.4.3 中有 857 个,1.2.1 中有 864 个,由 tools/spin_waits.py 找到,nxnative 搜索)将其回边通过 rt_spin_gate 发送,后者每轮暂停核心一次,并在 50 us 轮询后移交给等待者(TOTK_SPIN、TOTK_SPIN_HANDOFF_US)。 - TOTK_FREE_CORES=1 关闭门,用于实验。

4. 图形

  • 来自 MacNativeGames 的 NVN 前端(nvn_api_*.c、nvn_obj.h、纹理布局和解 Swizzle)。游戏通过它记录命令缓冲区;在游戏提交之前,没有任何东西到达 GPU。
  • 用 Vulkan 1.3 后端替代 MacNativeGames 的 Metal 后端。在 QueueSubmitCommands 时,记录的 NVN 命令被重放到 Vulkan 命令缓冲区中(vk_exec*.c)。
  • 内存池。Apple 的统一内存让 MacNativeGames 可以零拷贝地使用游戏的内存池。后端将内存池作为主机内存导入(VK_EXT_external_memory_host)。在独立 GPU 上,作为缓冲区绑定的内存池被镜像到设备本地内存中,CPU 写入的页面(写监视,见上文)在重放前上传;空闲的内存池区域被设置为只读,以便写监视查询跳过它们(vk_memory.c)。GPU 渲染的纹理在 CPU 读取时写回:在内存池失效时,在 CPU 复制时,对于小目标,一旦 CPU 等待栅栏(vk_texture.c)。
  • 重放线程。TOTK_REPLAY_ASYNC=1 将重放从提交线程移到一个最多落后游戏一帧的队列;GPU 结果的读取等待最新的写入者,而不是排空队列(vk_backend.c)。默认关闭,因为目前没有计时显示增益。
  • 记录器线程。重放对其命令缓冲区进行的 Vulkan 调用由单独的线程发出(vk_rec.c,继 Savage Wind Recomp 和 LAS Recomp 之后):每个 vkCmd* 将其参数复制到环形数据包中,记录器按顺序发出调用;对其他命令缓冲区的调用立即发出,提交等待环形缓冲区为空。在 RX 9070 上的城镇中,重放在提交线程上花费的时间减少了 17%。TOTK_VK_RECORDER=0 使每个调用都在提交线程上进行。
  • 统一快照。游戏在提交读取它们的帧后不久,就在其他线程上重写每对象统一块,而重放仍在记录它。在提交的重放开始时,它从 CPU 写入的内存池绑定的每个统一范围都被复制,绘制绑定副本(vk_usnap.c,继 MacNativeGames 的 TOTK_UBO_SNAPSHOT 之后);没有它,延迟绘制会读取另一个对象的矩阵。
  • 驱动程序数据。NVN 驱动程序维护一个每阶段常量库(c0),着色器读取它来获取点大小、调度大小和基础实例等。后端像驱动程序一样填充它。
  • 着色器:Ryujinx 转换器 1.3.2,带有 MacNativeGames 的补丁和我们的补丁(tools/xlate/patches/),使用 .NET 10 NativeAOT 构建为 totk_xlate.exe,并作为独立进程运行。其 SPIR-V 直接到 Vulkan。tools/prepare_shaders.py 和 totk.exe --prepare-shaders 提前转换和编译 MacNativeGames 共享列表的变体(1.4.3 有 29,587 个);其余的在首次使用时转换并缓存在磁盘上。
  • 管线:驱动程序支持 VK_EXT_graphics_pipeline_library。着色器阶段被提前编译成库;满足新状态的绘制立即链接它们(快速链接),当后台线程完成时,优化管线替换链接的管线。在游戏中看到的管线键被保存并在启动时构建。管线的绘制在其通道的目标在上一帧渲染时被跳过,否则等待,因此一次性通道永远不会丢失;全屏绘制也被跳过而不是等待(TOTK_PIPE_SKIP);等待绘制所需的阶段库在空闲工作线程上并行构建(TOTK_PIPE_WAIT)。没有扩展名时,缺失的管线在工作线程上构建,绘制等待或按相同规则跳过。
  • 分辨率。补丁集使游戏从补丁页读取其输出、交换链和场景大小(MacNativeGames 的站点);resolution.c 从 output_res 和 render_scale 设置它们,并扩大纹理堆和应用池以匹配。当窗口大于输出时,vk_fsr.c 在呈现时运行 AMD FSR 1(EASU、RCAS)。
  • 遮挡查询永远不会阻塞 CPU;未就绪的结果计为可见。
  • ASTC 纹理在设备报告 textureCompressionASTC_LDR 时原样上传,在其他地方通过计算着色器解码(vk_astc.c、TOTK_ASTC)。CPU 解码器(tex_astc*.c)是回退和测试预言机。
  • 绘制状态。程序、绑定状态和纹理重复的绘制重用先前绘制的着色器,并跳过重建其描述符集;描述符集通过更新模板写入(vk_exec_draw.c、vk_pipeline.c)。
  • 分配:Vulkan 内存分配器。调试:Vulkan 验证层、每通道帧转储(TOTK_DUMP、vk_dump.c)和飞行记录器(TOTK_FLIGHT)。RenderDoc 无法捕获,因为它对应用程序隐藏了 VK_EXT_external_memory_host。

后端必须匹配的 Maxwell 行为,以及其他移植版本修复的 TotK 缺陷:maxwell.md。

为什么选择 Vulkan 而不是 D3D12 或 wgpu:

  • 翻译器生成 SPIR-V,Vulkan 可直接使用。D3D12 会增加一步 SPIR-V 到 DXIL 的转换;wgpu 会通过 Naga 将 SPIR-V 转换为 WGSL,并丢弃游戏所用的一些特性(无绑定纹理、某些原子操作)。
  • LAS Recomp 的 Vulkan 后端在 Windows 上是可用的参考实现。
  • 同一后端可运行在 Linux 上。

5. 工具链

  • 生成的 C:MSYS2 UCRT64 Clang + LLD。生成的代码使用了 GNU 扩展(__int128、musttail、向量类型),cl.exe 无法编译这些代码。
  • 宿主代码:同样的 Clang,C11。
  • 构建:tools/build_game.py 写入 Ninja 文件并运行 MSYS2 Ninja。每个目标文件在链接前都会进行哈希校验,因为一条有故障的 RAM 模块曾导致存储的目标文件发生位翻转。
  • 分析:使用带 Switch 加载器的 Ghidra、Capstone、llvm-objdump。
  • 玩家:安装程序(installer.md)会在玩家机器上使用随附的 llvm-mingw 构建游戏代码,因此从不分发任何重新编译的代码。

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 to/totk-pc-recomp