BepInEx 项目深度解析:Unity / XNA 游戏模组框架是怎么把外挂代码塞进游戏进程里的

BepInEx 名字全称是 Bepis Injector Extensible,在 GitHub 上是 Unity 与 XNA / FNA / MonoGame 一类 .NET 游戏最主流的插件 / 模组加载框架。到 2026 年 10 月,它已经有 8.7k stars、912 forks、386 open issues,主体 C# 实现,约 54 万行代码,LGPL-2.1 开源。

它解决的问题很直接:游戏发行方只给你 .exe(Unity Mono)或更糟的 IL2CPP 编译产物,几乎没有任何”插件”入口。你想给游戏加汉化、加 MOD、加 debug 工具,传统做法是反编译改 IL、重打包。BepInEx 让你不改游戏本体,直接把外部 DLL 注入到游戏进程里启动,再通过运行时补丁改写游戏行为。

这一篇就扒一遍它源码、文档和插件生态,看看一个”游戏外挂框架”在工程上是怎么搭起来的。


一、三层启动模型:Doorstop → Preloader → Core

BepInEx 最值得讲的,是它的进程启动时机。普通 .NET 程序只有一个”运行时托管”的入口,而 BepInEx 强行把 .NET 启动前的一段空白抢了过来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌──────────────────────────┐
│ 操作系统加载 .exe │
└──────────────┬───────────┘
▼
┌──────────────────────────┐
│ 1. UnityDoorstop 劫持 │ ← .NET CoreCLR / Mono 启动前
└──────────────┬───────────┘
▼
┌──────────────────────────┐
│ 2. BepInEx.Preloader │ ← 运行时修补 + 钩子注入
└──────────────┬───────────┘
▼
┌──────────────────────────┐
│ 3. BepInEx.Core 启动 │ ← 配置、日志、Console、Chainloader
└──────────────┬───────────┘
▼
┌──────────────────────────┐
│ 4. 用户 DLL 插件链加载 │ ← HarmonyX 补丁生效、游戏主循环启动
└──────────────────────────┘

1.1 第一层:UnityDoorstop 抢入口

BepInEx 不自己做注入,它依赖另一个项目 NeighTools/UnityDoorstop。Doorstop 的核心做法只有一行:

1
2
3
4
# doorstop_config.ini(游戏根目录)
[General]
enabled=true
targetAssembly=BepInEx.Preloader.dll

Doorstop 给 Unity / XNA 的可执行文件挂了一个原生 main() 钩子(Windows 用 LoadLibrary+IAT hook,Linux 用 LD_PRELOAD),让程序在进入 .NET 运行时之前先加载 BepInEx.Preloader.dll。这一步的时机极早——.NET CLR 还没起来,你对 CLR 的任何运行时修补都还来得及。

1.2 第二层:Preloader 做”运行时修补”

进入 BepInEx.Preloader.Core 后,BepInEx 干了几件脏活:

  • Patching/:用 Mono.Cecil 直接改写 Assembly-CSharp.dll 的 MSIL 字节码——给游戏入口方法塞一个静态构造调用、把 AssemblyResolve 重定向到 BepInEx 的 DLL 解析器。
  • RuntimeFixes/:对 Unity Mono / IL2CPP 的已知坑打补丁,比如 HarmonyX 需要的 trampoline、IL2CPP 跨域通信所需的前置 hook。
  • EnvVars.cs / PlatformUtils.cs:探测当前是 Windows / Linux / OSX,是 Mono 还是 IL2CPP 运行时,决定后续启动分支。

注意这一步结束后游戏主程序还没运行,所以你可以安全地改写任何方法体。

1.3 第三层:Core 把插件链拉起来

接着 BepInEx.Core 启动,读 BepInEx/config/BepInEx.cfg,实例化 Logger、Console、ConfigManager,最后跑 Chainloader:

1
2
3
4
5
6
// 简化自 Chainloader.cs
foreach (var plugin in plugins)
{
try { plugin.Load(); }
catch (Exception e) { Logger.LogError(e); }
}

Chainloader 会扫描 BepInPlugins/<guid>/ 目录下所有 DLL,加载标了 [BepInPlugin] 特性、且依赖关系(DLL/CLB 字段)能成图的对象,按拓扑序实例化。


二、源码结构一览

仓库根目录:

目录 作用
BepInEx.Core/ 框架本体:配置、Console、Contract、Logging、Bootstrap
BepInEx.Preloader.Core/ 启动修补:Cecil 改 MSIL、RuntimeFixes、平台探测
Runtimes/Unity/ Unity Mono / IL2CPP 各自的预编译 DLL(含 Il2CppInterop 桥接层)
Runtimes/NET/ 传统 .NET Framework / .NET Core 运行时支持(XNA、FNA、MonoGame)
build/ Cake-based 构建脚本:自动下载 MonoMod、Cecil、HarmonyX 等依赖
docs/ 文档源(docs.bepinex.dev)

