ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity游戏模组开发入门:基于BepInEx的插件框架搭建与Harmony代码注入实战

Unity游戏模组开发入门:基于BepInEx的插件框架搭建与Harmony代码注入实战 1. 项目概述为什么需要BepInEx如果你玩过基于Unity引擎开发的PC游戏比如《雨中冒险2》、《英灵神殿》或者《星露谷物语》你大概率听说过“模组”这个词。模组或者说Mod是玩家社区为游戏注入新生命力的核心方式。但你是否想过这些模组是如何“挂载”到游戏本体上在不修改原始游戏文件的情况下实现新功能、新物品甚至新玩法的这背后就需要一个稳定、通用且强大的插件框架作为桥梁。BepInEx正是这样一座桥梁。它不是一个具体的模组而是一个模组加载器和插件框架。简单来说它负责在游戏启动时将自己“注入”到游戏进程中为后续所有模组提供一个统一的运行环境和管理平台。没有它每个模组开发者都需要各自为战用各种“黑科技”去Hook游戏代码不仅兼容性差还极易导致游戏崩溃。而有了BepInEx开发者只需要遵循其规范编写插件剩下的加载、初始化、依赖管理、日志输出等脏活累活都交给框架来处理。所以这个项目的目标非常明确从零开始搭建一个基于BepInEx的Unity游戏模组开发环境。这不是一个简单的“下载即用”教程而是一次深入框架内部的探索。我们将从理解BepInEx的核心架构开始一步步完成环境配置、基础插件开发、高级功能集成直到最终打包分发。无论你是想为自己喜欢的游戏制作第一个小Mod的爱好者还是希望为团队建立标准化模组开发流程的开发者这篇指南都将为你铺平道路。2. BepInEx核心架构与工作原理拆解在动手之前我们必须先搞清楚BepInEx是怎么工作的。知其然更要知其所以然这能帮助我们在后续开发中避开无数深坑。2.1 核心组件与启动流程BepInEx的启动是一个精妙的“寄生”过程。它主要包含以下几个核心组件BepInEx Bootstrapper (引导程序)这通常是一个名为winhttp.dll(Windows) 或lib开头的文件 (Unix-like系统)。游戏启动时操作系统或Steam等平台会优先加载这个DLL。它的唯一任务就是加载真正的BepInEx核心。BepInEx Core (核心库)包含BepInEx.Core.dll,BepInEx.Harmony.dll,BepInEx.Preloader.dll等。它们负责初始化框架环境比如设置日志系统、配置文件、路径管理以及最重要的——修补游戏程序集。Plugin Loader (插件加载器)核心初始化完成后会扫描BepInEx/plugins目录加载所有符合规范的插件DLL并调用它们的入口方法。Harmony Library (Harmony库)这是BepInEx的“魔法”之源。Harmony是一个强大的.NET运行时补丁库它允许我们在不接触游戏源代码的情况下修改游戏运行时的代码逻辑。BepInEx深度集成了Harmony让模组开发变得异常简单。启动流程可以简化为游戏启动 - 加载BepInEx引导DLL - 引导程序加载BepInEx核心 - 核心初始化日志、配置、Harmony - 扫描并加载所有插件 - 插件通过Harmony对游戏代码打补丁 - 游戏主循环开始模组生效。2.2 Mono vs IL2CPP两种不同的“注入”策略Unity游戏有两种主要的脚本后端Mono和IL2CPP。BepInEx对它们的处理方式有本质区别理解这一点至关重要。Mono传统的.NET运行时环境。游戏代码被编译成.NET程序集DLL。BepInEx针对Mono游戏主要采用“程序集修补”的方式。它在游戏加载其主程序集如Assembly-CSharp.dll之前先加载自己的核心然后利用MonoMod.RuntimeDetour或类似的工具对游戏的方法进行拦截和替换。这种方式相对直接但依赖于Mono运行时提供的反射和JIT即时编译特性。IL2CPPUnity将C#代码编译成C然后再编译为原生机器码。这带来了性能提升和更好的代码混淆保护但也关闭了传统的.NET反射大门。针对IL2CPPBepInEx采用了更底层的“函数Hook”技术。它不再直接修改C#程序集而是通过修改游戏原生二进制文件如GameAssembly.dll在内存中的指令将函数调用重定向到我们自己的代码上。BepInEx 5.0及以上版本通过BepInEx.IL2CPP支持这一模式其底层依赖于像MonoMod.Utils和自定义的IL2CPP交互层。注意对于IL2CPP游戏BepInEx的安装通常需要额外的步骤例如使用专门的安装器如UnityModManager的特定版本或BepInEx针对该游戏发布的特殊构建版因为它需要处理特定游戏的内存偏移地址。在开始为某个游戏开发模组前第一件事就是确认它使用的是Mono还是IL2CPP并找到对应可用的BepInEx版本。2.3 插件Plugin的基本结构一个BepInEx插件本质上就是一个实现了特定接口的.NET类库DLL。其最小结构如下using BepInEx; using BepInEx.Logging; using HarmonyLib; // 1. 定义插件元数据 [BepInPlugin(PluginGuid, PluginName, PluginVersion)] public class MyAwesomePlugin : BaseUnityPlugin // 2. 继承BaseUnityPlugin { // 3. 定义插件常量 public const string PluginGuid com.yourname.game.mods.pluginname; public const string PluginName My Awesome Plugin; public const string PluginVersion 1.0.0; // 4. 日志记录器 internal static ManualLogSource Logger; // 5. Awake() 方法插件入口点 private void Awake() { // 初始化日志 Logger base.Logger; Logger.LogInfo($Plugin {PluginName} is loading!); // 应用Harmony补丁 Harmony.CreateAndPatchAll(typeof(MyAwesomePlugin).Assembly, PluginGuid); Logger.LogInfo($Plugin {PluginName} loaded successfully!); } }[BepInPlugin]属性这是插件的“身份证”包含了全局唯一的GUID、显示名称和版本号。GUID必须是唯一的通常使用反向域名格式以避免与其他插件冲突。BaseUnityPlugin基类继承它你的插件就自动获得了配置管理、日志记录等基础功能。base.Logger就是框架提供的日志器。Awake()方法这是插件的生命周期起点相当于Unity脚本的Awake。所有初始化操作都应放在这里。3. 开发环境搭建与项目配置工欲善其事必先利其器。一个高效的开发环境能极大提升模组开发的体验和成功率。3.1 工具链选择与安装集成开发环境 (IDE)首选 Visual Studio 2022对.NET和C#支持最完善尤其是其强大的调试和NuGet包管理功能。安装时务必勾选“.NET 桌面开发”和“使用Unity的游戏开发”工作负载。备选 JetBrains Rider对Unity和C#的支持同样顶级尤其在代码分析和重构方面表现优异。对于资深开发者是不错的选择。VSCode轻量级但需要配置C#扩展和OmniSharp调试体验稍逊于前两者。适合喜欢轻量编辑器的用户。.NET SDKBepInEx 5.x/6.x 主要面向.NET Framework 4.7.2或.NET Standard 2.0。你需要安装对应的.NET SDK或运行时。对于Windows开发通常安装最新的.NET SDK即可它会包含向下兼容的框架。Unity 版本与游戏程序集这是最关键的一步。你需要获取目标游戏的“托管程序集”Managed Assemblies。对于Mono游戏这些DLL通常位于游戏目录的游戏名_Data/Managed文件夹下。核心文件是Assembly-CSharp.dll游戏逻辑和UnityEngine.dll引擎接口。你需要将这些DLL作为“引用程序集”添加到你的插件项目中以便Visual Studio能提供代码补全和类型检查。实操心得不要直接引用游戏目录下的DLL文件。我建议在项目目录下创建一个Libs/GameAssemblies文件夹将需要的游戏DLL复制过来然后在项目中引用这些副本。这样可以避免因游戏更新导致的项目引用突然失效也便于版本管理。3.2 创建BepInEx插件项目我们使用命令行和dotnet new模板来创建项目这是最清晰的方式。安装项目模板如果尚未安装dotnet new install BepInEx.Templates::*这个官方模板包包含了创建BepInEx插件所需的基本结构。创建新项目# 切换到你的工作目录 cd D:\ModDev # 使用模板创建插件项目 dotnet new bepinex5plugin -n MyFirstBepInExPlugin cd MyFirstBepInExPlugin项目结构解析 创建完成后你会看到类似如下的结构MyFirstBepInExPlugin/ ├── MyFirstBepInExPlugin.csproj # 项目文件 ├── Plugin.cs # 主插件类包含我们之前看到的样板代码 ├── manifest.json # 可选Thunderstore等模组商店的发布清单 ├── README.md # 说明文档 └── .csproj.user # 可选用户特定配置编辑项目文件 (.csproj)打开.csproj文件我们需要调整目标框架并添加游戏程序集引用。Project SdkMicrosoft.NET.Sdk PropertyGroup TargetFrameworknet472/TargetFramework !-- 通常为.NET Framework 4.7.2 -- OutputTypeLibrary/OutputType GenerateAssemblyInfofalse/GenerateAssemblyInfo BepInExPluginGuidcom.yourname.game.mods.myfirstplugin/BepInExPluginGuid BepInExPluginNameMy First BepInEx Plugin/BepInExPluginName BepInExPluginVersion1.0.0/BepInExPluginVersion /PropertyGroup ItemGroup !-- 引用BepInEx核心库 -- PackageReference IncludeBepInEx.Core Version5.4.21 / PackageReference IncludeBepInEx.Harmony Version5.4.21 / PackageReference IncludeBepInEx.PluginInfoProps Version1.1.0 / !-- 引用Harmony库 -- PackageReference IncludeHarmonyX Version2.10.2 / !-- 引用Unity引擎基础库可从Unity Hub安装目录或游戏目录获取 -- Reference IncludeUnityEngine HintPathLibs\UnityAssemblies\UnityEngine.dll/HintPath PrivateFalse/Private /Reference !-- 引用游戏逻辑程序集 -- Reference IncludeAssembly-CSharp HintPathLibs\GameAssemblies\Assembly-CSharp.dll/HintPath PrivateFalse/Private /Reference /ItemGroup /Project关键点TargetFramework: 必须与游戏使用的.NET版本匹配。对于大多数Unity游戏net472.NET Framework 4.7.2是安全的选择。PackageReference: 通过NuGet管理BepInEx和Harmony的依赖这是最推荐的方式能自动处理版本冲突。Reference: 添加对游戏DLL的引用。PrivateFalse/Private表示不要将这些DLL复制到输出目录因为游戏运行时已经提供了它们。构建项目在项目目录下运行dotnet build。如果一切顺利你会在bin/Debug/net472目录下看到生成的MyFirstBepInExPlugin.dll文件。这个DLL就是你的插件本体。4. 核心开发使用Harmony进行代码修补插件框架搭好了现在进入最核心的部分如何修改游戏行为答案就是Harmony。4.1 Harmony补丁Patch基础Harmony允许你以三种主要方式修改目标方法前缀 (Prefix): 在目标方法执行前运行。可以修改参数、跳过原始方法执行。后缀 (Postfix): 在目标方法执行后运行。可以读取/修改返回值、访问方法的局部变量通过__result,__instance等特殊参数。置换器 (Transpiler): 最强大的方式直接操作目标方法的CIL中间语言指令流。用于进行复杂的逻辑修改比如插入、删除或替换指令。这是高级话题本篇先聚焦前两种。4.2 实战创建一个简单的功能修改插件假设我们想为某个游戏修改玩家生命值。我们通过反编译工具如dnSpy, ILSpy或查阅游戏模组社区的文档找到了管理玩家生命值的类和方法。定位目标方法假设我们找到游戏中有个PlayerController类其中有一个public void TakeDamage(int amount)方法。创建Harmony补丁类在项目中新建一个C#文件例如PlayerDamagePatch.cs。using HarmonyLib; using UnityEngine; namespace MyFirstBepInExPlugin.Patches { [HarmonyPatch(typeof(PlayerController))] // 指定要修补的类 [HarmonyPatch(nameof(PlayerController.TakeDamage))] // 指定要修补的方法 internal class PlayerDamagePatch { // 这是一个前缀补丁在TakeDamage执行前运行 [HarmonyPrefix] static bool Prefix(PlayerController __instance, ref int amount) { // __instance 是调用该方法的PlayerController实例 // amount 是传入的伤害值通过ref关键字我们可以修改它 // 示例如果玩家生命值低于50则伤害减半 if (__instance.currentHealth 50) { amount Mathf.CeilToInt(amount * 0.5f); // 伤害减半 Plugin.Logger.LogInfo($玩家生命值低伤害由{amount*2}减免至{amount}); } // 返回 true 表示继续执行原始方法返回 false 则跳过原始方法 return true; } // 这是一个后缀补丁在TakeDamage执行后运行 [HarmonyPostfix] static void Postfix(PlayerController __instance, int amount) { // 示例每次受到伤害后在控制台输出日志 Plugin.Logger.LogInfo($玩家受到 {amount} 点伤害。当前生命值: {__instance.currentHealth}); // 甚至可以在这里触发其他效果比如屏幕闪红、播放音效等 // 注意直接调用游戏方法需要确保线程安全通常游戏主逻辑都在主线程。 } } }在插件主类中应用补丁修改Plugin.cs中的Awake()方法确保补丁被注册。private void Awake() { Logger base.Logger; Logger.LogInfo($Plugin {PluginName} is loading!); // 应用所有补丁 var harmony new Harmony(PluginGuid); harmony.PatchAll(); // 这会自动扫描当前程序集所有带有[HarmonyPatch]属性的类 // 或者手动指定补丁类harmony.PatchAll(typeof(PlayerDamagePatch).Assembly); Logger.LogInfo($Plugin {PluginName} loaded successfully!); }构建与测试重新构建项目将生成的DLL复制到游戏的BepInEx/plugins目录下启动游戏。当你控制的角色受到伤害时就应该能在BepInEx/LogOutput.log文件中看到我们添加的日志信息并且低生命值时伤害应该被减半。4.3 高级技巧使用配置文件和条件编译一个成熟的模组应该允许用户自定义配置。使用BepInEx配置系统BaseUnityPlugin基类提供了一个Config属性。// 在Plugin类中定义配置项 public static ConfigEntrybool ModEnabled { get; private set; } public static ConfigEntryfloat DamageMultiplier { get; private set; } public static ConfigEntryint LowHealthThreshold { get; private set; } private void Awake() { // ... 其他初始化 ... // 绑定配置 ModEnabled Config.Bind(General, Enabled, true, 是否启用此模组); DamageMultiplier Config.Bind(Balance, DamageMultiplier, 0.5f, 低生命值时的伤害乘数 (0.0 - 1.0)); LowHealthThreshold Config.Bind(Balance, LowHealthThreshold, 50, 触发伤害减免的生命值阈值); // 在补丁中使用配置 }在补丁中读取配置[HarmonyPrefix] static bool Prefix(PlayerController __instance, ref int amount) { // 如果模组被禁用直接返回true不执行任何修改 if (!Plugin.ModEnabled.Value) return true; if (__instance.currentHealth Plugin.LowHealthThreshold.Value) { float multiplier Plugin.DamageMultiplier.Value; amount Mathf.CeilToInt(amount * multiplier); Plugin.Logger.LogInfo($伤害减免生效: {multiplier:P0}); } return true; }用户可以在游戏目录的BepInEx/config文件夹下找到自动生成的com.yourname.game.mods.pluginname.cfg文件并用文本编辑器修改这些值。BepInEx还支持通过其他模组如ConfigurationManager提供图形化配置界面。条件编译与调试在开发阶段我们经常需要输出调试信息但发布时又不想让日志太冗杂。#if DEBUG Logger.LogDebug($详细调试信息: {someVariable}); #endif在Visual Studio中你可以在项目属性 - 生成 - 条件编译符号中定义DEBUG。这样只有在Debug构建时这些日志才会被编译进去。5. 插件测试、调试与发布5.1 测试与调试工作流手动复制测试每次修改代码后执行dotnet build然后将输出DLL手动复制到游戏的BepInEx/plugins目录。这是最基本的方法。使用生成后事件自动复制在Visual Studio项目属性 - 生成事件 - 后期生成事件命令行中添加如下命令xcopy /Y /I $(TargetPath) D:\SteamLibrary\steamapps\common\YourGameName\BepInEx\plugins\将路径替换为你游戏的实际安装路径。这样每次构建成功后DLL会自动复制过去。附加调试器这是最强大的调试方式。确保你的项目是Debug配置。在代码中需要调试的地方设置断点。启动游戏。在Visual Studio中点击调试 - 附加到进程。在进程列表中找到你的游戏进程例如GameName.exe选择它然后点击“附加”。现在当游戏执行到你打了断点的代码时Visual Studio就会中断你可以查看所有变量、调用堆栈进行单步调试。这是解决复杂Bug的终极武器。5.2 日志与问题排查BepInEx的日志是排查问题的生命线。日志文件默认位于BepInEx/LogOutput.log。日志级别在BepInEx.cfg配置文件中可以设置LogLevel来控制输出信息的详细程度如Debug,Info,Warning,Error。开发时建议设为Debug。控制台窗口对于Windows游戏你可以通过修改doorstop_config.ini对于Mono或使用BepInEx UnityIL2CPP的启动参数来让BepInEx弹出一个控制台窗口实时查看日志输出这对调试启动阶段的问题非常有用。常见错误插件未加载检查DLL是否放对了位置plugins下或子文件夹检查游戏日志是否有加载错误如缺少依赖。Harmony补丁失败检查目标类名、方法名是否完全正确包括命名空间。使用Harmony.DEBUG true;可以在日志中输出更详细的补丁信息。游戏崩溃通常是由于补丁逻辑错误如空引用、或修改了游戏关键数据导致状态不一致。仔细检查补丁代码特别是对__instance和参数的操作。5.3 打包与发布当你的插件稳定后就可以考虑分享了。准备发布包一个标准的BepInEx插件发布包通常包含YourPlugin_v1.0.0.zip ├── BepInEx/ │ └── plugins/ │ └── YourAuthorName/ 可选但推荐用于组织 │ └── YourPlugin/ │ ├── YourPlugin.dll │ ├── manifest.json Thunderstore元数据 │ ├── README.md │ └── icon.png └── 其他可选资源如翻译文件、资产包等创建manifest.json以Thunderstore为例{ name: Your Plugin Name, version_number: 1.0.0, website_url: https://github.com/yourname/yourplugin, description: A brief description of what your plugin does., dependencies: [ BepInEx-BepInExPack-5.4.21 ] }发布到模组平台如Thunderstore、Nexus Mods等。遵循平台的发布指南填写清晰的描述、截图和更新日志。6. 进阶主题与最佳实践6.1 处理游戏更新游戏更新是模组开发者的噩梦尤其是IL2CPP游戏因为函数的内存地址可能改变。版本检测与兼容性在你的插件Awake()方法中可以检查游戏版本。private void Awake() { string gameVersion Application.version; // 获取游戏版本 Logger.LogInfo($Game version: {gameVersion}); if (gameVersion ! 1.0.0) { Logger.LogWarning($This plugin was tested on version 1.0.0. Current version is {gameVersion}. Use at your own risk.); } // ... 其他初始化 }使用特征码Signature或模糊匹配对于Harmony补丁如果方法名或参数未变但类名因混淆而改变可以使用[HarmonyPatch]属性的methodType或argumentTypes参数或者使用HarmonyMethod构造函数传入方法特征码一种基于方法IL指令的模式来定位目标方法。这需要更深入的反编译知识。6.2 性能考量不当的模组代码会严重影响游戏性能。避免在频繁调用的方法中执行昂贵操作例如不要在Update()方法的补丁里每帧进行复杂的计算或字符串操作。缓存引用如果需要频繁访问某个游戏对象或组件在Start或Awake补丁中获取它的引用并保存起来而不是每次使用时都通过GameObject.Find或GetComponent去查找。谨慎使用反射虽然Harmony本身基于反射但在你自己的插件逻辑中应尽量避免运行时反射因为它很慢。6.3 与其他模组的兼容性唯一的GUID确保你的插件GUID全局唯一。依赖管理如果你的插件需要另一个插件才能运行可以在manifest.json的dependencies中声明。BepInEx会在加载时检查依赖。补丁冲突如果两个模组修补了同一个方法的同一个位置比如都是前缀可能会冲突。可以通过设置补丁的priority优先级属性来调整执行顺序或者使用Harmony的PatchProcessor进行更精细的控制。良好的做法是如果可能尽量使用后缀补丁来“观察”而非“拦截”游戏逻辑减少冲突概率。6.4 为IL2CPP游戏开发为IL2CPP游戏开发插件前期准备更复杂获取游戏DLLIL2CPP游戏没有Assembly-CSharp.dll。你需要使用工具如Il2CppDumper来从GameAssembly.dll和global-metadata.dat文件中“dump”出C#的伪程序集。这些伪程序集包含了类和方法的结构信息可用于编写代码但不能直接运行。引用特殊的BepInEx库项目需要引用BepInEx.IL2CPP和BepInEx.Unity.IL2CPP等包而不是标准的BepInEx.Core。补丁方式Harmony补丁的编写方式与Mono基本相同但底层实现完全不同。你需要确保使用的BepInEx版本明确支持该游戏的IL2CPP版本。从零搭建一个完整的BepInEx模组开发环境就像学习一门新的手艺。初期可能会被环境配置、反编译、晦涩的错误信息所困扰但一旦你成功让第一个自制功能在游戏中运行起来那种成就感是无与伦比的。这套流程不仅适用于制作趣味模组其背后对游戏运行时的理解、对Harmony等工具的应用也是深入理解软件工程和运行时技术的绝佳实践。最重要的是加入模组开发者社区阅读别人的代码参与讨论你会发现无数志同道合者并从中获得持续学习和进步的动力。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进