ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

BepInEx + HarmonyX 实战指南:不碰游戏源码的 Unity 插件补丁,从第一个 Prefix 到多插件共存

BepInEx + HarmonyX 实战指南:不碰游戏源码的 Unity 插件补丁,从第一个 Prefix 到多插件共存 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 ConfigEntryfloat _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.GetPluginDiscountShopPlugin(); __result * plugin._discount.Value; // 直接改返回值 } }ref float __result是 Harmony 的魔法参数:Postfix 里它能读到原方法的返回值,改完再写回去。改完运行游戏,商店里所有价格都乘了 0.9——补丁生效了。按场景选补丁:不是背类型,是对症下药别先问这四种补丁怎么用,先问我想在哪个时间点插手。速查表如下:你想做的事选哪种方法签名长什么样顺手能改什么阻止方法执行 / 改写入参Prefix返回bool参数、是否放行、结果方法跑完后加效果 / 改结果Postfix返回void__result返回值方法体内部 IL 逻辑要动刀Transpiler返回IEnumerableCodeInstruction字节码本身方法抛异常时兜底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 IEnumerableCodeInstruction GetTermInfoNumbersTranspiler( IEnumerableCodeInstruction 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.MethodInventory(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.csMono 与 IL2CPP 两套插件基类对比:BaseUnityPlugin.cs vs BasePlugin.cs真实 Transpiler 案例:XTermFix.csHarmonyX 官方文档与 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),仅供参考
返回列表