下面这张图把”启动链 + 关键命名空间”贴出来,你对照源码读会更顺:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
BepInEx.Preloader.Core/
├── Entrypoint.cs ← 真正的 .NET Main()
├── Patching/
│ ├── AssemblyPatcher.cs ← Cecil 修改 Assembly-CSharp
│ └── PatcherPlugin*.cs ← 可插拔的修补器
├── RuntimeFixes/
│ ├── UnityPatches.cs ← HarmonyX trampoline 修补
│ └── XnaPatches.cs ← XNA / FNA 修补
└── PlatformUtils.cs ← Windows/Linux/OSX + Mono/IL2CPP 探测

BepInEx.Core/
├── Bootstrap/
│ ├── BasePlugin.cs ← 所有插件的基类
│ └── Chainloader.cs ← 拓扑排序 + 实例化
├── Configuration/
│ └── ConfigFile.cs ← TOML-like 配置读写
├── Console/
│ └── UnityConsole.cs ← 游戏内可呼出的控制台
├── Contract/
│ └── Attributes.cs ← [BepInPlugin]、[BepInDependency]…
└── Logging/
└── Logger.cs ← 5 级日志 + 文件落盘

三、IL2CPP:硬骨头是怎么啃的

Unity IL2CPP 把原本托管的 C# 代码编译成 C++,再编译成原生机器码。结果就是:游戏里几乎没有可被 .NET 反射的元数据,传统 Mono 下 Type.GetType("Player") 直接失效。

BepInEx 处理 IL2CPP 的链路:

  1. Cpp2IL(SamboyCoding/Cpp2IL):解析 IL2CPP 生成的 global-metadata.dat + 二进制,把原生方法恢复成 MSIL 表达。
  2. Il2CppInterop(BepInEx/Il2CppInterop):自己 fork 了一份 .NET 6 runtime,用 __INTERNAL__call 等 stub 把 IL2CPP 原生函数”假装成” .NET 方法。
  3. dotnet-runtime 6:BepInEx 自己 fork 了一份 .NET 6(BepInEx/dotnet-runtime),打了若干补丁,让 IL2CPP interop 跑得稳。

最终效果:你写插件的时候,依然可以 var player = Il2CppType.Get<Player>(); player.DoSomething();——和写 Mono 插件几乎一模一样。

平台兼容矩阵来自仓库 README:

Windows OSX Linux ARM
Unity Mono ✔️ ✔️ ✔️ N/A
Unity IL2CPP ✔️ ❌ ✔️ ❌
.NET / XNA ✔️ Mono Mono N/A

注意 IL2CPP 的 OSX / ARM 还是空的——Apple Silicon 玩家想跑 IL2CPP MOD 得自己编译。


四、补丁双子星:HarmonyX 与 MonoMod

光”把 DLL 塞进进程”还不够,MOD 通常要改游戏的某段逻辑。BepInEx 不自己造补丁轮子,而是把两套现成工具嵌进来:

4.1 HarmonyX:源码级别的 PREFIX / POSTFIX / TRANSPILER

HarmonyX 是 Harmony 的 fork,针对 Mono / IL2CPP 做了 trampoline 优化。用法长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using HarmonyLib;

[HarmonyPatch(typeof(PlayerController), nameof(PlayerController.TakeDamage))]
public class TakeDamagePatch
{
static bool Prefix(ref int damage, PlayerController __instance)
{
// 拦截原方法:返回 false 直接跳过原方法
if (__instance.HasShield)
{
damage = 0;
return true; // 还是执行原方法,但 damage 已被改成 0
}
return true;
}

static void Postfix(int damage, PlayerController __instance)
{
Debug.Log($"玩家受伤,剩余 HP={__instance.HP - damage}");
}
}

Prefix 改参、Postfix 改返回、Transpiler 改 MSIL——这套范式已经成为 C# 模组开发的事实标准。

4.2 MonoMod:更底层的 IL 重写

MonoMod 提供 MMHook 和 ILManipulator,能直接改写方法体的指令流。Harmony 解决不了的时候(比如改 struct 字段、改枚举底层值),MonoMod 是后备方案。


五、插件加载器生态:兼容是 BepInEx 的护城河

游戏圈在 BepInEx 之前就有 BSIPA、IPA、MelonLoader、uMod 等十几个加载器,每个都有自己的插件格式。BepInEx 不替代它们,而是给每个老加载器做了一个”shim”:

