公司动态
Unity游戏模组开发指南:BepInEx插件框架从入门到精通
1. 项目概述为什么我们需要BepInEx如果你是一个Unity游戏开发者或者是一个热衷于为Unity游戏制作模组的爱好者那么你一定遇到过这样的困境游戏更新了你辛辛苦苦写的插件代码因为游戏程序集Assembly的版本变化或者内存地址偏移瞬间全部失效。又或者你想为游戏添加一个简单的功能却发现需要反编译、重打包游戏DLL过程繁琐且容易出错。更别提那些需要直接操作游戏内存的“危险”操作了一不小心就可能导致游戏崩溃。BepInEx的出现就是为了优雅地解决这些问题。它不是一个具体的插件而是一个插件加载与运行时框架。你可以把它理解为一个“地基”或者“操作系统”它为所有想在Unity游戏里“安家”的插件提供了一套标准、安全、稳定的运行环境。它的核心价值在于“非侵入性”和“运行时补丁”。非侵入性意味着你不需要修改游戏原始的DLL文件所有修改都在游戏运行时动态发生运行时补丁则允许你通过代码“钩住”Hook游戏原有的方法在方法执行前后插入你自己的逻辑或者直接替换掉整个方法。这带来的好处是革命性的插件开发者不再需要为每次游戏更新而疲于奔命地更新偏移地址插件可以以独立的.dll文件形式存在安装和卸载就像复制粘贴一样简单多个插件之间可以共享BepInEx提供的通用服务如日志、配置管理避免了重复造轮子。从《雨中冒险2》Risk of Rain 2到《英灵神殿》Valheim再到《星露谷物语》Stardew Valley的许多知名模组背后都有BepInEx的身影。它已经成为Unity游戏模组社区事实上的标准框架。所以这篇指南的目标就是带你从零开始彻底掌握BepInEx。无论你是想为自己喜欢的游戏制作第一个小插件还是希望为你团队开发的游戏构建一个官方的模组生态理解并运用BepInEx都是至关重要的一步。我们将从最基础的环境搭建开始一步步深入到高级应用场景让你不仅能“用起来”更能“懂得为什么这么用”。2. 环境搭建与核心组件解析搭建BepInEx环境远不止是下载一个压缩包解压那么简单。理解其目录结构和每个核心组件的职责是后续一切高级操作的基础。一个典型的BepInEx安装目录看起来是这样的游戏根目录/ ├── BepInEx/ │ ├── core/ # BepInEx核心运行时库如BepInEx.Core.dll │ ├── plugins/ # 用户插件存放目录你的插件.dll就放这里 │ ├── patchers/ # 预处理器Patcher插件目录用于在游戏早期加载时打补丁 │ ├── config/ # 插件配置文件目录.cfg文件 │ ├── cache/ # 缓存文件如预编译的补丁 │ └── LogOutput.log # 运行时日志文件排查问题的生命线 ├── doorstop_config.ini # Doorstop配置文件注入器核心 ├── winhttp.dll # Doorstop代理DLLWindows └── 游戏主程序.exe2.1 安装与配置手动与自动化的抉择手动安装是最经典、最能理解原理的方式。你需要从BepInEx的GitHub Releases页面下载对应版本的压缩包。这里第一个关键选择就出现了选择x86还是x64版本这完全取决于你的游戏本身是32位还是64位程序。一个简单的判断方法是查看游戏根目录下主程序.exe的属性或者在任务管理器中查看进程名后面是否带有“(32位)”标识。选错版本会导致注入失败游戏无法启动。下载后将压缩包内所有文件解压到游戏根目录即与游戏主.exe文件同级。接下来需要配置doorstop_config.ini这是整个注入过程的“大脑”。有几个关键参数你必须理解targetAssembly这个参数告诉Doorstop注入器最终应该由哪个程序集来接管。对于BepInEx 5这个值固定为BepInEx\core\BepInEx.Preloader.dll。这个Preloader是BepInEx启动链中的第一环。doorstop.enabled总开关必须设为true。doorstop.redirectOutputLog是否将Unity的Debug.Log输出也重定向到BepInEx的日志文件。建议设为true这样游戏内和插件内的日志都能在同一个文件里查看调试时非常方便。注意部分游戏尤其是一些使用了特定反作弊或特殊启动器的游戏可能会与Doorstop的注入方式冲突。如果遇到游戏无法启动、闪退首先检查日志文件LogOutput.log。如果日志文件都没有生成那说明注入环节就失败了。此时可以尝试使用unitydoorstop针对Unity旧版本或者研究游戏特定的启动参数。自动化安装则是通过社区开发的模组管理器如Thunderstore的r2modman或Overwolf的Mod Manager。它们能自动检测游戏、下载正确版本的BepInEx、管理插件依赖和版本冲突。对于只想安心玩模组的用户这是最佳选择。但对于开发者我强烈建议至少经历一次手动安装这能让你在后续遇到诡异问题时有能力进行底层排查。2.2 核心组件职责详解Doorstop它不是一个DLL而是一个技术集合通常是一个重命名的winhttp.dll。它的原理是利用Windows DLL的加载顺序劫持。游戏启动时系统会加载winhttp.dll而Doorstop替换了它从而获得了最早的执行权限。它负责读取配置然后加载BepInEx.Preloader.dll。BepInEx.Preloader这是BepInEx在游戏本体代码运行前就加载的“先遣队”。它的核心任务是在Unity引擎初始化、游戏自身的程序集被加载之前准备好BepInEx自身的运行环境并加载patchers目录下的预处理器插件。这个阶段是进行底层、全局性补丁如修改Unity引擎方法的最佳时机。BepInEx.Core / BepInEx.Unity这是框架的核心运行时。它负责在游戏进入主循环后初始化插件管理系统加载plugins目录下的所有插件并提供日志、配置、事件等基础服务。我们日常开发的插件主要就是与这个运行时层交互。插件Plugins放在BepInEx/plugins下的.dll文件。每个插件都是一个独立的类库包含一个继承自BaseUnityPlugin的主类。这是最常用、最上层的扩展方式。预处理器Patchers放在BepInEx/patchers下的.dll文件。它们比普通插件加载得更早用于在游戏程序集加载时直接对其进行修改通过Harmony库打补丁。常用于修改其他插件依赖的底层游戏代码或者进行一些必须在游戏逻辑开始前完成的复杂操作。理解这套层次分明的加载链你就明白了为什么有些修改必须用Patcher而有些用普通Plugin就行。这就像装修房子Patcher是在打地基、砌墙的阶段修改房屋结构游戏代码而Plugin是在房子建好后往里搬家具和电器添加游戏功能。3. 创建你的第一个BepInEx插件理论说得再多不如动手写一行代码。让我们从一个最简单的“Hello World”插件开始感受BepInEx插件的基本结构和工作流程。这个插件的功能是游戏启动时在控制台打印一条日志。3.1 项目创建与依赖配置首先你需要一个C#开发环境。Visual Studio 2022或Rider都是优秀的选择。新建一个“类库(.NET Framework)”项目。注意.NET目标框架版本至关重要。你必须选择与游戏所使用的Unity版本相匹配的.NET版本。对于使用Unity 2017-2019的游戏通常是**.NET Framework 3.5/4.x**对于Unity 2020可能是**.NET Standard 2.0/2.1或.NET Framework 4.x**。一个稳妥的方法是用dnSpy等工具反编译游戏自身的Assembly-CSharp.dll查看其编译目标。接下来通过NuGet包管理器添加必要的依赖。最核心的两个包是BepInEx.Core提供BaseUnityPlugin等基础接口。BepInEx.Unity可选但推荐包含针对Unity的特定集成和辅助类。HarmonyXBepInEx 5 默认使用HarmonyX库来进行方法补丁这是实现代码注入的利器。添加引用时你还需要引用游戏目录下的关键程序集。通常需要引用的有UnityEngine.CoreModule.dll(位于游戏目录的UnityPlayer_Data/Managed或类似路径下)Assembly-CSharp.dll(游戏主逻辑代码)BepInEx\core\BepInEx.dll(用于访问BepInEx的API)3.2 插件主类与元数据创建一个类例如HelloWorldPlugin并继承BaseUnityPlugin。这个类就是你的插件入口。using BepInEx; using BepInEx.Logging; using UnityEngine; namespace MyFirstBepInExMod { // 最重要的元数据标签 [BepInPlugin(PluginGUID, PluginName, PluginVersion)] public class HelloWorldPlugin : BaseUnityPlugin { // 定义插件的唯一标识符、名称和版本 public const string PluginGUID com.myname.helloworld; public const string PluginName Hello World Plugin; public const string PluginVersion 1.0.0; // 内部日志记录器 internal static ManualLogSource Log; // Awake方法在插件被加载时立即调用早于所有游戏对象的Start private void Awake() { // 初始化日志记录器使用插件的类名作为日志源 Log Logger; // 输出一条信息级别的日志 Log.LogInfo($Plugin {PluginName} is loaded!); // 尝试在游戏内也创建一个文本显示需要游戏有UI环境 // GameObject.CreatePrimitive(PrimitiveType.Cube); // 简单的例子创建一个立方体 } // Update方法每一帧都会被调用类似于MonoBehaviour的Update // 但对于简单插件通常不需要。这里仅为演示。 // private void Update() { } } }关键点解析[BepInPlugin]属性这是插件的“身份证”。PluginGUID必须是全局唯一的通常使用“作者.插件名”的逆域名格式这是BepInEx识别和管理插件的依据。ManualLogSource这是BepInEx提供的日志接口。永远不要使用Console.WriteLine()或Debug.Log()来输出调试信息因为它们的输出可能无法被捕获。使用Logger.LogInfo()、Log.LogWarning()等方法日志会被统一写入LogOutput.log文件并且可以通过BepInEx的日志查看器如BepInEx.ConfigurationManager插件提供的界面实时查看。Awake()方法这是插件初始化的主战场。在这里你应该完成所有一次性的设置工作如读取配置、注册Harmony补丁、订阅游戏事件等。3.3 编译、部署与测试编写完代码后将项目编译为Release模式。将生成的MyFirstBepInExMod.dll取决于你的项目名复制到游戏的BepInEx/plugins目录下。你可以创建一个以你插件命名的子文件夹如BepInEx/plugins/MyHelloWorld/然后把dll放进去这是一个保持整洁的好习惯。启动游戏。如果一切顺利你会在游戏根目录的BepInEx/LogOutput.log文件中看到类似这样的输出[Info : MyFirstBepInExMod.HelloWorldPlugin] Plugin Hello World Plugin is loaded!恭喜你你的第一个BepInEx插件已经成功运行了这标志着你已经打通了从开发到部署的完整流程。4. 深入核心Harmony补丁与游戏交互仅仅打印日志是远远不够的。模组的魅力在于与游戏深度交互修改游戏行为添加新功能。这一切的核心技术就是Harmony补丁。Harmony是一个强大的.NET库它允许你在运行时修改补丁其他方法而无需访问原始源代码。4.1 Harmony补丁类型与应用场景Harmony主要提供三种补丁类型理解它们的执行时机是写出正确补丁的关键前缀补丁Prefix在原方法开始执行前运行。你可以访问和修改原方法的参数。跳过原方法的执行通过返回false。向原方法传递修改后的参数。典型应用验证输入参数、实现条件拦截例如检测玩家是否拥有权限执行某个操作如果没有则阻止原方法执行。后缀补丁Postfix在原方法执行完成后运行无论原方法是正常返回还是抛出异常。你可以访问原方法的参数。读取和修改原方法的返回值通过ref参数。访问原方法执行过程中创建的局部变量通过__result,__instance等特殊参数。典型应用修改方法的返回结果、在方法执行后执行清理工作、记录日志。中缀补丁Transpiler这是最强大也是最复杂的补丁类型。它不是在方法前后插入代码而是直接修改方法的IL指令流。你可以插入、删除或替换原方法中的CIL中间语言指令。实现一些前缀和后缀无法完成的复杂修改例如改变循环逻辑、内联调用其他方法。典型应用进行极底层的性能优化、修复游戏bug、实现极其复杂的游戏机制修改。4.2 实战用前缀与后缀补丁实现无敌模式假设我们想为某个游戏实现一个“无敌模式”功能。我们发现玩家受到伤害时会调用一个名为Player.TakeDamage(float damage)的方法。我们的目标是当无敌模式开启时阻止伤害并播放一个特效。首先我们需要用Harmony创建一个补丁类。这个类不需要继承任何东西只需要包含标记了[HarmonyPatch]属性的静态方法。using HarmonyLib; using UnityEngine; namespace MyFirstBepInExMod { // 使用HarmonyPatch属性关联要补丁的类和方法 [HarmonyPatch(typeof(Player), nameof(Player.TakeDamage))] internal class Patch_PlayerTakeDamage { // 定义一个配置项来控制无敌模式开关 private static ConfigEntrybool configGodMode; // 静态构造器用于初始化配置。这会在类被访问时执行一次。 static Patch_PlayerTakeDamage() { // 假设我们在插件主类中已经创建了这个ConfigEntry // configGodMode Config.Bind(Cheats, GodMode, false, Enable invincibility); } // 前缀补丁方法 [HarmonyPrefix] static bool Prefix(Player __instance, ref float damage) { // 检查无敌模式是否开启 if (configGodMode ! null configGodMode.Value) { // 在原方法执行前将伤害值设为0 damage 0f; // 可以在玩家位置生成一个防御特效需要获取游戏对象 // GameObject.Instantiate(deflectEffect, __instance.transform.position, Quaternion.identity); // 输出调试日志 HelloWorldPlugin.Log.LogInfo($GodMode active! Damage negated for {__instance.playerName}.); // 返回true允许原方法继续执行但伤害已是0 // 如果返回false则会完全跳过原方法的执行 return true; } // 如果无敌模式关闭正常执行原方法 return true; } // 后缀补丁方法我们可能还想在受到伤害后做点什么比如记录日志 [HarmonyPostfix] static void Postfix(Player __instance, float damage) { // 即使伤害被改为0原方法接收到的damage参数值已经是0了。 // 这里可以记录一下这次伤害事件无论是否生效 if (damage 0) { HelloWorldPlugin.Log.LogDebug(${__instance.playerName} took {damage} damage.); } } } }代码要点与避坑指南[HarmonyPatch]属性第一个参数是目标类typeof(Player)第二个参数是目标方法名。使用nameof()操作符是安全的最佳实践可以避免拼写错误。前缀补丁的返回值bool类型。返回true表示继续执行原方法返回false则会跳过原方法的执行。在上例中即使我们修改了伤害为0我们仍然返回true因为可能原方法里除了扣血还有播放受伤动画、触发事件等其他逻辑我们不想跳过它们。特殊参数Harmony会自动向你的补丁方法注入一些上下文信息。__instance如果原方法不是静态方法这个参数代表调用该方法的对象实例即this。在上例中它就是受到伤害的那个Player对象。ref float damage这是原方法的参数。通过ref关键字我们可以在前缀中修改它并在后缀中访问修改后的值。__result在后缀中使用如果原方法有返回值你可以通过ref T __result来访问和修改它。配置管理示例中使用了ConfigEntry。你需要在插件主类的Awake()方法中创建它Config.Bind(Cheats, GodMode, false, Enable invincibility);。BepInEx会自动在BepInEx/config目录下生成一个com.myname.helloworld.cfg文件来存储这个配置。配合ConfigurationManager插件玩家可以在游戏内图形化界面中修改这个设置。最后别忘了在你的插件主类Awake()方法中应用这些Harmony补丁private void Awake() { Log Logger; Log.LogInfo($Plugin {PluginName} is loaded!); // 应用所有带有[HarmonyPatch]属性的补丁 Harmony.CreateAndPatchAll(typeof(HelloWorldPlugin).Assembly); }这行代码会扫描当前程序集你的插件dll中所有标记了[HarmonyPatch]的类并自动为它们创建补丁。5. 高级应用与性能调优当你掌握了基础插件和Harmony补丁后就可以探索更强大的功能了。这些高级应用能让你开发出更稳定、更高效、功能更复杂的模组。5.1 协程与异步操作Unity是单线程的但游戏逻辑常常需要处理延迟执行、循环等待等操作比如等待几秒后刷新一个道具。如果在Update方法里用计数器模拟代码会变得非常臃肿。这时就该使用协程Coroutine。BepInEx插件本身继承自BaseUnityPlugin而它又间接继承自MonoBehaviour因此你可以直接使用StartCoroutine(IEnumerator routine)方法。private void Awake() { // ... 其他初始化 StartCoroutine(PeriodicLogRoutine()); } private System.Collections.IEnumerator PeriodicLogRoutine() { while (true) // 小心这是一个无限循环需要有退出条件。 { Log.LogInfo(This message prints every 5 seconds.); yield return new WaitForSeconds(5f); // 关键挂起协程5秒 // 在这5秒内Unity主线程可以自由处理其他事情不会阻塞。 } }注意事项协程虽然好用但一定要管理好生命周期。如果插件被禁用或游戏对象销毁还在运行的协程可能会引发错误。可以在OnDestroy()方法中停止所有协程或者使用一个布尔标志来控制循环退出。5.2 事件订阅与游戏生命周期直接使用Harmony补丁去钩每一个方法有时过于笨重。许多游戏或BepInEx插件会暴露一些事件Event允许你以更优雅、解耦的方式响应用户操作或游戏状态变化。例如假设游戏有一个GameEvents静态类提供了OnPlayerSpawned事件// 在你的插件Awake方法中订阅事件 private void Awake() { GameEvents.OnPlayerSpawned OnPlayerSpawnedHandler; } private void OnPlayerSpawnedHandler(Player player) { Log.LogInfo($Player {player.Name} has spawned! Giving starter kit.); // 在这里给新玩家发放初始装备 } // 非常重要在插件卸载时取消订阅防止内存泄漏 private void OnDestroy() { GameEvents.OnPlayerSpawned - OnPlayerSpawnedHandler; }对于Unity引擎本身的事件如场景加载SceneManager.sceneLoaded也可以直接订阅。这种方式比用Harmony去补丁具体的加载方法更清晰、更安全。5.3 性能考量与最佳实践随着插件越来越复杂性能问题不容忽视。一个糟糕的插件足以让游戏帧率骤降。减少每帧操作Update除非必要否则不要在Update方法中执行复杂逻辑或频繁的查找如GameObject.Find。如果需要持续检查考虑使用协程配合WaitForSeconds进行轮询或者基于事件驱动。缓存引用频繁访问的对象或组件如玩家对象、UI根节点应该在Awake或Start中获取一次并缓存起来而不是每次使用时都去查找。private Player localPlayer; private void Start() { // 假设有一个方法能获取本地玩家这个查找可能比较耗时 localPlayer GetLocalPlayer(); // 之后在Update中直接使用localPlayer而不是反复调用GetLocalPlayer() }慎用中缀补丁TranspilerTranspiler非常强大但直接操作IL指令极易出错且难以调试。它应该是你最后的选择。在能用前缀/后缀补丁组合实现功能时就不要用Transpiler。管理补丁数量每个Harmony补丁都有微小的性能开销。避免为同一个方法创建多个功能相似的小补丁尽量将它们合并到一个补丁类中。使用条件编译与日志级别在开发阶段使用Log.LogDebug输出大量信息在发布版本中可以通过条件编译指令#if DEBUG来移除这些日志或者将BepInEx的日志级别设置为Info或Warning减少磁盘I/O开销。6. 调试、排查与社区资源开发过程中遇到问题是常态。高效的调试和排查能力是资深模组开发者和新手的核心区别。6.1 日志你的第一道防线BepInEx的日志系统是你的最佳伙伴。LogOutput.log文件记录了从框架启动到所有插件运行的全部信息。日志级别合理使用LogDebug调试信息、LogInfo常规信息、LogWarning警告不影响运行但需注意、LogError错误功能失效和LogFatal致命错误可能导致崩溃。使用ConfigurationManager安装这个BepInEx插件后你可以在游戏内按F1默认打开一个配置窗口里面有一个“Logging”选项卡可以实时查看和过滤日志比翻文件方便得多。堆栈跟踪当抛出异常时日志会包含完整的堆栈跟踪。仔细阅读它它能精确告诉你错误发生在哪一行代码、哪个方法。6.2 常见问题排查表问题现象可能原因排查步骤游戏启动崩溃无日志Doorstop注入失败1. 检查doorstop_config.ini中enabledtrue。2. 检查winhttp.dll/version.dll是否与游戏位数匹配。3. 尝试以管理员身份运行游戏。4. 某些杀毒软件可能拦截尝试暂时禁用。游戏启动后无反应日志停在某处某个插件或补丁在Awake阶段卡死或抛异常1. 查看LogOutput.log最后几行寻找错误或警告。2. 使用“二分法”移出一半插件重启游戏逐步定位问题插件。插件功能不生效但日志显示已加载1. Harmony补丁未正确应用。2. 目标方法签名不匹配。3. 代码逻辑条件未触发。1. 检查补丁类是否被Harmony.CreateAndPatchAll扫描到。2. 用dnSpy反编译游戏DLL确认目标类名、方法名、参数类型完全一致注意泛型、重载。3. 在补丁方法开始处加日志确认是否被执行。游戏运行一段时间后崩溃内存泄漏、未取消订阅事件、协程未正确停止1. 检查所有事件订阅是否在OnDestroy中取消。2. 检查长时间运行的协程是否有退出机制。3. 使用性能分析工具监控内存。与其他插件冲突修改了同一游戏方法或资源1. 查看双方插件的日志看是否有错误提示。2. 尝试调整插件加载顺序BepInEx默认按文件名排序。3. 联系另一个插件的作者协商兼容方案。6.3 不可或缺的开发工具dnSpy / ILSpy.NET反编译神器。用于查看游戏原始的C#代码和IL指令是编写Harmony补丁的“地图”。没有它模组开发就像闭着眼睛走路。Unity Explorer或BepInEx Debug Tools这些BepInEx插件可以在游戏内提供一个类似Unity编辑器的调试界面让你实时查看场景中的游戏对象GameObject、组件Component、属性值甚至动态调用方法。对于理解游戏运行时结构和调试UI相关模组至关重要。Visual Studio Debugger如果游戏是使用Mono或IL2CPP且开启了托管调试开发的你可以将Visual Studio调试器附加到游戏进程像调试普通C#程序一样设置断点、单步执行、查看变量。这是最强大的调试手段但设置相对复杂。GitHub 社区论坛BepInEx的官方GitHub仓库、Wiki以及相关游戏的模组社区如Thunderstore的讨论区是宝贵的资源。你遇到的问题很可能别人已经遇到并解决了。学会搜索和提问。开发BepInEx插件是一个不断探索、逆向和创造的过程。它要求你不仅是程序员还是游戏机制的“侦探”。从读懂游戏代码开始到用巧妙的补丁改变它最终创造出全新的游戏体验这种成就感是独一无二的。记住保持耐心善用日志和工具积极参与社区你的每一个插件都在让某个游戏世界变得更加有趣。