Foreword
上一篇把龙窟破坏者的 Wiki 做出来了,还没想过弄 mod。人家好歹全程联网,mod 听起来就不合适。
没想到你不做就有别人做,还是 AI 做的。看了一眼,基本全是生成的,东西也能用。那就顺着 BepInEx 把mod的执行流程弄清楚。
BepInEx
BepInEx 是 Unity 游戏里很常见的插件框架,解压到游戏根目录就能用。自带配置和日志,改游戏方法主要靠 Harmony;真正把代码塞进进程的是底下的 UnityDoorstop。
它分 Mono 和 IL2CPP 两套包,不能换着放。龙窟破坏者走 IL2CPP,要用 6。
以前玩雨中冒险 2、护核纪元等等,打 mod 其实就是这一套。
怪猎世界、曙光、荒野是另外一路。它们不是 Unity,BepInEx 插不进去;常见是 Fluffy Mod Manager 管安装,Rise / 荒野再叠 RE Framework。RE Framework 用的是根目录里的 dinput8.dll 代理;BepInEx 这边常见是 winhttp.dll。本质都是 DLL 劫持,只是系统库不一样。
BepInEx 6 结构
把 BepInEx 6 的 IL2CPP 包解到游戏根目录(和 Vaultbreakers.exe 同级),大致会多出这些东西:
| 名字 | 作用 |
|---|---|
winhttp.dll(有的包是 version.dll / dxgi.dll) |
Doorstop 的代理 DLL,游戏一加载这个导入名就进了你的库 |
doorstop_config.ini |
告诉 Doorstop 去跑哪个托管入口、IL2CPP 时 CoreCLR 在哪 |
BepInEx\core\ |
预加载器、Harmony、Cecil 这些核心程序集 |
BepInEx\plugins\ |
你自己或别人的插件 DLL 放这里 |
BepInEx\patchers\ |
比插件更早、用 Cecil 改程序集结构的东西;多数情况用不到 |
BepInEx\config\ |
第一次跑起来之后生成的配置 |
BepInEx\LogOutput.log |
总日志;注入成没成、插件挂没挂 |
dotnet\ |
IL2CPP 包自带的 CoreCLR,给插件当托管运行时 |
第一次启动往往会停很久。IL2CPP 要生成 BepInEx\interop 里那一堆代理程序集,下一次启动就可以用这个缓存了。
注入
Mono/IL2CPP
| Mono | IL2CPP | |
|---|---|---|
| 打包 | 游戏目录里常有 Managed\,里面一堆 .dll |
脚本被提前编译成 C/C++,再编进 GameAssembly.dll,旁边是 il2cpp_data\Metadata\global-metadata.dat |
| 运行时 | 进程里跑 Mono(或新一点的 .NET),能直接加载/反射这些 DLL | 跑的是原生代码;没有可执行的游戏 C# DLL 给你随便 Assembly.Load |
| 识别 | 有 Managed,有 Assembly-CSharp.dll 一类 |
有大体积 GameAssembly.dll,根目录通常看不到游戏脚本 DLL |
| BepInEx | 5 / 6 的 Mono 包 | 必须用 6 的 IL2CPP 包,还要自带 CoreCLR + 生成 interop |
龙窟破坏者是 IL2CPP:玩法脚本编进了 GameAssembly.dll,旁边是 il2cpp_data\Metadata\global-metadata.dat。
Windows 加载 exe 时会处理导入表。查找时,exe 所在目录排在 System32 前面。Unity 会导入 winhttp.dll,游戏目录里放一份同名文件,进程起来拿到的就是这份——代理 DLL 就这么进来了。
文件名是 winhttp.dll,内容却不是微软那套 HTTP 库,而是 UnityDoorstop:把自己的导出转给真正的 System32\winhttp.dll,再读 doorstop_config.ini 去做 hook。游戏要的是「名叫 winhttp 的 DLL」,不管里面是不是真 HTTP,所以会加载。有的播放器导入的是 version.dll / dxgi.dll,就把同一份代理改成对应文件名,原理一样。
Doorstop
Doorstop 找的是 il2cpp_init,原函数返回之后,若 ini 里写了 CoreCLR 路径,就把 dotnet\coreclr.dll 载入进程,coreclr_initialize 建一个叫 Doorstop Domain 的应用程序域,再 coreclr_create_delegate 调到 Doorstop.Entrypoint.Start。目标程序集是 BepInEx.Unity.IL2CPP.dll。doorstop_config.ini 的 Il2Cpp 段里,coreclr_path 默认 dotnet\coreclr.dll,corlib_dir 默认 dotnet。文件不在就跳过,不会去调 Start。
入口仍是无参的 static void Doorstop.Entrypoint.Start()。
CoreCLR
游戏这边的脚本已经是原生码了,直接看不到 GameAssembly 里的类。BepInEx 在进程里另起 CoreCLR,专门跑插件。
Il2CppInteropManager 用 Cpp2IL 读 GameAssembly.dll(有的包是 UserAssembly.dll)和 metadata,把类和方法的形状还原成代理程序集,调用时再转到 il2cpp_ 那组原生接口。生成前对 GameAssembly.dll、引擎引用库和生成器版本做 MD5,写在 BepInEx\interop\assembly-hash.txt。引擎引用库游戏包不带,按 Unity 版本从 unity.bepinex.dev/libraries/{版本}.zip 下载到 BepInEx\unity-libs。
plugins
BepInEx 自己的 Chainloader 会去翻 BepInEx\plugins 目录。它先只读 DLL 里有哪些类型,找到挂了 [BepInPlugin] 的才认作插件
- 上面要写 GUID、名字、版本。同一个 GUID 出现多份时只留版本更高的;
- 如果还标了
[BepInProcess],进程名对不上当前游戏 exe 的直接跳过。
龙窟中,插件基类是 BasePlugin,入口是 Load。配置写到 BepInEx\config\<GUID>.cfg,总日志在 LogOutput.log。
改游戏方法用 HarmonyX。IL2CPP 下方法体已经是机器码,补丁在函数入口跳转上;常见写法:
| 写法 | 干什么 |
|---|---|
| Prefix | 原方法前面跑;返回 false 就不进原方法 |
| Postfix | 原方法后面跑,可以改返回值 |
CoreCLR 里创建不了引擎物体。要逐帧跑,用 Il2CppInterop 的 ClassInjector / AddComponent 把行为注册进 IL2CPP,再挂到游戏那边的物体上。
另外还有个目录叫 BepInEx\patchers,和 plugins 不是一回事。里面的 DLL 会在插件 Load 之前跑,用 Mono.Cecil 直接改程序集长什么样——比如加字段、加类、整份换掉某个程序集。
Harmony 做的是「方法已经被加载之后,在入口挂跳转」;patcher 动的是更早、更底层的结构。多数自己写的功能用 Harmony 就够了,官方也更建议能不用 patcher 就别用,难调试,也难和别人的模组一起使用。
Mod
注入链路清楚以后,自己要的功能就是:找到游戏里的类和方法,写一个插件 DLL,丢进 plugins。
确认框架可注入
- 游戏根目录放的是 IL2CPP 包,不是 Mono 包。
- 启动一次,等
BepInEx\interop生成完。 - 打开
BepInEx\LogOutput.log,能看到 Doorstop / Preloader / Chainloader 正常跑完。
日志齐全、插件目录却是空的,先别写业务代码——入口没起来,后面怎么编译都白扯。
找要改的点
和做 Wiki 同一条路:
- 用 Il2CppDumper 出
dump.cs,搜你关心的类名、方法名。 - 第一次启动之后,到
BepInEx\interop里用 dnSpy / ILSpy 对照代理程序集。字段、方法在代理里常常是公开的,跟dump.cs对得上就能在插件里直接引用。
想改掉落、UI、某次网络回包,先在名单里把「谁在什么时候被调用」定位到,再决定用 Prefix 拦掉、还是Postfix 改返回值。
建插件工程
官方模板:
dotnet new install BepInEx.Templates --nuget-source https://nuget.bepinex.dev/v3/index.json
dotnet new bep6plugin_unity_il2cpp -n VaultbreakersSomething
IL2CPP 插件骨架大致是:
using BepInEx;
using BepInEx.Unity.IL2CPP;
using HarmonyLib;
[BepInPlugin("com.example.vb.something", "Vaultbreakers Something", "1.0.0")]
public class Plugin : BasePlugin
{
public override void Load()
{
Log.LogInfo("loaded");
Harmony.CreateAndPatchAll(typeof(Patches));
}
}
// TargetType / TargetMethod 换成 dump / interop 里看到的名字
[HarmonyPatch(typeof(TargetType), nameof(TargetType.TargetMethod))]
class Patches
{
// Prefix:原方法还没跑。返回 false 就跳过原方法;true 或不写返回值则继续进原方法
[HarmonyPrefix]
static bool Before(TargetType __instance)
{
// 拦掉、改参数、或只打日志
return true;
}
// Postfix:原方法已经跑完。__result 是返回值,可以改
[HarmonyPostfix]
static void After(TargetType __instance, ref bool __result)
{
// 改状态、改 __result、或只打日志
}
}
类上的 [HarmonyPatch] 指定要钩哪一个方法;下面用 [HarmonyPrefix] / [HarmonyPostfix] 分别挂到前面说的两种时机。__result 的类型要和原方法返回值一致,没有返回值就别写这个参数。
工程里要引用游戏生成出来的 interop 程序集,以及 BepInEx / Harmony 相关包。模板会带一部分;游戏逻辑那部分指到 BepInEx\interop 里对应的 dll。
编出来的插件 DLL 拷到 BepInEx\plugins,再开一次游戏。LogOutput.log 里出现你的 GUID 和 loaded,说明Chainloader 正常加载了。
配置
BasePlugin 自带 Config(类型是 ConfigFile)。第一次 Bind 并跑起来之后,会在 BepInEx\config\ 下生成以插件 GUID 命名的文件,例如 GUID 是 com.example.vb.something,文件就是:
BepInEx\config\com.example.vb.something.cfg
格式接近 ini:[段名] 分组,下面 键 = 值。## 是给人看的说明,# 开头那几行是类型和默认值注释,改的时候一般只动等号右边。
[General]
## 启动时是否启用本插件逻辑
# Setting type: Boolean
# Default value: True
Enabled = true
## 倍率一类的数字配置
# Setting type: Single
# Default value: 1
Multiplier = 1.5
程序里不用自己读文件。在 Load 里 Bind 一次,以后用返回的 ConfigEntry<T>.Value 取值;改 .Value 也会写回同一个 cfg。
using BepInEx.Configuration;
ConfigEntry<bool> enabled;
ConfigEntry<float> multiplier;
public override void Load()
{
enabled = Config.Bind(
"General", // 对应 cfg 里的 [General]
"Enabled", // 键名
true, // 默认值;文件不存在或没有这一项时用它
"启动时是否启用本插件逻辑"); // 写进 ## 说明
multiplier = Config.Bind("General", "Multiplier", 1f, "倍率一类的数字配置");
if (!enabled.Value)
return;
Log.LogInfo($"multiplier={multiplier.Value}");
Harmony.CreateAndPatchAll(typeof(Patches));
}
Bind 四个参数就是:段名、键名、默认值、说明。Harmony 的 Prefix / Postfix 里要用的话,把 ConfigEntry 做成 static,或在 Load 里存到静态字段,补丁里读 enabled.Value 即可。手改 cfg 之后,默认要重启才会重新读进内存;想边改边试,看下一节热加载。
热加载
正式给玩家用的 mod,几乎都是冷加载:游戏启动时 Doorstop → BepInEx → Chainloader 扫 plugins,DLL的加载就定了。关游戏再开,才能加载新的dll。
自己调试时这套很烦。改一行 Prefix,就要退回桌面、等 IL2CPP / interop、再进主菜单,几分钟没了。所以才需要热加载:游戏还开着,把旧逻辑卸掉,再把新编出来的 DLL 装进去。
思路就两步,和语言无关:
- 卸干净:Harmony 补丁
UnpatchSelf,自己创建的物体毁掉,静态字段、事件订阅、计时器清掉。卸不干净,下一次再挂就会叠两层 Prefix,或者旧对象还在场景里。 - 再装一份:重新
Assembly.Load(或等价加载)新 DLL,再跑入口、再CreateAndPatchAll。CLR 里已经加载过的程序集名通常卸不掉,调试器用的加载器往往会给程序集加时间戳后缀,等于每次当新程序集。
cfg 的 .Value 只能热改「开关和数字」;改的是 C# 代码,必须走上面这套卸了再装。
Mono 游戏
官方调试套件 BepInEx.Debug 里的 ScriptEngine 就是干这个的:
- 把
ScriptEngine.dll放进BepInEx\plugins(常驻,只装一次)。 - 正在开发的插件不要放
plugins,改放到BepInEx\scripts。 - 游戏里默认按 F6 重载;也可以开文件监视,DLL 一覆盖就自动重载。
插件里必须写清理。Mono 上常见是 OnDestroy:
Harmony harmony;
void Awake()
{
harmony = Harmony.CreateAndPatchAll(typeof(Patches));
}
void OnDestroy()
{
harmony?.UnpatchSelf();
// 自己 new 出来、又没挂在 ScriptEngine 父物体下的东西,这里一并清掉
}
重载后调试器里看到的程序集名会变(带一串后缀)。需要断点时,打开 ScriptEngine 的 DumpAssemblies,从倒出来的 DLL 再附着,或者在入口打 Debugger.Break()。
IL2CPP
龙窟是 IL2CPP,ScriptEngine 那套 Mono 宿主用不了。思路一样,换一个会监视文件的常驻插件,例如 BepInEx.IL2CPP.AssemblyLoader:
AssemblyLoader.dll放BepInEx\plugins,游戏开着时它一直在。- 你正在改的代码编成
Payload.dll,丢进BepInEx\assembly。 - 加载器调
static void Main()当作入口;文件一变就先调static void Unload(),再加载新 DLL。
Unload 里同样要拆 Harmony、拆自己挂上去的组件,否则热加载等于泄漏。
static Harmony harmony;
public static void Main()
{
harmony = Harmony.CreateAndPatchAll(typeof(Patches));
}
public static void Unload()
{
harmony?.UnpatchSelf();
harmony = null;
}
工程上可以加 PostBuild,编译完自动拷到游戏的 assembly 目录,省得每次手拖。
边界
| 能热加载 | 不能 |
|---|---|
| 自己插件里的 Harmony、逻辑、大部分配置开关 | Doorstop / BepInEx 本体、第一次生成的 interop |
| 反复改 Prefix / Postfix 试效果 | 已经写进存档、已经发给服务器的状态 |
开发期放 scripts / assembly |
给别人分发时仍应回到普通 plugins 冷加载 |
热加载是开发加速,不是发布形态。发出去的包还是放 plugins,让玩家正常启动加载;Unload / OnDestroy 就算发布包里留着也无妨,只是冷启动用不上。
Summary
对BepInEx有了一些了解,后续自己做mod或者给AI去工作,也可以快速完成我想要的东西。
Quote
https://github.com/BepInEx/BepInEx
https://github.com/NeighTools/UnityDoorstop
https://www.nexusmods.com/site/mods/818
https://github.com/praydog/REFramework
https://github.com/BepInEx/BepInEx.Templates
https://docs.bepinex.dev/master/articles/dev_guide/plugin_tutorial/2_plugin_start.html
https://docs.bepinex.dev/master/articles/dev_guide/plugin_tutorial/4_configuration.html
https://github.com/BepInEx/BepInEx.Debug
https://github.com/zongjingyao/BepInEx.IL2CPP.AssemblyLoader