老加载器 适配项目 用途
BSIPA BepInEx.BSIPA.Loader Beat Saber 系
IPA IPALoaderX 老一代 Unity Mono
MelonLoader BepInEx.MelonLoader.Loader Melon 插件
MonoMod BepInEx.MonoMod.Loader 原生 MonoMod 包
uMod BepInEx.uMod.Loader Oxide/uMod 系

这套兼容设计是 BepInEx 真正黏住生态的地方——很多老 MOD 直接套上 shim 就能跑,新 MOD 作者也能挑熟悉的范式写。


六、50 行写一个最简插件

跟着 官方教程,你只需要一个 C# 类库项目加一个 dll 引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using BepInEx;
using BepInEx.Logging;
using HarmonyLib;
using UnityEngine;

namespace MyFirstPlugin;

[BepInPlugin("com.example.myfirstplugin", "MyFirstPlugin", "1.0.0")]
public class Plugin : BasePlugin
{
public static ManualLogSource Log;

public override void Load()
{
Log = base.Log;
Log.LogInfo("Hello from MyFirstPlugin!");
Harmony.CreateAndPatchAll(typeof(MyPlugin));
}
}

[HarmonyPatch(typeof(UnityEngine.Debug), nameof(UnityEngine.Debug.Log), new[] { typeof(object) })]
public static class DebugLogPatch
{
public static void Prefix(object message)
{
Plugin.Log.LogInfo($"[Unity] {message}");
}
}

构建出 MyFirstPlugin.dll,扔到 BepInEx/plugins/com.example.myfirstplugin/ 下,启动游戏——Debug.Log 的内容会同时打到 BepInEx 日志里。


七、怎么从零上手

  1. 去 Releases 页 选对应平台(Unity Mono、IL2CPP、.NET)的版本下载。IL2CPP 玩家看清楚是 x86 还是 x64。
  2. 解压到游戏根目录,Windows 下双击运行一次 BepInEx.Preloader.dll,会生成 BepInEx/ 目录。
  3. 编辑 BepInEx/config/BepInEx.cfg,按需开启 [Logging]、[Console]、[Harmony]。
  4. 编译你的插件 DLL,丢进 BepInEx/plugins/<guid>/。
  5. 启动游戏,控制台应弹出(默认按 F1),日志写到 BepInEx/LogOutput.log。

如果想用 Bleeding Edge(6.x),文档在 docs.bepinex.dev/master。


八、局限与生态位

把 BepInEx 抬到一个合理位置看,它的优缺点也很清楚:

强项

  • 唯一同时覆盖 Unity Mono / IL2CPP / .NET XNA 的开源加载器
  • Doorstop + Preloader 的注入时机比 MelonLoader 更早,能改 MSIL
  • HarmonyX + MonoMod 双重补丁工具链,方法体级别的修改几乎是上限
  • 兼容 shim 生态让老插件零成本迁移

短板

  • OSX IL2CPP / ARM 还没支持,Apple Silicon 是空白
  • 每个 Unity 版本升级时 IL2CPP metadata 格式可能变,需要 Il2CppInterop 适配,社区偶尔会滞后
  • 不像 MelonLoader 有内置的 IL2CPP 汇编器,要写复杂 MOD 的人得自己结合 Cpp2IL
  • 文档对”如何在不开 AntiCheat 的商用游戏里跑”的指导偏少(这也是为了规避滥用)

它在模组圈的生态位可以总结成一句话:当你需要的是”一个能跨平台、改游戏底层行为、有完整补丁生态的模组运行时”,BepInEx 是目前唯一的开源选项。


九、写在最后

BepInEx 这个项目最值得学习的,不是某个单独的 API,而是它对进程入口的争夺——用 Doorstop 抢在 .NET 之前、用 Preloader 在 CLR 启动瞬间打补丁、用 Chainloader 把外部 DLL 拓扑化成插件链。这个套路在 Unity mod、嵌入式脚本宿主、应用热补丁这些场景里都通用。

如果你正打算给某个 Unity / XNA 游戏做工具链,或者你想深入 .NET CLR 的运行机制,从 BepInEx 源码入手会比直接看 CoreCLR 友好很多——它把 Cecil / MonoMod / Harmony 的用法都演示在了真实工程里。

相关阅读可以延伸到我之前写的 AirLLM 分层流式加载拆解,里面也讲到了”模型分片按需加载”的同类工程思路;想看更多开源项目深度玩法可以翻 GitHub 9 月第一周热榜 Top 5。

你平时用什么给 Unity 游戏做 MOD?HarmonyX 顺手吗?欢迎评论区聊聊。