前情提要

最近刚入坑了 RimWorld,在 Mac 上玩同时还打几个工具模组。发现在 1.6 游戏版本中,Harmony(Mod 框架,也是大多数其他 Mod 的前置)不能正常工作。

稍微调研一番后发现 RimWorld 从 1.6 版本开始为 macOS 提供了 ARM64 的 binary,而恰巧 Harmony 没有提供 macOS ARM64 支持。不过好在我对 Untiy/.NET 略有了解,于是想着能不能自己上手修一下。。。

此文就顺着我的 Mod Debug 流程,粗略总结半篇 Unity Mod 开发入门。

.NET Core, .NET Framework and Mono

要想开始 Unity Modding,得先搞清楚 .NET 中几个基本概念。

.NET Framework 是微软最开始于 2002 年为 Windows 开发的 .NET 平台,而 .NET Core 是微软于 2016 年推出的开源、跨平台的 .NET。

虽然 .NET Framework 如今已进入维护模式,但是 .NET Core 并不能完全成为 .NET Framework的开源跨平台替代。比如,.NET Core 中就不包含桌面应用开发所需要的组件。

Mono: 一个在 .NET Core 推出以前,就为 .NET Framework 提供的开源跨平台的解决方案。

不过目前 Unity 实际上基于的是 .NET Framework 而不是 .NET Core,并使用了 Mono (Fork) 作为 C# Script 的 Runtime。

Emm,.NET 生态有属实是有点混乱。。。

Harmony & MonoMod

如前文所述,Harmony 是一个 Mod 框架,提供了许多方便的运行时方法修改的工具。
Mod 作者只需要找到目标函数(Functions/Methods)的签名,就可以使用 Harmony 对其进行修改,比如改写整个函数或者在原函数前后注入代码。

当然,懂 .NET 或者 Java 的小伙伴大概能猜测出 Harmony 是怎么个原理:先使用反射找到目标函数,再用某种方法把原函数替换为自己的代码。

不过 Harmony 其实并没有直接与 Runtime 底层打交道,而是把替换函数代码的任务交给了 MonoMod
因此 Harmony 自身的纯 C# 代码一般并不会有跨平台兼容性问题,我得转向探究 MonoMod。

果不其然,MonoMod 那边也已经有人提了对于 Apple silicon 支持的 issue
可惜,讨论内容对于我一个天天写 Web 的而言还是太底层了,看的云里雾里。不过好在有大牛正好在5天前完成了集合了相关补丁的实现并提了 PR

那么,这时候我需要做的就是将 PR 中的分支 Clone 下来,用这个修补过后的 MonoMod 作为 Harmony 的依赖,重新构建 Harmony。

本地依赖 Patch

Clone 完两个仓库后,需要先将 Harmony 的 MonoMod.Core 依赖替换为本地的版本。
项目 *.csproj 文件修改如下:

1
2
<!--<PackageReference Include="MonoMod.Core" Version="$(MonoModCoreVersion)" PrivateAssets="all" />-->
<ProjectReference Include="../../MonoMod/src/MonoMod.Core/MonoMod.Core.csproj" PrivateAssets="all"/>

也就是将本来的 NuGet 包引用替换为本地项目引用。

最好同时将这两个外部项目添加进 Harmony.sln 解决方案文件中,不然 IDE (e.g. Rider) 可能会不太高兴。“最好”是因为即使不添加,dotnet cli 还是能正确找到对应的引用。

