BepInEx插件开发入门:从环境搭建到Harmony补丁实战
1. 项目概述为什么选择BepInEx作为Unity游戏插件开发的起点如果你是一个Unity游戏玩家尤其是对《雨中冒险2》、《英灵神殿》这类支持模组的游戏情有独钟那你一定对“插件”或“模组”不陌生。它们能改变游戏规则添加新内容甚至修复官方未处理的Bug极大地延长了游戏的生命周期。而BepInEx正是连接玩家创意与游戏世界的那座最稳固、最通用的桥梁。它不是某个特定游戏的专属工具而是一个为Unity引擎游戏量身打造的、强大的插件/模组加载与运行框架。简单来说BepInEx是一个“中间人”。它能在游戏启动时将自己“注入”到游戏进程中接管Unity引擎的某些核心加载流程。这样一来它就能在游戏加载官方资源的同时也加载我们开发者编写的、以DLL动态链接库形式存在的插件代码并让这些代码在游戏运行时生效。这个过程我们称之为“运行时补丁”或“Hook”钩子。与直接修改游戏原生DLL文件俗称“破解”或“crack”这种高风险、不兼容且易被反作弊系统检测的方式不同BepInEx采用非侵入式的内存注入安全、可逆并且为插件之间提供了良好的隔离和协作机制。为什么说它是“终极”选择因为在Unity游戏模组开发社区BepInEx已经成为了事实上的标准。它拥有庞大的社区支持、详尽的文档尽管有些是社区贡献的、以及经过无数模组验证的稳定性。从简单的界面修改到复杂的游戏机制重写BepInEx都能提供相应的底层支持。对于开发者而言它抽象了复杂的注入细节让我们可以更专注于插件功能本身的逻辑实现用熟悉的C#和.NET环境进行开发这大大降低了入门门槛。所以无论你是想为自己喜爱的游戏制作一个显示更多信息的小插件还是想开发一个功能全面的 overhaul大修模组从BepInEx开始你的旅程都是一个明智且高效的选择。它适合所有有一定C#和Unity基础并渴望将自己的想法融入现有游戏的开发者。2. 核心思路与工具链搭建构建你的开发环境在动手写代码之前一个顺手且正确的开发环境是成功的一半。BepInEx插件开发本质上是在为某个特定的、已编译的Unity游戏制作“外挂”程序集因此我们的环境需要同时兼顾.NET开发、Unity游戏资源分析以及最终的测试部署。2.1 开发环境选型解析集成开发环境IDEVisual Studio 2022 Community这是我们的主力代码编辑器。选择VS2022社区版因为它免费、功能强大对C#和.NET的支持最为完善。你可能会看到一些关于在Visual Studio中使用Qt插件或其他机制的讨论但那与我们无关。我们只需要它的核心C#开发、NuGet包管理和调试功能。确保安装时勾选“.NET桌面开发”工作负载。代码辅助工具Cursor或VS Code可选但推荐如果你觉得VS2022过于庞大或者喜欢更轻量、AI辅助更强的编辑器Cursor是一个新兴的优秀选择。它基于VS Code但深度整合了AI代码补全和对话功能。在开发过程中当你需要快速查询某个BepInEx API的用法或者让AI帮你生成一段常见的Hook代码模板时Cursor能显著提升效率。当然传统的VS Code配合C#扩展也能胜任。目标游戏与分析工具目标游戏你需要一个确定的支持BepInEx的Unity游戏并确保其已安装。例如《Risk of Rain 2》就是一个绝佳的入门选择其模组生态极其繁荣。Unity游戏分析利器dnSpy / ILSpy这是插件开发者的“眼睛”。游戏本身的逻辑都编译在Assembly-CSharp.dll等程序集中。我们需要使用dnSpy或ILSpy这类.NET反编译工具来打开游戏的DLL文件查看其中的类、方法、字段以确定我们需要修改或交互的代码位置。这是进行“运行时补丁”的前提因为你必须知道你要“钩”住哪里。BepInEx开发包我们需要通过NuGet为项目引用BepInEx的核心库。通常需要以下两个包BepInEx.Core 提供插件系统核心、日志、配置等基础功能。BepInEx.Unity/BepInEx.Harmony 提供与Unity引擎的集成以及对Harmony库的支持用于方法级别的代码修补。注意 不要从不明来源下载所谓的“BepInEx SDK”压缩包。始终通过Visual Studio的NuGet包管理器来获取官方发布的包这是保证依赖版本正确和项目可维护性的关键。2.2 创建与配置插件项目新建项目 在Visual Studio中创建一个新的“类库(.NET Framework)”项目。框架版本的选择至关重要。你必须根据目标游戏所使用的.NET版本通常是.NET Framework 4.x如4.7.2来选择。选错框架版本会导致插件无法被加载。项目名称可以定为类似MyFirstBepInExPlugin。引用BepInEx库 右键点击项目“引用” - “管理NuGet程序包”。在浏览选项卡中搜索BepInEx.Core并安装。通常它会自动引入相关的依赖如Harmony。对于大多数Unity游戏你还需要安装BepInEx.Harmony或BepInEx.Unity取决于游戏和BepInEx版本以官方文档为准。关键项目配置右键项目 - “属性”。在“应用程序”选项卡确保“目标框架”与游戏匹配。在“生成”选项卡将“输出路径”修改为一个方便的位置比如bin\Debug\。这会让编译后的DLL生成在项目下的bin\Debug文件夹便于我们后续手动复制到游戏目录进行测试。分析游戏程序集 找到你的游戏安装目录进入游戏名_Data\Managed文件夹。将Assembly-CSharp.dll文件复制到一个安全位置不要直接在原目录操作。用dnSpy打开这个文件。现在你可以像浏览源代码一样浏览游戏的所有逻辑了。例如如果你想做一款修改玩家金币的插件就可以搜索“Gold”、“Currency”、“Player”等关键词来定位相关类和方法。3. 插件核心架构与基础代码实现一个最基本的BepInEx插件由几个核心部分组成插件元数据、启动入口、配置管理和日志系统。理解这些是编写任何功能的基础。3.1 插件类与元数据定义每个插件都必须有一个作为入口的主类并使用BepInEx提供的特性Attribute进行标记。using BepInEx; using BepInEx.Logging; using HarmonyLib; // 命名空间建议与插件名相关避免冲突 namespace MyFirstBepInExPlugin { // 最重要的特性标记这是一个BepInEx插件 // GUID必须是全球唯一的通常使用“作者名.插件名”的格式 [BepInPlugin(PluginInfo.PLUGIN_GUID, PluginInfo.PLUGIN_NAME, PluginInfo.PLUGIN_VERSION)] public class Plugin : BaseUnityPlugin // 必须继承自BaseUnityPlugin { // 内部静态引用方便其他类访问插件实例和日志 internal static Plugin Instance { get; private set; } // 日志记录器用于输出信息到BepInEx的控制台和日志文件 internal static ManualLogSource Log Instance.Logger; // 插件的配置项如果需要 internal static BepInEx.Configuration.ConfigFile Config Instance.Config; // Awake方法在插件被加载时由BepInEx自动调用这是你的启动代码 private void Awake() { // 设置静态实例 Instance this; // 输出一条日志确认插件已加载 Log.LogInfo($插件 {PluginInfo.PLUGIN_NAME} v{PluginInfo.PLUGIN_VERSION} 正在加载...); // 应用Harmony补丁这是实现代码修改的核心稍后详解 Harmony.CreateAndPatchAll(typeof(Plugin).Assembly); Log.LogInfo($插件 {PluginInfo.PLUGIN_NAME} 加载完成); } } // 一个静态类用于集中定义插件的元信息保持整洁 public static class PluginInfo { public const string PLUGIN_GUID com.yourname.myfirstbepinexplugin; public const string PLUGIN_NAME 我的第一个BepInEx插件; public const string PLUGIN_VERSION 1.0.0; } }代码解析与注意事项[BepInPlugin] 这个特性是插件的“身份证”。BepInEx通过它来识别和加载插件。GUID是唯一标识符必须确保不与网络上其他插件重复否则会导致冲突。Name和Version会显示在BepInEx的启动日志和某些模组管理器中。BaseUnityPlugin 继承这个类你的插件就自动获得了日志记录器(Logger)、配置文件(Config)等基础服务。Awake() 这是Unity标准的生命周期方法之一。在BepInEx中它等同于插件的“主函数”。所有初始化逻辑都应放在这里。日志的重要性 在插件开发中Log.LogInfo/Debug/Warning/Error()是你的最佳伙伴。通过日志你可以在游戏运行时观察插件的行为这是排查问题的首要手段。永远不要使用Console.WriteLine()因为它的输出在打包的游戏里是看不到的。3.2 使用Harmony进行方法级代码修补Harmony库是BepInEx实现运行时修改的“魔法棒”。它允许你在不接触原始DLL文件的情况下在目标方法执行前、后或完全替换它。这是插件实现游戏逻辑修改的核心技术。假设通过dnSpy我们发现在游戏中有个Player类里面有一个AddGold(int amount)方法我们想实现“双倍金币”的效果。第一步创建补丁类和方法我们不在主Plugin类里直接写Harmony代码而是创建一个专门的补丁类这样结构更清晰。using HarmonyLib; namespace MyFirstBepInExPlugin.Patches { // HarmonyPatch特性用于指定要修补的目标类和方法 [HarmonyPatch(typeof(Player))] // 目标类Player [HarmonyPatch(nameof(Player.AddGold))] // 目标方法AddGold internal class PlayerAddGoldPatch { // Prefix补丁在目标方法执行前运行 // 方法名可以是任意的但必须为静态返回类型为bool可选并接收与目标方法相同的参数 static bool Prefix(ref int amount) { // 修改传入的amount参数实现双倍效果 Plugin.Log.LogInfo($原金币增加量: {amount}); amount * 2; Plugin.Log.LogInfo($修改后金币增加量: {amount}); // 返回 true 表示继续执行原始方法返回 false 则会跳过原始方法的执行 return true; } // Postfix补丁在目标方法执行后运行 // 可以访问目标方法的返回值通过 __result和参数 // static void Postfix(int amount, ref int __result) // { // // 如果AddGold有返回值可以在这里进一步修改__result // } } }第二步应用补丁回到主Plugin类的Awake方法中我们需要让Harmony知道这个补丁类的存在。我们之前使用的Harmony.CreateAndPatchAll(typeof(Plugin).Assembly);这行代码会自动扫描当前程序集即你的插件DLL中所有带有[HarmonyPatch]特性的类并应用补丁。这是一种简便的批量注册方式。Harmony补丁类型详解Prefix前缀 在原方法执行之前运行。可以通过返回false来阻止原方法执行。常用于修改参数、进行条件检查或完全替代原方法逻辑。Postfix后缀 在原方法执行之后运行。可以读取和修改原方法的返回值通过__result也可以访问参数。常用于处理结果、触发额外事件。Transpiler编译器 高级功能直接操作原方法的CIL中间语言指令。用于进行更底层、更复杂的修改比如修改循环次数、插入新的指令等。新手初期很少用到。实操心得 在编写Prefix/Postfix时参数的命名和类型必须与目标方法完全一致可以使用ref来修改值类型参数。Harmony使用了一种叫做“参数访问”的机制你甚至可以通过定义名为__instance的参数来访问目标方法所属的实例对象对于非静态方法。多查阅Harmony的官方文档和示例是掌握这门“魔法”的关键。4. 配置系统与用户交互一个成熟的插件应该允许用户进行自定义配置而不是把参数硬编码在代码里。BepInEx内置了一个简单但强大的配置系统。4.1 创建与绑定配置项我们在Plugin类的Awake方法中初始化完日志后来创建配置。private void Awake() { Instance this; Log.LogInfo($插件 {PluginInfo.PLUGIN_NAME} v{PluginInfo.PLUGIN_VERSION} 正在加载...); // 1. 绑定配置文件 // Config属性来自BaseUnityPlugin它已经关联了一个以插件GUID命名的.cfg文件 // 我们在此处定义配置项 BindConfigurations(); // 2. 应用Harmony补丁 Harmony.CreateAndPatchAll(typeof(Plugin).Assembly); Log.LogInfo($插件 {PluginInfo.PLUGIN_NAME} 加载完成); } private void BindConfigurations() { // 创建一个“通用”配置部分 var section Config.Bind( Settings, // 配置部分Section的名称用于在配置文件中分组 GoldMultiplier, // 配置项的键Key 2.0f, // 默认值 金币倍率。设置为1.0为原版2.0为双倍0.5为减半。 // 描述会显示在配置文件中 ); // 将配置项的值存储在一个静态属性中方便全局访问 // 注意Config.Bind返回的ConfigEntryT对象其Value属性才是当前值 PluginSettings.GoldMultiplier section.Value; // 订阅配置值改变事件可选 section.SettingChanged (sender, args) { PluginSettings.GoldMultiplier section.Value; Log.LogInfo($金币倍率已更新为: {PluginSettings.GoldMultiplier}); }; } // 一个静态类来集中管理所有配置值 public static class PluginSettings { public static float GoldMultiplier { get; set; } 2.0f; }4.2 在代码中使用动态配置现在我们可以修改之前的Harmony补丁使用动态的配置值而不是硬编码的2。[HarmonyPatch(typeof(Player))] [HarmonyPatch(nameof(Player.AddGold))] internal class PlayerAddGoldPatch { static bool Prefix(ref int amount) { // 使用配置值 float multiplier PluginSettings.GoldMultiplier; if (Mathf.Approximately(multiplier, 1.0f)) // 如果倍率是1则不做任何事直接跳过补丁逻辑以提升性能 { return true; } Plugin.Log.LogDebug($应用金币倍率: {multiplier}); // 注意amount是intmultiplier是float需要处理类型转换和舍入 int originalAmount amount; amount Mathf.RoundToInt(originalAmount * multiplier); Plugin.Log.LogInfo($金币变化: {originalAmount} - {amount} (倍率: {multiplier})); return true; } }生成的配置文件 当插件第一次运行后会在BepInEx/config目录下生成一个com.yourname.myfirstbepinexplugin.cfg文件。用户可以用任何文本编辑器打开并修改GoldMultiplier的值下次启动游戏时就会生效。[Settings] ## 金币倍率。设置为1.0为原版2.0为双倍0.5为减半。 # Setting type: Single # Default value: 2 GoldMultiplier 2注意事项线程安全Config.Bind和访问Value在Awake中调用是安全的。但如果你的插件在其他线程非游戏主线程中读取配置需要考虑同步问题不过大多数简单插件不会遇到。类型匹配Bind方法的泛型参数T必须与默认值的类型一致。支持基本类型int, float, bool, string和枚举。文件位置 配置文件位于BepInEx/config日志位于BepInEx/LogOutput.log。教会用户如何找到这些文件是提供支持的第一步。5. 高级技巧与Unity游戏对象和UI交互很多插件不仅需要修改数据还需要创建或修改游戏内的UI、监听游戏事件等。这需要与Unity引擎的运行时进行更深入的交互。5.1 使用MonoBehaviour创建游戏内行为BaseUnityPlugin本身继承自MonoBehaviour这意味着你的主插件类可以像普通的Unity组件一样使用Update(),OnGUI()等生命周期方法。但对于需要独立GameObject或更复杂行为的情况最好创建单独的MonoBehaviour。using UnityEngine; namespace MyFirstBepInExPlugin.Components { public class GoldDisplayHUD : MonoBehaviour { private Player _player; private GUIStyle _labelStyle; void Start() { // 尝试在场景中寻找Player实例 // 注意游戏对象的查找时机很关键可能需要在游戏加载完成后的某个时刻进行 _player FindObjectOfTypePlayer(); if (_player null) { Plugin.Log.LogError(未找到Player对象); enabled false; // 禁用此组件 return; } // 创建GUI样式 _labelStyle new GUIStyle(GUI.skin.label); _labelStyle.fontSize 24; _labelStyle.normal.textColor Color.yellow; } void OnGUI() { // 使用IMGUI在屏幕左上角绘制当前金币 if (_player ! null) { GUI.Label(new Rect(10, 10, 300, 50), $金币: {_player.CurrentGold}, _labelStyle); // 可以绘制更多信息比如倍率 GUI.Label(new Rect(10, 50, 300, 50), $当前倍率: {PluginSettings.GoldMultiplier}x, _labelStyle); } } void Update() { // 每帧都可以在这里做一些事情例如检查玩家状态 // 但要注意性能避免每帧进行昂贵的查找或计算。 } } }然后你需要在插件加载时的某个时机例如在确认游戏场景已加载后将这个组件添加到游戏中的一个GameObject上。// 在Plugin类中添加一个方法 private void CreateHUD() { // 创建一个新的空GameObject来承载我们的HUD组件 GameObject hudGO new GameObject(MyPlugin_HUD); DontDestroyOnLoad(hudGO); // 非常重要防止场景切换时对象被销毁 // 将我们的显示组件添加上去 hudGO.AddComponentComponents.GoldDisplayHUD(); Log.LogInfo(金币显示HUD已创建。); }调用CreateHUD()的时机需要谨慎。一个常见且相对安全的地方是在Awake()中启动一个协程等待几帧或等待某个特定的游戏管理器初始化完成后再创建。5.2 监听与触发游戏事件除了修改方法有时我们还需要在特定游戏事件发生时执行代码比如玩家死亡、场景加载完成等。如果游戏本身使用了C#事件event或标准的Unity事件如UnityEngine.Events.UnityEvent我们可以通过Harmony进行订阅。更通用的方法是找到负责触发这些事件的方法并对它们进行Postfix补丁。例如假设游戏有一个GameEvents.OnPlayerDied的静态事件[HarmonyPatch(typeof(GameEvents))] [HarmonyPatch(nameof(GameEvents.TriggerPlayerDied))] internal class PlayerDiedEventPatch { static void Postfix() { // 玩家死亡后执行的逻辑 Plugin.Log.LogWarning(玩家已死亡); // 可以在这里重置一些插件状态或者弹出自定义提示 } }如果游戏没有暴露这样清晰的事件你可能需要去修补具体的功能方法比如Player.TakeDamage或Player.Respawn。高级技巧与避坑指南时机就是一切 在Unity中很多操作都依赖于正确的执行时机。不要在Awake()里试图访问尚未实例化的游戏对象。使用Start()协程、或监听SceneManager.sceneLoaded事件来确保你的代码在正确的时机运行。性能考量OnGUI()每帧调用多次效率不高仅适用于简单的调试信息显示。对于复杂的UI可以考虑使用游戏自带的UI系统如果暴露了的话或者更高级的UI框架如Unity官方的UI Toolkit但这需要游戏包含相关程序集。对象持久化 用new GameObject()创建的对象务必记得DontDestroyOnLoad()否则在切换场景时它会被销毁导致你的插件功能失效。错误处理 对任何可能为null的对象如FindObjectOfType的结果进行判空。健壮的插件不应该因为一个意外的null引用而导致游戏崩溃。6. 调试、测试与发布全流程6.1 本地调试与日志追踪调试BepInEx插件最直接的方式就是通过日志。除了使用Plugin.Log你还可以通过BepInEx的日志查看器来实时监控。控制台输出 如果游戏是通过BepInEx的启动器如doorstop_config.ini配置启动的并且游戏本身有控制台窗口或通过winhttp.dll等方式启用了控制台那么Log.LogInfo等信息会直接打印在控制台。日志文件 所有日志都会写入BepInEx/LogOutput.log文件。这是最可靠的记录。BepInEx控制台 一些工具如BepInEx自带的BepInEx.ConfigurationManager插件可以提供游戏内的控制台方便查看日志。调试技巧在关键逻辑分支处添加详细的日志包括变量值。使用Log.LogDebug输出更详细的信息并在发布版本中通过修改BepInEx的日志等级配置来关闭它们避免日志文件过大。如果插件导致游戏崩溃第一时间检查LogOutput.log文件的末尾通常会有堆栈跟踪信息。6.2 插件测试流程编译 在Visual Studio中生成你的项目通常是CtrlShiftB。部署 将编译生成的MyFirstBepInExPlugin.dll位于项目的bin\Debug\或bin\Release\目录下复制到游戏的BepInEx\plugins文件夹下。如果该文件夹不存在请先运行一次已安装BepInEx的游戏它会自动生成。正确的路径示例Steam\steamapps\common\Risk of Rain 2\BepInEx\plugins\MyFirstBepInExPlugin\MyFirstBepInExPlugin.dll建议为你的插件创建一个子文件夹这样更整洁。启动游戏 正常启动游戏。观察游戏启动时控制台或日志中是否有你的插件加载信息。功能验证 在游戏中触发你插件设计的功能比如捡金币观察游戏内效果和日志输出是否符合预期。配置测试 修改BepInEx\config下的配置文件重启游戏或触发配置重载如果支持检查功能是否随之改变。6.3 打包与发布当你确认插件稳定后就可以准备分享给其他玩家了。清理与编译Release版本 在Visual Studio中将解决方案配置切换到“Release”然后重新生成。这会对代码进行优化并移除调试符号使DLL文件更小。创建发布包 通常是一个压缩包ZIP包含以下内容README.md 必含用清晰的语言说明插件功能、安装方法、配置说明、已知问题等。CHANGELOG.md 版本更新日志。plugins/YourPluginName/YourPlugin.dll 你的插件主文件。plugins/YourPluginName/icon.png可选 插件图标供模组管理器显示。config/可选 如果插件有默认配置文件可以包含一个示例。manifest.json如果发布到Thunderstore等模组平台 这是模组平台的元数据文件定义了插件名、版本、作者、依赖等。选择发布平台Thunderstore 许多热门Unity游戏如《雨中冒险2》、《英灵神殿》的模组社区都使用它。你需要注册账号按照平台指引上传你的ZIP包。GitHub Releases 适合技术向玩家和作为备用下载源。可以很好地管理版本和issue。游戏专属论坛/社区 如Steam创意工坊、Reddit板块、Discord频道等。6.4 常见问题排查速查表在开发和测试过程中你几乎一定会遇到下面这些问题。这里提供一个快速排查指南。问题现象可能原因解决方案游戏启动时BepInEx控制台一闪而过/插件未加载1. BepInEx安装不正确。2. 插件DLL放错了位置。3. 插件依赖的BepInEx或Harmony版本与游戏不匹配。1. 检查游戏根目录下是否有BepInEx文件夹及doorstop_config.ini等文件。2. 确认DLL放在BepInEx/plugins或其子目录下。3. 检查游戏使用的BepInEx版本并确保你的插件项目引用了兼容版本的NuGet包。插件已加载但日志显示Harmony补丁失败1. 目标方法签名参数、返回类型不匹配。2. 目标类或方法名错误包括重载。3. 游戏更新方法已改名或移除。1. 用dnSpy再次确认目标方法的完整签名包括参数类型和返回类型。2. 检查[HarmonyPatch]特性中的类名和方法名是否正确。对于重载方法需要使用[HarmonyPatch(Type, new Type[] {参数类型数组})]来指定。3. 更新你的插件以适应新版本游戏。游戏运行正常但插件功能未生效1. 补丁逻辑有误如Prefix返回了false阻止了原方法。2. 配置未正确加载或使用。3. 代码执行时机不对如访问的对象为null。1. 在补丁方法开始处添加日志确认补丁是否被执行。2. 检查配置文件路径和内容在代码中打印配置值确认。3. 添加更多的空值检查和日志确认代码执行路径。考虑使用StartCoroutine延迟初始化。修改配置后插件行为未改变1. 配置值改变事件未正确订阅或处理。2. 插件代码中缓存了旧的配置值未读取最新值。1. 确保在BindConfigurations中订阅了SettingChanged事件并在事件处理中更新静态变量。2. 避免在局部变量中长期保存配置值每次都从ConfigEntry.Value或你的静态设置类中读取。游戏崩溃日志末尾有NullReferenceException代码中访问了未初始化的Unity对象如FindObjectOfType返回null。1. 在所有可能访问Unity对象的地方添加严格的null检查。2. 确保你的组件在合适的时机如场景加载完成后再去查找对象。使用GameObject.Find或FindObjectOfType时要意识到它们的性能开销和可能返回null。开发BepInEx插件的旅程就像是在一个已经建好的乐高城堡上添加自己设计的模块。你需要细心观察原有结构用dnSpy分析使用安全的连接方式Harmony并确保你的模块不会让城堡倒塌稳定性。这个过程充满了挑战但当看到自己的创意在喜爱的游戏中变为现实那种成就感是无与伦比的。从最简单的数值修改开始逐步尝试创建UI、监听事件最终你将能打造出功能丰富、影响深远的模组。记住社区是你的后盾遇到难题时去游戏的模组Discord或相关论坛提问通常会有热心的前辈为你解答。