前两天有个群友在群里发了一张截图,游戏目录里 BepInEx 文件夹、plugins 文件夹都建好了,他自己照着网上的模板用 Visual Studio 写了一个插件,扔进去之后日志里却什么都没有。这个问题我太熟了。BepInEx 插件开发这件事,大部分人第一道坎根本不是 C# 代码本身,而是版本选型、工程配置和运行机制没对齐。BepInEx 是目前 Unity 游戏 Mod 圈子里事实标准的前置框架,Visual Studio 是写 C# 插件最顺手的 IDE,两者组合起来,一条完整的 Mod 开发流水线并没有想象中复杂。这篇文章会把这条流水线完整拆开讲一遍:从 BepInEx 版本怎么选、VS 工程怎么建,到插件入口怎么写、Harmony 补丁怎么打,再到调试怎么挂、乱码和加载失败怎么排查。适合手里已经有一款 Unity 单机游戏、想从单纯的玩家进一步变成 Mod 开发者的朋友,也适合刚入行想做游戏扩展的 .NET 开发者。
1. 动手前的准备:版本选型与前置安装
1.1 先搞清楚BepInEx 5和BepInEx 6的差别
进入正题前先说一个绕不开的问题:下载 BepInEx 时你会在 GitHub Releases 和各大整合包站点看到两种版本,5.4.22 和 6.x 预览版。很多新手直接下载了标题里带 6 的最新版,然后拿着 5 的教程写代码,最后各种加载失败。稳妥起见,2024 年做常规 Unity Mod 开发,我的建议依然是先用 BepInEx 5.4.22 起步。
原因很简单:BepInEx 5 是基于 Mono 运行时设计的,生态最成熟,社区教程、现成模板、老游戏兼容性几乎都围绕它展开;BepInEx 6 是官方在推进的新一代,底层换成了 .NET,方向没问题,但配套工具链和第三方插件生态还没完全跟上,遇到的很多问题你可能连搜都搜不到答案。除非你明确知道自己要打交道的游戏必须用 6(一般是较新的 IL2CPP 游戏),否则别让最新版成为你的第一课。像英灵神殿这类老牌 Unity 单机游戏,社区里的 Mod 至今还是以 BepInEx 5 为底座在运行,这也从侧面说明 5 的稳定性经过了大量实战验证。
这一块的选型逻辑,我做了个简单的对照表:
| 对比项 | BepInEx 5.4.x | BepInEx 6.x |
|---|---|---|
| 主流程度 | 高,绝大多数单机 Unity Mod 都用它 | 预发布状态,生态还没完全迁移 |
| 插件目标框架 | .NET Framework 3.5~4.8 | .NET 6+ |
| IL2CPP 支持 | 不支持(传统版) | 有 IL2CPP 实验支持 |
| 新手友好度 | 高,教程最多 | 中等,需要排查更多环境问题 |
如果你手里的游戏是两年内比较大众的 Unity 单机作品,默认选 5.4.22 基本不会错。这里也顺便纠正一个很常见的误区:BepInEx 的 x64 和 x86 两个压缩包不能互换,下载前先确认游戏主程序是多少位的,否则注入阶段就直接失败。怎么看?在任务管理器里找到游戏进程,看“平台”列,或者用 Dependency Walker 一类的工具打开游戏主程序看架构。
1.2 Visual Studio需要安装哪些组件
写 BepInEx 插件本质上是用 C# 编写一个类库,所以 Visual Studio 这边只需要一个工作负载就够:在 VS Installer 里勾选“.NET 桌面开发”。它会帮你把 .NET Framework 4.8 的 Targeting Pack、MSBuild、C# 编译器一起装好。社区版就完全够用,不需要 Enterprise。
这里有一个特别容易混淆的点:VS 的扩展市场里有一个官方出品的“Unity 工具”扩展,很多人以为写 BepInEx 插件开发也需要它。那个扩展是给“开发 Unity 引擎本身”的人用的,用来在 VS 里调试 Unity 编辑器、查看场景和资源。而我们做 Mod 开发是在游戏运行时做扩展,根本走不到 Unity 编辑器那一步,所以这个扩展可以完全忽略,装不装都不影响。
另外一个细节:如果你在创建项目的时候发现模板里找不到“.NET Framework 类库”,说明你的 VS 工作负载没装全。用 VS Installer 修改安装,在“.NET 桌面开发”下面把 .NET Framework 4.8 开发工具包勾上,然后重启 VS 就能看到了。不要为了省空间跳过这个组件,后面所有工程配置都依赖它。
1.3 正确把BepInEx解压进游戏目录
前置安装这个环节看着简单,实际踩坑率很高。正确做法是:从 Releases 页面下载对应架构的压缩包,解压后把压缩包里的所有文件(BepInEx 文件夹、doorstop_config.ini、winhttp.dll)原样释放到游戏根目录,也就是游戏主程序 exe 所在的那个目录,而不是释放到游戏目录下的某个子文件夹里。
很多人的第一个问题就出在这里:只复制了 BepInEx 文件夹,漏掉了 winhttp.dll。这个 DLL 是 BepInEx 的启动注入器,它必须和游戏 exe 同级,BepInEx 才能通过 Unity 的 Doorstop 机制在游戏启动早期把自己挂载进去。少了它,你后续写再多代码都是白搭,游戏日志里一点痕迹都不会有。
装好之后第一次启动游戏,BepInEx 会生成自己的目录结构,包括 config、plugins、logs 这些子目录,同时根目录可能出现一条控制台窗口。看到这个窗口基本就说明注入成功了。Steam 游戏的话,记得先在库里面右键游戏 -> 属性 -> 已安装文件 -> 浏览,打开本地目录,不要手动瞎猜路径。如果游戏原本就整合过 Mod 环境,先检查根目录里是不是已经存在 BepInEx,存在的话要先确认版本,避免新旧文件互相覆盖。
1.4 快速判断游戏是Mono还是IL2CPP
这个判断很关键,直接决定你能不能按这篇文章的流程做下去。Unity 游戏有两种脚本后端:Mono 和 IL2CPP。Mono 构建的游戏会保留一个名为 Assembly-CSharp.dll 的托管程序集,通常放在游戏目录下带 _Data 后缀的文件夹里的 Managed 子目录中;IL2CPP 构建的游戏则是把 C# 代码全部转成 C++ 再编译,产物叫 GameAssembly.dll,体积通常比较大。
BepInEx 5 的常规插件只能针对 Mono 游戏工作,因为它的核心是直接托管注入;如果你面对的是 IL2CPP 版本,就必须使用 BepInEx 6 的 IL2CPP 路线,而且里面涉及的 API 和调试方式差异很大。判断方法很简单:先在根目录找有没有 GameAssembly.dll,有大概率是 IL2CPP;再找 _Data/Managed/Assembly-CSharp.dll,有就是 Mono。新手第一次练手,一定要选 Mono 版本的游戏,这个选择能帮你绕开后续一半以上的坑。
这里顺便提一下,热搜里面经常出现“gameassembly.dll的作用”“bepinex压缩包”这些词,很多人其实是被 IL2CPP 游戏卡住了。如果你手里的游戏只有 IL2CPP 版,我的建议是:先换一个 Mono 版的老游戏练手,把基础流程跑通,再去研究 IL2CPP 的特殊方案。顺序不要反。
2. 搭建插件工程:类库项目与程序集引用
2.1 为什么建“类库”而不是“控制台应用”
我第一次写 BepInEx 插件的时候,习惯性地建了控制台项目,结果编译出一堆带 Main 入口的 exe,扔到 plugins 里毫无反应。你要理解:BepInEx 插件不是一个独立程序,它是一段被 BepInEx 进程加载并执行的托管代码,所以工程类型必须选 C# 的“类库”,最终产物是一个 DLL,由 BepInEx 的插件加载器在合适的时机创建实例。
类库工程没有固定的入口方法,这一点对很多刚从 WinForms 或控制台转过来的人不太习惯。BepInEx 是通过约定来找插件的:找到继承 BaseUnityPlugin 的类,让 Unity 的 MonoBehaviour 生命周期接管它,所以你的插件本质上是一个被动态实例化的 MonoBehaviour。想通这一点,后面写代码就不会觉得“我明明什么都没调用,Awake 怎么自己就跑了”。
创建项目的时候,在 VS 里搜“类库”就能看到模板,注意选择带(.NET Framework)后缀的那个,而不是默认的“.NET 类库”。如果看不到这个模板,回到上一节说的,补装 .NET Framework 开发工具包。
2.2 目标框架选.NET Framework还是.NET 8
这一步是新手最容易踩的坑。VS 2022 默认新建的类库项目通常面向 .NET 8 或 .NET 6,这种项目编译出来的 DLL 拿到 BepInEx 5 里基本没法用,因为 BepInEx 5 的运行时是基于 Mono 的,它不认识较新 .NET 运行时要求的那一堆依赖。正确做法是把目标框架改成 .NET Framework 4.7.2 或 4.8。
为什么是 4.x 而不是 3.5?Unity 2018 之后的版本内置 Mono 对 .NET Framework 4.x 的支持已经比较完整,BepInEx 5 官方插件模板也是基于 4.x 编写的。如果选太老的 3.5,有些 C# 语法和基础库特性用不了;如果选太新的 .NET 8,游戏里的 Mono 运行时又缺失相关组件。项目创建后,右键项目 -> 属性 -> 目标框架,把它改成 .NET Framework 4.8 即可。
语言版本这一项就不用太保守了,只要编译器支持,C# 9、10 甚至更新的语法都可以用,因为最终编译出来的仍是 .NET Framework 程序集,不依赖运行时的语法特性。所以字符串插值、switch 表达式这类现代写法,放心用。
2.3 添加BepInEx.dll和0Harmony.dll引用
接下来进入第一个真正的“连接”动作:把 BepInEx 的核心程序集引用到项目里。在解决方案资源管理器里右键项目的“引用” -> 添加引用 -> 浏览,先去游戏目录下的 BepInEx\core 文件夹,选中 BepInEx.dll 添加进来。如果你后面要用 Harmony 打补丁,再选择 0Harmony.dll 一并添加,这两个 DLL 的引用是基础。
这里有一个必须记住的细节:添加完引用后,把两个 DLL 的“复制本地”属性改成 False。默认情况下,编译器会把引用的程序集复制到输出目录,也就是你的项目 bin 文件夹甚至 plugins 目录,这会让 BepInEx 在加载时出现重复程序集、版本冲突一类问题。BepInEx 本身会在运行期从 core 目录加载这些程序集,你把 DLL 复制到 plugins 目录反而帮倒忙。
怎么改?在解决方案资源管理器里展开“引用”,选中 BepInEx.dll 和 0Harmony.dll,看属性面板,把“复制本地”切换成 False。改完之后,项目运行时才能安心地引用 core 目录里的版本。
2.4 把输出路径直接指向游戏的plugins目录
开发体验在这个环节会有一个明显提升。右键项目 -> 属性 -> 生成,在“输出路径”一栏填入游戏目录的完整路径,比如 D:\SteamLibrary\steamapps\common\MyGame\BepInEx\plugins\。设置之后,每次编译完的 DLL 都会自动出现在游戏插件目录,省去手动复制的步骤。
这个路径填绝对路径最省事。如果有同事协作或者换电脑,可以改成相对路径,但绝对路径在个人开发时完全够用。唯一要注意的是:编译时如果游戏还在运行,生成的 DLL 会被游戏进程锁定,VS 会报“文件正在被另一进程使用”,导致生成失败。所以开发期要么先把游戏关掉再编译,要么养成“先改后跑”的习惯。
plugins 目录放插件时,直接放在根目录最稳妥。BepInEx 对子目录的扫描支持在不同版本上表现不一,没必要为了整洁把 DLL 塞进嵌套目录里去找不痛快。项目名可以随便起,但输出 DLL 的文件名最好保持和项目名一致,方便出错时定位是哪个插件出了问题。
3. 核心代码:从空插件到能用的Mod
3.1 插件入口类与BepInPlugin特性
代码部分从最基础的入口类开始。BepInEx 通过一个标记了 BepInPlugin 特性的类来识别插件,这个类必须继承 BaseUnityPlugin。BepInPlugin 特性有三个参数:GUID、名称、版本号。GUID 是全局唯一标识,约定用反域名格式,比如 com.yourname.myfirstmod,不要直接抄别人的 GUID,否则两个插件会因为标识冲突而无法同时加载。
看一下最简入口的样子:
using BepInEx; using BepInEx.Logging; namespace MyFirstMod { [BepInPlugin("com.yourname.myfirstmod", "My First Mod", "1.0.0")] public class Plugin : BaseUnityPlugin { private void Awake() { Logger.LogInfo("My First Mod 加载成功"); } } }Logger属性是 BaseUnityPlugin 自带的 ManualLogSource 实例,直接用就好,不需要自己 new。这行日志虽然简单,但它是判断插件有没有被 BepInEx 识别的最快方式。
这里再多说一句命名空间的习惯。建议用你的作者名或工作室名做前缀,不要用默认的 namespace Project1,一旦插件多了,重名和混淆是大概率事件。养成一开始就起好名字的习惯,后来能省掉不少排查时间。
3.2 生命周期方法:Awake、Update与OnGUI
因为插件类继承了 BaseUnityPlugin,而 BaseUnityPlugin 又继承自 UnityEngine.MonoBehaviour,所以 Unity 的生命周期方法在这里全都有效。最常见的三个是 Awake、Update 和 OnGUI。Awake 在插件被加载时执行一次,适合做初始化;Update 每帧执行,适合监听按键和轮询状态;OnGUI 在 UI 绘制阶段执行,适合绘制调试信息或简单弹窗。
很多从普通 C# 转过来的朋友会困惑:我没人 new 这个类,Awake 怎么会被调用?答案就是 MonoBehaviour 的生命周期不由你自己的代码控制,而是由 UnityEngine 的游戏循环机制在合适的时机调用。BepInEx 在加载插件时创建了实例,然后把后续流程完全交给 Unity 调度。想通了这一点,你会发现插件开发其实就是“写一个特殊的 MonoBehaviour”,门槛瞬间低了很多。
生命周期方法里最容易踩的坑是性能。Update 每帧都会跑,如果里面做了字符串拼接、日志输出、查找对象这类操作,游戏帧率很快就会被拖下来。我一般习惯把高频输出用计数器限制住,要么只输出状态变化,要么每 60 帧输出一次,测试阶段跑起来流畅得多。
3.3 实战:做一个按键提示Mod
现在写一个真正能跑起来的示例:按 F 键,在游戏画面左上角显示一条“按键触发”的提示。这个例子不依赖任何具体游戏的内部类,几乎可以在所有 Mono 版 Unity 游戏上直接验证。
[BepInPlugin("com.yourname.myfirstmod", "My First Mod", "1.0.0")] public class Plugin : BaseUnityPlugin { private bool _showTip; private float _tipTimer; private void Awake() { Logger.LogInfo("My First Mod 加载成功"); } private void Update() { if (Input.GetKeyDown(KeyCode.F)) { _showTip = true; _tipTimer = 3f; Logger.LogInfo("玩家按下了 F 键"); } if (_showTip) { _tipTimer -= Time.deltaTime; if (_tipTimer <= 0f) { _showTip = false; } } } private void OnGUI() { if (_showTip) { GUI.Label(new Rect(20f, 20f, 400f, 60f), "Hotkey F triggered by My First Mod"); } } }这段代码演示了三件事:Input 检测、GUI 绘制、定时清除。你可以把 LogInfo 换成任何调试输出,把 GUI.Label 里的文字换成你自己的内容。编译通过后启动游戏,按 F 键应该能看到日志和左上角的提示。
如果按键没反应,先确认目标游戏用的是旧版输入系统。Unity 2019 之后有些新项目默认开启了新版 Input System,旧的 Input.GetKeyDown 可能被禁用。判断方法是在游戏里能不能通过 UnityEngine.Input 相关 API 读到输入,读不到就需要找输入系统的其他入口,不过大多数 BepInEx 支持的 Mono 老游戏仍然是旧输入系统。OnGUI 里的 GUI.Label 性能一般,只在测试阶段用,不要真拿来做正式 UI。
3.4 再进一步:用Harmony打补丁改游戏逻辑
按键提示只是验证环境,真正让 Mod 具备“改变游戏规则”能力的是 Harmony。Harmony 是一个专门用来在运行时修改 .NET 方法逻辑的库,BepInEx 5 的 core 目录里已经带了兼容版本。为什么 Mod 几乎都用 Harmony 而不是直接改游戏的 Assembly-CSharp.dll?因为直接改原文件一更新就没了,二无法分发,三容易把游戏搞坏。Harmony 的特点是“运行时补丁”,游戏文件保持原样,补丁逻辑由你的插件动态加载。
Harmony 最常用的两种补丁是 Prefix 和 Postfix。Prefix 在原方法执行前运行,可以拦截参数、跳过原方法;Postfix 在原方法执行后运行,可以读取返回值、修改 ref 参数。下面是一个 Postfix 示例,假设你在 dnSpy 里看到了某个游戏类 PlayerInventory 有一个 AddItem 方法:
using HarmonyLib; [HarmonyPatch(typeof(PlayerInventory), nameof(PlayerInventory.AddItem))] public static class Patch_AddItem { private static readonly ManualLogSource Log = BepInEx.Logging.Logger.CreateLogSource("Patch_AddItem"); [HarmonyPostfix] static void Postfix(int itemId, int count) { Log.LogInfo($"AddItem called: itemId={itemId}, count={count}"); } }注意一句:PlayerInventory 和 AddItem 只是示例,真实类名、方法名必须以你反编译出来的为准。方法参数也是一样,参数名和类型都要从反编译结果里抄。正式使用前,需要在插件 Awake 里调用一个 PatchAll:
var harmony = new Harmony("com.yourname.myfirstmod.harmony"); harmony.PatchAll();PatchAll 会自动扫描当前程序集里所有打了 HarmonyPatch 特性的类并应用补丁。补丁写完后不要在 Awake 里反复 PatchAll,只调用一次,否则会打重复补丁,同一方法被挂载多次可能造成诡异重复执行。
Harmony 的威力在 Prefix 里更明显:你可以在原方法执行前修改参数、改变返回值,甚至直接 return false 跳过原方法。但能力越大责任越大,乱改核心逻辑很容易导致存档损坏或任务卡死。开发阶段建议只加日志不改行为,验证流程通了再逐步扩大修改面。
3.5 调试输出:Logger与Unity Debug的配合
插件开发中日志是排查问题的第一生产力。BepInEx 的 Logger 分四个级别:LogInfo、LogWarning、LogError、LogFatal,分别对应普通信息、警告、错误和致命错误。日常开发我会在 Awake 里输出加载成功、在关键逻辑处输出参数、在 catch 里输出异常堆栈,基本靠这三类日志就能解决大部分问题。
Unity 自己的 UnityEngine.Debug.Log 输出也会被 BepInEx 捕获到 LogOutput.log 里,所以两种日志都行。不过我个人的习惯是插件内部统一用 BepInEx 的 Logger,原因是日志行会带上插件标签,出问题的时候能快速定位是哪个插件在说话。比如用 CreateLogSource 创建的日志源,输出样例是这样的:[Patch_AddItem] AddItem called: itemId=5, count=1,一眼就能看到来源。
还有一个小技巧:加入临时调试日志时,用#if DEBUG包起来,编译 Debug 配置时输出,Release 配置时不输出。这样你正式发布 Mod 的时候不用一个个删日志代码,改一下编译配置就干净了。开发阶段日志可以多,用户到手后日志要克制,这是 Mod 分发的一个基本素养。
4. 调试与验证:附加进程、看日志、断点
4.1 附加到正在运行的游戏进程
开发到这一步,最爽的时刻来了:当游戏在你眼前跑起来,而你的断点精准命中了某一行代码。操作路径是:先启动游戏,确保插件已经被 BepInEx 加载,然后在 VS 里点“调试”菜单 -> “附加到进程”,在进程列表里选中目标游戏进程,点击附加,再打开插件源码,在你想暂停的地方打上断点,触发对应逻辑即可。
附加调试只对 Mono 版游戏有效,IL2CPP 版本的托管代码已经被编译成 C++ 和二进制,无法用传统托管调试器挂断点。这也是我一直强调新手先找 Mono 游戏的原因,调试体验完全不是一个量级。附加之后,如果发现插件代码里的断点一直不命中,先检查一下是不是插件没有真正加载,再看 VS 的“调试”窗口里代码类型是否包含了“托管(v4.6等)”这一项。
附加完成以后,游戏进程里的异常多数会在 VS 里直接中断,局部变量、调用堆栈都可以正常看。这一套流程顺畅的话,Mod 开发效率和“盲写 + 纯日志”相比是几何级提升。
4.2 游戏启动太快,插件断点跟不上怎么办
附加调试最大的麻烦在于:插件 Awake 往往在游戏启动最早期就执行了,等你打开 VS、附加进程,Awake 早就跑完了。应对办法有几种,最简单粗暴的是在 Awake 里加 System.Diagnostics.Debugger.Launch(),也就是调用一个程序化的调试器启动:
#if DEBUG System.Diagnostics.Debugger.Launch(); #endif当插件执行到这一行时,系统通常会让调试器挂起并弹出选择窗口,你选择当前 VS 进程就能接上断点。Mono 运行时对这个方法的支持并不是百分百稳定,所以如果发现弹不出来,不要死磕。更通用的办法是:把想在 Awake 阶段验证的逻辑改成由按键触发,比如在 Update 里按 F5 输出状态,游戏加载完成后再人工触发,这样就不需要抓住启动窗口期了。
再给一个极端场景的备选方案:真的需要断在 Awake,就把 Awake 里的逻辑拆一部分到 Start 或者延迟协程里,给附加留出时间窗口。踩过几次坑之后你就会发现,调试这个事最靠谱的路径往往不是“越复杂越高级”,而是“能不用断点就别用断点,日志先行”。
4.3 日志文件是排查问题的第一现场
如果断点挂了半天都不稳定,放弃断点转向日志是更务实的选择。BepInEx 的日志默认写在游戏目录 BepInEx\LogOutput.log。每次运行游戏,这个文件都会重新生成,记录插件加载链、异常、普通输出。开发时我习惯用 VS Code 或者 Notepad++ 直接打开这个文件,开着自动刷新,游戏操作一遍后回来一拉就能看到最新输出。
LogOutput.log 的开头部分其实信息量很大。正常加载时你会看到 BepInEx 版本、游戏版本、Gatekeeper 状态,然后就是所有插件的加载链路,类似[Info : BepInEx] Loading [My First Mod 1.0.0]。如果你的插件名出现在这里,说明加载成功;如果只出现了其他插件而你就是找不到自己的插件,那问题大概率出在插件放置位置、GUID 冲突、依赖缺失这三件事上。
日志文件也会把异常堆栈打出来,这是排查 MissingMethodException 和 TypeLoadException 的关键现场。看到异常先别急着换方案,复制堆栈,搜索关键异常类型,很多问题其实在日志里已经写得明明白白。
4.4 重新加载与热更新:改完代码怎么最快生效
BepInEx 社区目前没有特别成熟稳定的插件热重载方案,常规操作就是“改完代码 -> 重新编译 -> 重启游戏”。因为游戏在运行时会锁定已加载的 DLL,你会发现不退出游戏时编译会报文件占用错误。所以开发节奏往往是:改代码、编译、退出游戏、重新开始游戏、看日志、再改。
这个流程看起来麻烦,但如果把前面 2.4 的输出路径配置做好了,实际耗时几乎可以忽略。编译完的产物直接落到 plugins 目录,重启游戏就是一次验证。我见过有些人为了省事,硬塞一个独立线程去加载插件 DLL 实现“伪热重载”,最终出了各种状态残留问题,反而浪费更多时间。
一个提升效率的小习惯:把“启动游戏”这个动作固定成一条命令或脚本,游戏路径和插件目录都写死,这样每次改完代码只需要点一次编译和一次启动脚本。开发工具链的自动化程度决定了你能把注意力放在写逻辑上,而不是放在点鼠标上。
5. 常见问题与避坑实录
5.1 插件没有加载:先查这几个地方
“插件没有加载”是出现频率最高的问题。按下面的顺序排查,绝大多数情况几分钟内能定位。第一,打开 BepInEx\LogOutput.log,搜你自己的 GUID 或插件名,没有出现就说明 BepInEx 根本没扫描到你的 DLL。第二,确认 DLL 确实在 BepInEx\plugins 根目录,不要放进子文件夹,不要改成奇怪的文件名后缀。第三,确认你编译的是 Debug 或 Release 配置里正确的目标,别把旧的缓存文件误当成新产物。第四,检查 GUID 是否和其他插件重复,重复 GUID 会让 BepInEx 直接拒绝加载其中之一。
还有一个很容易被忽略的问题:程序集版本号格式。BepInPlugin 版本号要用三段数字如 “1.0.0”,有些新写代码的人习惯写成 “1.0” 或 “v1.0.0”,虽然不一定报错,但会导致部分解析工具异常。尽量保持标准三段式,少给自己挖坑。
如果日志里插件名出现了,但没有任何后续输出,说明插件的 Awake 可能抛了异常。去日志里找Error级别的行,通常伴随完整堆栈。我见过的大部分案例都是引用缺失,比如插件引用了某个其他 DLL,但该 DLL 没有被放在 plugins 或 BepInEx 的加载路径里。
5.2 中文乱码:四种场景四种解法
“bepinex乱码”这个热搜词,说明被中文乱码折磨的人不止一个。乱码分四种场景,解决方式完全不同,别一看乱码就以为是编码问题,先定位是哪个环节。
第一种,BepInEx 控制台窗口中文乱码。这是 Windows 控制台代码页的问题。在游戏启动前或系统终端里执行chcp 65001,把活动代码页切成 UTF-8,控制台里的中文通常就正常了。第二种,LogOutput.log 文件本身用记事本打开乱码。记事本对 UTF-8 无 BOM 的识别有历史问题,用 VS Code 或 Notepad++ 打开,一般直接正常显示。第三种,插件源码里的中文字符串在代码里看着正常,但日志输出到控制台乱码。检查一下源码文件编码是不是 UTF-8 with BOM,VS 里通过“文件 -> 高级保存选项”改成“Unicode (UTF-8 with signature) - 代码页 65001”。第四种,游戏界面内的中文 UI 乱码或显示成方块。这不是字符串编码问题,而是 Unity 默认字体不包含中文字形,需要在 UI 层替换为带中文的字体资源。
把上述情况整理成速查表:
| 现象 | 本质原因 | 处理方式 |
|---|---|---|
| 控制台中文乱码 | Windows 控制台代码页不是 UTF-8 | 执行 chcp 65001 或用 VS Code 查看 |
| 日志文件用记事本打开乱码 | 记事本对 UTF-8 无 BOM 识别差 | 改用 VS Code / Notepad++ 打开 |
| 源码字符串输出乱码 | 源文件编码不是 UTF-8 with BOM | 高级保存选项改为 UTF-8 with BOM |
| 游戏 UI 中文变方块 | Unity 默认字体缺中文字形 | 加载中文字体资源替换 UI 字体 |
我之前在一个老游戏上做中文提示,前三种都挨个踩了一遍,最后发现最坑的是第四种:字符串内容完全正确,显示层字体没有中文字形,看起来就像乱码。这种情况你改编码改到天亮都没用,直接换字体才对路。
5.3 MissingMethodException与程序集冲突
你在开发时会碰到很多MissingMethodException或TypeLoadException,这类报错十有八九和程序集版本冲突有关。最常见的原因是插件引用了 NuGet 上的 Harmony 2.x,而 BepInEx 运行时加载的是它自带的核心 0Harmony.dll,两个程序集版本不同,方法签名或程序集标识对不上,运行时找方法自然失败。
解决办法很朴素:引用的 0Harmony.dll 一定要从游戏目录 BepInEx\core 下添加,而不是从 NuGet 下载,并且把复制本地设为 False。BepInEx 自带的是兼容版本,和插件环境匹配程度最高。Harmony 之外的第三方库引用也同理,优先找“能在目标游戏 Mono 运行时里跑”的版本,不要盲目追新。
另一种情况是插件用了 .NET Standard 或 .NET Core 特有的基础类库方法,比如 System.Text.Json 的某些 API,在游戏的 Mono 运行时不支持。遇到这类问题,替换成旧 API,或者自己实现一个轻量解析,别指望游戏运行时给你补齐新的 BCL。老 Mono 能带的东西有限,写插件时要时刻想着“这段代码在五年前的 .NET Framework 环境里能不能跑”。
5.4 IL2CPP游戏的GameAssembly.dll怎么处理
GameAssembly.dll 是 IL2CPP 编译后的产物,把原本的 IL 代码转化成了 C++ 再编译出的本地指令,所以传统的 “引用 Assembly-CSharp.dll + Harmony 托管注入” 方案天然失效。BepInEx 6 的 IL2CPP 分支提供了一套兼容层,思路是在游戏早期初始化托管运行时,再把 IL2CPP 的函数指针暴露给托管侧,从而让 C# 插件能调用游戏内部逻辑。
但 IL2CPP 路线对新手极其不友好。你不仅要做托管补丁,还要懂 IL2CPP 的 Metadata 结构,理解 il2cpp_api 的调用方式,涉及的工具链也复杂得多。网上有一堆工具可以帮你从 GameAssembly.dll 和 global-metadata.dat 里还原大致的方法名和类结构,但这类逆向分析只建议对你自己购买的正版单机游戏做学习研究,不要用来做任何违规的事情。对绝大多数人来说,第一直觉应该是绕开 IL2CPP 游戏,而不是硬刚。
如果非做不可,路径大概是:用 BepInEx 6 的 il2cpp build + Il2CppDumper 一类的工具还原结构,用 il2cpp 互操作 API 写插件。整个过程调试能力大打折扣,断点几乎使不上劲,基本靠日志和原生调试器配合。在没有底子之前,别指望一个晚上就把 IL2CPP Mod 跑通。
5.5 手机端Unity游戏能装BepInEx吗
搜“bepinex 前置手机昨安装”的朋友,多半是看到某个游戏的手游版想装 Mod。结论先说:BepInEx 官方正式支持的平台主要是 Windows、Linux、macOS 桌面游戏的 Mono 或 IL2CPP 环境,手机端尤其是 Android 和 iOS,并没有官方版可用。
Android 上有些社区维护的 BepInEx 移动端 fork,但安装方式非常繁琐:通常需要把文件写入到应用私有目录、修改 APK、设置环境变量,还得处理签名校验,很多游戏改完就闪退。iOS 因为沙盒和签名机制限制更严格,基本没有公开可复现的方案。折腾小半天,最后多半是白忙一场。想搞手机游戏的 Mod,更现实的做法是去关注对应游戏社区的官方 Mod 接口或专用 Mod 加载器,而不是硬套 BepInEx。
如果只是想在手机上复现你写的桌面 Mod 效果,我建议调整思路:先在 PC 版把逻辑跑通,再考虑平台适配,而不是一上来就挑战移动端。这个顺序能帮你省下大量验证时间,也更容易定位问题到底是“插件逻辑”还是“移动端环境”。
6. 三件小事:开发体验升级
最后分享三个开发习惯,都是我自己从反复踩坑里沉淀下来的。第一,备份一份“干净的游戏根目录”。新手期改配置、删文件、乱放 DLL 是家常便饭,如果把原目录备份好,出问题直接还原,比对着报错慢慢猜快得多。Steam 游戏的验证文件完整性也能救场,但整体还原一个目录是最无脑的方案。
第二,写改动前先查日志,写完后立刻看日志。BepInEx 的 LogOutput.log 基本能反映一切:加载链、异常、警告,你的思路要围绕日志文件展开,而不是围绕“我觉得应该没问题”。很多时候你觉得代码没问题,日志里却早就把原因写得明明白白。养成“日志优先”的调试习惯之后,超过七成的插件问题都能在展开编辑器前解决。
第三,开发期插件里多写点用#if DEBUG包起来的调试输出,发布前统一关闭。这样做既不影响用户运行时的性能,又能让调试期间的线索不丢失。Mod 开发到了一定程度,真正难的不是写代码,而是“如何快速定位自己代码和陌生游戏环境之间的断层”,日志在这里就是你的探照灯。希望这篇从零到一的长文能帮你把第一条 BepInEx 插件顺利跑起来,后面就靠你拿着 Harmony 到处探索了。