1
2
3
4
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MonoMod.Core", "..\MonoMod\src\MonoMod.Core\MonoMod.Core.csproj", "{0829DEC7-6FDE-41A4-88DB-324EB43FE7F4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MonoMod.Utils", "..\MonoMod\src\MonoMod.Utils\MonoMod.Utils.csproj", "{DFAC85FF-13A6-41D8-82D6-B2F9AB6632D6}"
EndProject

确定 .NET 版本

如前文所述,一般 Unity 使用的是 .NET Framework。

我通过参考 HarmonyRimWorld 项目配置文件 中的 Target Framework 字段为 net472,得知 RimWorld 目前具体使用的是 .NET Framework 4.7.2。在 RimWorld Wiki 中也有相关说明。

因此在非 Windows 平台上构建时,除了 dotnet 工具链,还需要 Mono。Mac 上使用 Brew 安装即可。

1
brew install mono

使用 dotnet build -c Release --framework net472,指定所需构建的 .NET 版本。这能简化像是 MonoMod 或者 Harmony 这样的多 Target Frameworks 项目的构建过程。

构建没出错的话,我们 Mod 所需要的 0Harmony.dll 二进制文件应该会位于类似 bin/Release/net472 的目录下。

Debug

把 Harmony Mod 文件夹中的 DLL 替换后,运行游戏,又有了新的问题。
Mod 加载时抛出异常:System.IO.IOException: Invalid handle to path "[Unknown]"

那看来这个 PR 可能还不太完美。。

为了先确定 Bug 位于游戏侧还是 Mod 侧(游戏侧Debug显然会更麻烦),我跑了一下 Harmony 的单元测试 dotnet test --framework net472
幸运的是,在单元测试中都出现了相同的异常。

我使用了 Rider 的 Debugger 来排查异常。由于 Rider Debugger 暂不支持 macOS ARM64 上的 Stepping into external methods。这时候就凸显出把 MonoMod 添加到解决方案中的意义了————添加到解决方案中后 Debugger 对于 MonoMod 中的代码也能正常 Debug 了。

对单元测试进行 Debug 后,发现源于一个 Mono 的历史遗留问题,mono/mono/issues/12783

#12783: FileStream does not work with P/Invoked file descriptor and SafeFileHandle

Issue 大致内容:SafeFileHandle 无法处理对非 Mono 运行时内部创建的文件描述符(fd)

而 PR 中的代码使用了外部 native 库 libSystem 的创建的文件描述符。
那么,解决起来也比较容易,把 SafeFileHandle 替换为 native 的处理方式。

好在 PR 的代码没有其他明显的 Bug,重新构建后,Harmony 也总算成功在 ARM Mac 上正常工作了。。。

为了避免其他 macOS 玩家遇到和我一样的问题,我把 Patch 后的 Mod 上传到了 Steam Workshop
毕竟等 PR 合并入 MonoMod 主线,Harmony 开发者更新依赖大概还要很久。。

MonoMod 是怎么运作的?

虽然 Mod 貌似是正常运作了,可是搞不清楚 PR 的原理,我又该怎么放心使用呢?而要想看懂PR,得先搞懂 MonoMod Core的大致原理。

这里放上一篇介绍如何在 Run-time 时修改 IL 的文章:.NET CLR Injection: Modify IL Code during Run-time

文章概要:
由于目标方法很可能已经被JIT编译为机器码,修改 IL 已经无用了。因此,可以通过 Hook 运行时的 ICorJitCompiler::compileMethod 方法,然后调用内部方法 MethodDesc::Reset 重置目标方法的所有状态(重置为未JIT状态)。此后目标方法触发JIT编译时(通常为被调用时)就会调用被 Hook 的 compileMethod,此时也就可以篡改 IL 了。

有了这篇文章作为理论基础,我就用 Debug 大法大致摸清了流程。由于过程较为枯燥无味,这里我也就只说原理或思路。。

MonoMod 所用的方法与上面文章说的方法有些许不同。MonoMod(仅限在Mono上,各个Runtime实现有许多差异)并不通过 Hook compileMothod 修改 IL。MonoMod 的 Detour(改道)需要传入源方法 A 以及目的方法 B 的 MethodInfo(反射产物)。Detour 过程也就是让方法 A 跳转为方法 B。

先通过 MethodInfo 拿到 A 和 B 的 JIT 后的(Mono 运行时在获取函数指针时会进行JIT编译) native 的函数指针。

1
MethodInfo.MethodHandle.GetFunctionPointer();

此后就需要用一点汇编了,构造一个无条件跳转到 B 函数体头部的指令。汇编代码为:

1
2
3
4
5
6
7
8
.text
.global _main

_main:
ldr x9, _target ; 伪指令,将 _target 标签的地址加载到 x9 寄存器
br x9 ; 无条件跳转到 x9 寄存器的地址

_target: .quad 0x0 ; 地址占位符,8个字节

将编译后的.text段导出即为所需要的二进制指令。以 Hex 形式载入 C# 代码中大概长这样:

1
2
3
4
5
6
7
ReadOnlySpan<byte> stubData = [
0x49, 0x00, 0x00, 0x58, // ldr x9, _target
0x20, 0x01, 0x1F, 0xD6, // br x9
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // _target: .quad 0x0
];
stubData.CopyTo(detourData);
Unsafe.WriteUnaligned(ref detourData[8], (ulong)to); // to 为 B 函数指针

现在 detourData 中就存放好了用于从 A Detour 到 B 的指令。

之后一般使用纯 C# detourData.CopyTo(target) 就可以将指令写入 A 函数体头部了。

然而,因为苹果在 Apple silicon 设备上默认开起了内存保护:

When memory protection is enabled, a thread cannot write to a memory region and execute instructions in that region at the same time. Apple silicon enables memory protection for all apps, regardless of whether they adopt the Hardened Runtime. Intel-based Mac computers enable memory protection only for apps that adopt the Hardened Runtime.

Cited from: https://developer.apple.com/documentation/apple-silicon/porting-just-in-time-compilers-to-apple-silicon

并且 Dotnet 使用了 Hardened runtime - com.apple.security.cs.allow-jit 权限。

这意味着 Dotnet 就会使用 MAP_JIT 标记所有的 JIT 编译的代码,并使用 W^X 权限。W^X 意味着可读或可执行,且两者不可同时存在。 MAP_JIT 标记的内存区域有且仅有一块,不能更细化控制访问权限。也就是说,MAP_JIT 中的代码是不可能实现修改自身的。

既然这样,MAP_JIT 区域中的代码就得借助于外部的代码来绕过这个限制。于是,就有了如下代码:

1
2
3
4
5
6
7
8
9
10
#include <pthread.h>
void jit_memcpy(void* dst, const void* src, size_t n) {
if (pthread_jit_write_protect_supported_np()) { // 检查是否开启了 MAP_JIT 保护
pthread_jit_write_protect_np(0); // 关闭写保护
memcpy(dst, src, n);
pthread_jit_write_protect_np(1); // 开启写保护,允许区域代码执行
} else {
memcpy(dst, src, n);
}
}

然后在 C# 侧调用这个 unmanaged native 方法对方法 A 的函数体进行修改:

1
2
3
4
fixed (byte* dataPtr = detourData)
{
MacOSArm64Helper.Instance.JitMemCpy(targetPtr, (IntPtr)dataPtr, (ulong)detourData.Length);
}

至此,就实现了将方法 A Hook 为方法 B。

不过,直接使用方法 B 会有一个漏洞:如果在 B 中调用了 A,就会出现无限跳转。为了让 Hook 有更好的鲁棒性,方法 B 需要使用完整独立的 IL。Harmony 与 MonoMod 就使用了 Cecil 以实现运行时生成动态方法 B。

Ending

看来为了在 Mac 上玩游戏,也是不小心开了个新坑啊(游戏都几天没玩了。。。
实话说,这篇文章可能对于 Mod 开发帮助不算是太大,何况我也还没入门 Mod 开发。
之后如果真有空做了 RimWorld Mod 的开发,再看着写篇入门文章好了。