BepInEx + HarmonyX 实战指南:不碰游戏源码的 Unity 插件补丁,从第一个 Prefix 到多插件共存
【免费下载链接】BepInExUnity / XNA game patcher and plugin framework项目地址: https://gitcode.com/GitHub_Trending/be/BepInEx
给 Unity 游戏加个「商店九折」或者「死亡不掉装备」,最省事的路子其实只有一条:游戏跑起来之后再动手。BepInEx 负责把你的插件加载进游戏进程并管好配置、日志这些基础设施,HarmonyX 负责在运行时把目标方法「包一层壳」,两者组合就是 Unity 插件开发里最常用的运行时补丁技术栈。这篇指南按「先跑通、再选补丁、后管工程」的顺序,带你完整走一遍。
选型:改源码、反编译还是运行时补丁?
先花两分钟把几条路摆在一起比较,避免一开始就走弯路:
| 路线 | 你实际改了什么 | 拿到的好处 | 要付出的代价 |
|---|---|---|---|
| 直接改游戏源码 | 编译产物本身 | 想改哪改哪 | 大多数商业游戏没有源码;改了也无法随游戏版本更新 |
| 反编译后重新编译 | 反编译出的 IL | 能改任意方法 | 签名/校验问题多,游戏一升级就得重来 |
| HarmonyX 运行时补丁 | 运行时内存中的方法入口 | 不碰文件、可随插件卸载还原、天然多插件友好 | 只能改方法内部,动不了字段布局之外的东西 |
| MonoMod Detour(底层) | 方法跳转指令 | 性能极致 | API 更原始,维护成本高,适合框架层用 |
分工一句话讲清:BepInEx 是"宿主管家"——扫描插件目录、按依赖顺序加载、给每个插件发日志和配置文件;HarmonyX 是"改造队"——把一个已编译方法的入口替换成你生成的包装方法,原方法被包在里面,随时可以还原。BepInEx 的核心库里就带着 HarmonyX 的引用(见 BepInEx.Core.csproj 里的 HarmonyX 包引用),所以插件项目只需引用 BepInEx 就能直接用。
环境准备:3 步搭出第一个能跑的补丁
依赖清单
- 目标游戏:Unity + Mono 运行时(本文主线;IL2CPP 游戏插件基类换成
BasePlugin,思路相同) - BepInEx 6(本仓库当前主线),插件代码目标框架
.NET 3.5 / netstandard2.0 - 编辑器:VS 2022 或 Rider,引用
BepInEx.Core.dll和游戏本体程序集 - 构建细节可参考 docs/BUILDING.md
最小插件骨架
[BepInPlugin("com.me.discountshop", "DiscountShop", "1.0.0")] public class DiscountShopPlugin : BaseUnityPlugin { private Harmony _harmony; private ConfigEntry<float> _discount; private void Awake() { // 每插件一个独立 Harmony 实例,ID 用 GUID,方便按插件精确卸载 _harmony = new Harmony(Info.Metadata.GUID); _harmony.PatchAll(); // 自动收集本程序集里所有 [HarmonyPatch] 方法 _discount = Config.Bind("Shop", "DiscountRate", 0.9f, "商品价格折扣"); } }三个关键点:
[BepInPlugin]属性是 BepInEx 识别插件的唯一凭证,缺了它加载器会直接抛异常(校验逻辑在 BaseUnityPlugin.cs 的构造函数里);GUID 一旦发布就别再改,它是插件的身份证;Awake时机是打补丁的安全窗口——此时游戏场景还没开始跑逻辑;- 插件基类已经给你配好了
Logger(日志)和Config(配置文件),不用自己造。
第一个 Prefix 补丁
目标是游戏里的ShopManager.GetPrice(int itemId),给所有价格打折:
public static class ShopPatches { [HarmonyPatch(typeof(ShopManager), "GetPrice")] [HarmonyPostfix] private static void GetPricePostfix(int itemId, ref float __result) { var plugin = PluginHelper.GetPlugin<DiscountShopPlugin>(); __result *= plugin._discount.Value; // 直接改返回值 } }ref float __result是 Harmony 的魔法参数:Postfix 里它能读到原方法的返回值,改完再写回去。改完运行游戏,商店里所有价格都乘了 0.9——补丁生效了。
按场景选补丁:不是背类型,是对症下药
别先问"这四种补丁怎么用",先问"我想在哪个时间点插手"。速查表如下:
| 你想做的事 | 选哪种 | 方法签名长什么样 | 顺手能改什么 |
|---|---|---|---|
| 阻止方法执行 / 改写入参 | Prefix | 返回bool | 参数、是否放行、结果 |
| 方法跑完后加效果 / 改结果 | Postfix | 返回void | __result返回值 |
| 方法体内部 IL 逻辑要动刀 | Transpiler | 返回IEnumerable<CodeInstruction> | 字节码本身 |
| 方法抛异常时兜底 | Finalizer | 返回Exception | 异常是否吞掉 |
Prefix:门口拦人
Prefix 在原方法之前运行。返回false直接跳过原方法(此时你的 Prefix 里自己填好__result);返回true则放行。
[HarmonyPatch(typeof(Inventory), "AddItem", new[] { typeof(Item), typeof(int) })] [HarmonyPrefix] private static bool AddItemPrefix(Item item, int count, ref int __0) { if (item == null) return false; // 脏数据:拦截,不让进背包 return true; // 正常:放行,原方法照常执行 }注意第二行属性里把参数类型写死了——AddItem有多个重载时,不写参数列表就可能打错目标,这是新手最高频的坑,后面排坑区还会再提。
适用:参数校验、条件拦截、入参放大缩小。不适用:你想看返回值做后处理——那是 Postfix 的活。
Postfix:善后加工
[HarmonyPatch(typeof(QuestTracker), "CompleteQuest")] [HarmonyPostfix] private static void CompleteQuestPostfix(string questId, ref int __result) { if (questId == "tutorial_first_blood") __result += 50; // 特定任务额外奖励,原方法完全不知道 }适用:不改流程、只加效果(日志、统计、奖励、UI 刷新)。不适用:需要"阻止"原方法——Postfix 时原方法已经执行完了,拦不住。
Transpiler:动 IL 的核选项
先说白话:IL 是 .NET 方法编译后的一串"微指令",Transpiler 就是让你在方法重新编译前,把这串指令翻一遍、改几条再交回去。只有当你要改的是方法体内部的计算逻辑(比如把某个加法换成查表),而入口参数和出口返回值都表达不出你的意图时,才考虑它。
仓库里就有一个真实案例:XTermFix.cs 用 Transpiler 按索引改写TermInfoReader内部指令,把硬编码的整型宽度读取替换成动态读取:
public static IEnumerable<CodeInstruction> GetTermInfoNumbersTranspiler( IEnumerable<CodeInstruction> instructions) { var list = instructions.ToList(); list[31] = new CodeInstruction(OpCodes.Ldsfld, AccessTools.Field(typeof(XTermFix), nameof(intOffset))); list[36] = new CodeInstruction(OpCodes.Nop); list[39] = new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(XTermFix), nameof(GetInteger))); return list; }适用:参数/返回值都改不动的内部逻辑(精度问题、魔数替换)。不适用:一切能用 Prefix/Postfix 表达的场景——Transpiler 生成的代码脱离原方法上下文,调试成本指数级上升,能用前两种就别用它。
Finalizer:异常兜底
Finalizer 包在目标方法的异常处理路径外,方法抛异常或正常返回都会被调用:
[HarmonyPatch(typeof(SaveSystem), "Flush")] [HarmonyFinalizer] private static Exception FlushFinalizer(Exception __exception) { if (__exception is Exception ex) { PluginHelper.GetLog().LogError($"存档落盘失败: {ex.Message}"); // 返回 null 表示异常已消化,原调用方感知不到 } return null; }适用:给关键路径(存档、网络发送)加"崩前留证据 + 吞掉致命异常"。不适用:当业务异常,它只在异常真的发生时出现,不是普通的收尾钩子。
多插件共存:补丁冲突的 3 种解法
游戏装到十来个插件后,同一个方法可能被多方打补丁,顺序就成了问题。
1. 用 HarmonyPriority 控制先后
同类型补丁的执行顺序由Priority决定,数值越大越先执行:
[HarmonyPriority(Priority.Low)] // 默认 100;想要"我最后跑"就调低 private static void LatePostfix(...) { } [HarmonyPriority(Priority.VeryHigh)] // 想"我第一个跑"就调高 private static void EarlyPrefix(...) { }经验法则:改参数的 Prefix 给高优先级,做统计展示的 Postfix 给低优先级,让"加工"发生在"读数"之后。
2. 把补丁目标钉死
冲突的一半来自"打错了重载"。三种手段按强度递增:
// 手段一:属性里显式列参数类型(最常用) [HarmonyPatch(typeof(Inventory), "AddItem", new[] { typeof(Item), typeof(int) })] // 手段二:代码里用 AccessTools 精确拿 MethodBase,再手动 Patch var target = AccessTools.Method<Inventory>("AddItem", new[] { typeof(Item), typeof(int) }); _harmony.Patch(target, postfix: new HarmonyMethod(typeof(ShopPatches), "Postfix")); // 手段三:同名方法找不到时,检查是不是打了基类/接口 // 运行时实际调用的是派生类覆写版本,补丁要打到真正被调用的那个3. 用依赖声明 + 补丁查询排查
BepInEx 在插件层面提供加载期约束,定义见 Contract/Attributes.cs:
[BepInPlugin("com.me.discountshop", "DiscountShop", "1.0.0")] [BepInDependency("com.me.baselib", BepInDependency.DependencyFlags.SoftDependency)] public class DiscountShopPlugin : BaseUnityPlugin { }- 硬依赖缺了:你的插件直接不加载,避免在缺依赖时半残运行;
- 软依赖缺了:照常运行,自己降级逻辑;
[BepInIncompatibility]:和某插件互斥时,直接拒绝加载并提示用户。
运行时想确认"这个方法到底被谁打了补丁",可以反查:
var info = Harmony.GetPatchInfo(targetMethod); // info.Prefixes / info.Postfixes 里能看到所有插件的 ID foreach (var p in info.Postfixes) Logger.LogInfo($"已挂 Postfix: {p.Owner}");这是排查"我为什么被别人的行为影响"的第一手工具。
工程化实践:让插件配得上"发布"两个字
配置驱动行为
把一切可调的数值都挂到Config.Bind上,补丁只读值、不写死:
_discount = Config.Bind("Shop", "DiscountRate", 0.9f, "商品价格折扣");好处:用户不改代码就能调行为,出问题时也能先关功能再定位。BepInEx 6 的依赖属性还支持 SemVer 版本区间(如>=1.0.0),声明跨插件依赖时记得用区间而不是裸版本号——裸版本在 6 里是精确匹配。
补丁里必须自带异常保护
Prefix/Postfix 抛异常会直接打断原方法的调用链,轻则功能失效,重则游戏崩。原则:补丁内部永远 try/catch,失败时按"放行"处理。
[HarmonyPrefix] private static bool SafePrefix(int arg) { try { if (!MyValidator.Check(arg)) return false; } catch (Exception ex) { Logger.LogError($"前缀检查失败,放行原方法: {ex}"); } return true; // 任何异常都不拖累游戏 }性能:高频方法上是另一条命
Unity 里Update级的方法每帧几十上百次调用,补丁里的每一行都会被放大:
- 反射结果(
AccessTools.Method、GetField)在静态只读字段里缓存一次,别在补丁体内反复反射; - 高频路径的补丁只做比较和赋值,日志、文件 IO、复杂集合操作挪出去;
- 能用一个 Postfix 解决,就别同时挂 Prefix + Postfix。
版本兼容:给插件留退路
游戏更新改个方法名,插件就整颗雷。标准姿势是用[HarmonyPatch]无参形式 + 动态TargetMethod或Prepare():
[HarmonyPatch] public static class AdaptivePatch { // 每次打补丁前评估,不满足直接跳过 private static bool Prepare() => AccessTools.TypeByName("Game.SaveSystem") != null; private static MethodBase TargetMethod() // 旧版方法名不存在时自动换新版,都找不到返回 null 即不打 => AccessTools.Method("Game.SaveSystem:Write") ?? AccessTools.Method("Game.SaveSystem:WriteAsync"); [HarmonyPostfix] private static void Postfix() { } }再叠加启动时的版本检查(用UnityInfo.Version取 Unity 引擎版本),不兼容就Logger.LogError并提前返回,别让用户在崩溃后才发现。
排坑速查:这 4 个故障占了九成工单
1. 补丁"不生效"现象:方法照旧,没有任何报错。原因:补丁打在了基类/接口上,运行时实际执行的是派生类覆写;或者目标在别的程序集里。解决:用Harmony.GetPatchInfo确认方法上有没有你的补丁;没有就打,有但无效就检查是不是打错了同名方法。
2. 打到了错误重载现象:功能时灵时不灵,或参数错位抛ArgumentException。原因:只写了方法名,Harmony 选了第一个同名重载。解决:属性里补参数类型列表,或改用AccessTools.Method(type, name, new[] { ... })显式拿方法。
3. 游戏一更新,补丁全失效现象:升级后功能消失甚至加载报错。原因:方法改名/签名变化。解决:Prepare()+ 候选TargetMethod做自适应(见上节),并把关键类型名集中到一个文件,升级时只改一处。
4. 打补丁阶段就崩现象:游戏刚进场景就崩,日志里一堆 Harmony 错误。原因:目标方法不存在、__result类型和方法实际返回值对不上。解决:开 Harmony 日志通道看具体报错——BepInEx 已把 HarmonyX 日志接进自己的日志系统(HarmonyLogSource.cs),在核心配置的Harmony.Logger段把LogChannels调成Warn | Error | IL,IL 通道会打印生成的补丁方法,用完记得关(它输出整个方法体,日志会爆)。调试期还可以在 HarmonyBackendFix.cs 对应的Preloader配置里切换 MonoMod 后端(如切到cecil方便 dnSpy 断点)。
回顾与延伸
- 架构分工:BepInEx 管插件加载/配置/日志,HarmonyX 管方法改写,一个
new Harmony(插件GUID)划清各自地盘; - 补丁选型:先问"我在哪个时间点插手"——拦参数用 Prefix、加效果用 Postfix、动 IL 才上 Transpiler、兜异常用 Finalizer;
- 共存三件套:
HarmonyPriority定顺序、参数列表钉死目标、BepInDependency/GetPatchInfo管依赖和排障; - 工程底线:补丁内部 try/catch、反射结果缓存、
Prepare()做版本自适应。
延伸阅读
- 构建与打包流程:docs/BUILDING.md
- 插件元数据(依赖/互斥/进程限定)属性定义:BepInEx.Core/Contract/Attributes.cs
- Mono 与 IL2CPP 两套插件基类对比:BaseUnityPlugin.cs vs BasePlugin.cs
- 真实 Transpiler 案例:XTermFix.cs
- HarmonyX 官方文档与 Wiki(搜索 "HarmonyX documentation")
- 调试工具:dnSpy(断点调试补丁方法)、BepInEx 配置管理器
- 社区:BepInEx Discord 频道、各游戏 Nexus Mods 版块(提问前先贴
GetPatchInfo输出和完整日志,能省掉一大半往返)
【免费下载链接】BepInExUnity / XNA game patcher and plugin framework项目地址: https://gitcode.com/GitHub_Trending/be/BepInEx
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考