1. 先搞清楚这件事:BepInEx到底在什么位置干活
第一次在游戏根目录看到 winhttp.dll、doorstop_config.ini、BepInEx 文件夹时,很多人会愣一下:这游戏本体明明是Unity引擎开发的,怎么根目录里多出了几个不像是Unity默认生成的文件?这正是BepInEx在“游戏启动早期”插了一脚的表现。在讲怎么装、怎么写插件之前,我建议先把这层机制弄懂,否则后面排错时会一直处于“照着教程做了但不知道为什么”的悬浮状态。
1.1 Mono与IL2CPP:先得知道游戏脚本是怎么被运行的
要理解BepInEx,绕不开Unity本身的运行机制。Unity游戏绝大多数逻辑是用C#写的,C#代码编译后不会直接变成CPU能执行的原生指令,而是先编译成一种叫中间语言(IL)的东西。IL需要一个运行时来消费,Unity目前有两条路线:
- Mono模式:游戏包内自带一套Mono运行时,IL交给Mono运行时解释执行。对应到游戏目录,你会看到
MonoBleedingEdge文件夹,并且在*_Data/Managed目录下能直接看到大量托管DLL。 - IL2CPP模式:Unity打包时会把C#代码的IL转成C++代码,再用各个平台的原生编译器把C++编译成二进制。此时游戏目录里没有可以直接阅读的托管DLL,逻辑被压进了
GameAssembly.dll这种原生库里。
这两者决定了mod制作的难度和路子完全不同。Mono模式下,你甚至可以把游戏 Managed 目录里的DLL反编译出来,直接改逻辑再放回去,很多老Unity游戏就是这么被改的。IL2CPP模式下,反编译和修改原生二进制都麻烦得多,常规手段是在“游戏加载运行时的入口”上下功夫,也就是用BepInEx这类框架在外部介入。
我见过不少新手一上来就问“为什么我反编译看不到我的目标方法”,结果一问游戏是IL2CPP的,自然看不到熟悉的C#托管DLL。先判断游戏属于哪种模式,后面的选型和做法才会顺。
1.2 BepInEx的核心工作方式:在游戏启动时抢先进场
BepInEx的底层原理,我习惯叫它“门禁式注入”。Windows平台下,Unity游戏的可执行文件启动时,需要加载一系列系统DLL。BepInEx利用 winhttp.dll 这个系统库劫持点,让游戏进程在初始化游戏逻辑之前,先把BepInEx核心程序集加载进来。
打个比方:游戏本来是一个商场,顾客是各种游戏逻辑。BepInEx是在商场门口加装的一道闸机。顾客还是那些顾客,但进门前必须先过闸机,闸机可以偷偷做自己的事——记录顾客、给顾客换券、在顾客消费离场后再补一刀。这就是BepInEx加载插件、以及配合Harmony改方法逻辑的本质。
具体到流程,游戏启动后大致是这样的:
- Windows加载器因为劫持配置,提前加载
winhttp.dll。 winhttp.dll读取doorstop_config.ini,定位并启动BepInEx的核心入口。- 核心入口初始化BepInEx环境,扫描
BepInEx/plugins目录。 - 加载每个插件程序集,执行插件里
Awake、Update等生命周期方法。 - 一切就绪后,游戏本体逻辑开始正常执行。
因为动作发生得足够早,BepInEx可以在游戏自己的初始化代码运行前就注册好补丁和Hook,从而在后续游戏逻辑里“埋伏笔”。
1.3 为什么是BepInEx,而不是其他mod框架
Unity游戏mod框架远不止BepInEx一家,MelonLoader、Unity Mod Manager、Nebula等也各自有用户群。但BepInEx有一个非常适合做mod的特性组合:
- 插件化彻底:你可以同时丢十几个插件进
plugins目录,只要不互相冲突,它们会并行加载。 - 版本策略清晰:BepInEx 5 LTS主打Mono游戏,BepInEx 6覆盖IL2CPP游戏,路线划分明确。
- 日志系统强大:每次运行都会生成
LogOutput.log,排错时这是第一手资料。 - 中文社区资料多、教程多,网上能搜到的踩坑记录也最全。
如果只是给自己的单机Unity游戏做本地功能修改,BepInEx基本是默认首选。当然,MelonLoader在某些特定游戏上也有优势,但BepInEx的通用性和成熟度决定了它更适合作为入门主线。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 版本选型:BepInEx 5 LTS和BepInEx 6到底差在哪
许多新手栽的第一个跟头,就是版本下错了。搜索引擎里那些“bepinex 5 lts x64”“bepinex 6 unity.il2cpp win x64”热搜词,恰恰说明大家天天在这里踩坑。BepInEx的发布页命名确实容易让人懵,这里帮大家彻底捋一遍。
2.1 用什么版本,由游戏的脚本模式决定
BepInEx 5 LTS是长期支持版。它成熟、稳定、插件生态最丰富,主要服务Mono模式的Unity游戏。由于Mono模式是早期Unity游戏最常见的形态,大量独立游戏和老作品都在这个范围里。哪怕到今天,我给人推荐版本时,如果对方不确定游戏情况,我也会先让他看有没有 MonoBleedingEdge,有就无脑上BepInEx 5,大多数情况不会出大问题。
BepInEx 6是重写版本,底层换成了更现代的.NET运行时,最大的意义是支持IL2CPP模式游戏。IL2CPP游戏可执行文件里没有传统托管DLL,光靠传统注入方式不够,BepInEx 6需要借助Il2CppInterop这样的互操作层,把托管代码和原生代码桥接起来,插件才能访问游戏内部对象。BepInEx 5做不到这件事,所以遇到IL2CPP游戏,版本选择错误会直接导致“装完之后根目录生成了BepInEx目录,但插件怎么都不加载”。
要快速判断游戏属于哪一类,我建议看根目录特征:
- 有
MonoBleedingEdge文件夹,大概率Mono,用BepInEx 5。 - 有
GameAssembly.dll,大概率IL2CPP,用BepInEx 6。 - 两个同时存在也不奇怪,但以最终启动时加载的那个为准。
2.2 安装流程:解压位置和首启文件
BepInEx安装本身不复杂,但位置错了就全盘皆输。以下是我反复使用的一套标准步骤,以Windows平台为例:
- 从BepInEx官方发布页下载对应版本。文件名里
BepInEx_win_x64_6.0.0-pre.1.zip表示64位BepInEx 6,pre是预发布版;BepInEx_x64_5.4.23.3.zip这类是BepInEx 5的64位LTS版。 - 把压缩包内所有文件解压到“游戏主程序exe所在的根目录”,不是
*_Data,更不是Steam客户端目录。 - 首次运行游戏。BepInEx会自动生成
BepInEx文件夹、doorstop_config.ini、winhttp.dll。如果启用了控制台,还会弹出一个黑色命令行窗口。 - 确认生成了
BepInEx/plugins目录,后续做好的插件DLL扔到这里。
有一个小经验:解压时不要用“解压到文件夹”,否则容易在游戏根目录里多套一层目录。解压完成后,我习惯先看一眼游戏exe旁边有没有 winhttp.dll,没有就说明层级错了。
关于Steam平台还有一个额外提醒:如果游戏启用了“验证游戏完整性”功能,或者游戏后续自动更新,BepInEx注入用的文件可能会被覆盖或清除。这不是BepInEx坏了,而是游戏更新把文件还原了,重新解压一遍即可。
2.3 IL2CPP专属细节:win-x64不是全自动
BepInEx 6的IL2CPP版本对平台后缀非常敏感。文件名里的 win-x64 指Windows 64位平台,选择时需要和游戏进程位宽对应。如果游戏是32位的,请选 win-x86;如果你用的BepInEx版本分 x64 和 x86 两种,同样需要匹配。
装了匹配版本后,首次启动会比较慢。因为BepInEx 6需要额外生成或加载dotnet运行时和IL2CPP互操作相关文件,比如 Il2CppInterop 系列程序集。看到进程“卡”十几秒甚至更久,不要急着关,给初始化留点时间。
我第一次接触BepInEx 6时,就发生过“明明下载了支持IL2CPP的版本,但启动直接报缺少 Il2CppInterop 入口”的问题。后来检查才发现游戏是32位,而我下的是 win-x64。位宽不匹配时,很多互操作初始化会异常。所以第一步永远是先确认游戏进程位数,再看安装包后缀。
| 判断信息 | 结果 | 推荐版本 |
|---|---|---|
根目录有 MonoBleedingEdge |
Mono模式 | BepInEx 5 LTS |
根目录有 GameAssembly.dll |
IL2CPP模式 | BepInEx 6 |
| 游戏进程32位 | 平台x86 | 下载对应 win-x86 |
| 游戏进程64位 | 平台x64 | 下载对应 win-x64 |
3. 动手写第一个插件:从零到一条日志
BepInEx装好了,环境也启动了,接下来就是写自己的插件。做mod这件事,看起来像在“改游戏”,本质上是在写一个被特定框架加载的C#类库DLL。
3.1 工程搭建:引用别一股脑全引
开发环境我建议用Visual Studio 2022,或者VS Code加.NET SDK。你需要创建一个C#类库项目,不是控制台应用或WinForms项目。
工程建出来后,主要引用这类程序集:
BepInEx.Core.dll:提供插件基类、Config、日志接口,必引。BepInEx.Unity.dll:Unity生命周期相关的封装,必引。UnityEngine.CoreModule.dll:如果需要访问GameObject、Component等Unity对象,从游戏目录的*_Data/Managed下引用。
引用游戏DLL这个环节,有一点非常关键:只引你要用的,不要全都引。很多人图省事把游戏 Managed 目录里所有DLL一股脑拖进项目,编译确实能过,但运行时经常出现程序集版本冲突,报错反而不容易定位。
目标框架也要匹配。BepInEx 5的插件通常用 .NET Framework 4.7.2 或 netstandard2.0 编译;BepInEx 6的插件建议按官方模板使用 .NET 6/8 类库。如果插件框架选择和BepInEx宿主对不上,加载时会直接抛 FileLoadException。
3.2 最小插件代码:能加载就算成功
核心代码由几个元素组成。下面这个最小示例,我经常拿来验证“BepInEx环境是否真的正常”:
csharp复制using BepInEx;
using BepInEx.Logging;
namespace MyFirstPlugin
{
[BepInPlugin("com.myname.testmod", "My Test Mod", "1.0.0")]
public class TestPlugin : BaseUnityPlugin
{
private void Awake()
{
Logger.LogInfo("TestMod loaded successfully!");
}
}
}
BepInPlugin 特性里的三个参数分别是GUID、显示名称、版本。GUID一定要全局唯一,不然多个插件容易冲突。Logger.LogInfo 会把内容输出到控制台和 LogOutput.log。编译成DLL后放进 BepInEx/plugins,重启游戏,日志里出现“TestMod loaded successfully!”就说明你的开发链路通了。
你还可以用 Logger.LogWarning 和 Logger.LogError 输出不同级别的日志。在排查阶段,我会故意在关键节点加 LogError,用来确认代码执行到了哪一步。
3.3 生命周期和第一个坑:Awake里别急着找场景对象
很多第一次写BepInEx插件的人会犯一个错:在 Awake 里直接 FindObjectOfType<SomeComponent>() 找游戏对象,结果返回空。这是因为 Awake 执行得非常早,Unity场景可能还没加载完整,游戏自身逻辑也还没初始化。真正稳的做法是:
- 在
Update里延迟查找; - 或者通过
StartCoroutine等一两帧再访问; - 或者直接Hook游戏某个后续执行的方法,在那之后再找对象。
另外,Update 是每帧调用的,写在里面的代码运行在游戏主线程上。如果你在 Update 里做重活,比如每帧写文件、每帧反射查找,很快能感到帧数下降。所以能用 Awake 一次性初始化的就别放 Update,能每30帧做一次的就别每帧做。
4. 从实际报错反推:那些最常见的翻车现场
这一节我把它当成一次“现场排查手记”来写。网上高频出现的“bepinex乱码”“bepinex安装”搜索词,背后其实就是几个固定的问题场景。弄懂这些,你能省下大量爬帖时间。
4.1 插件没被加载,按什么顺序排查
如果插件DLL放进 plugins 目录后毫无反应,我的排查顺序是固定的,几乎不会绕路:
- 看
LogOutput.log最后几行,搜你的插件GUID或异常堆栈。这个文件是第一手情报。 - 确认插件DLL在
BepInEx/plugins根目录下,而不是被放进了BepInEx的其他子目录。 - 确认BepInEx版本匹配。IL2CPP游戏装了BepInEx 5,插件不会加载;Mono游戏装了BepInEx 6,也不一定能直接读传统插件。
- 检查插件的目标框架是否和BepInEx宿主匹配。
- 最后再检查代码:插件静态构造或
Awake里如果抛了异常,BepInEx会跳过这个插件,但不影响其他插件运行。
我自己就多次遇到第5种情况——插件在调试机上是好的,但别人装了就说没反应,最后发现是机器缺少对应.NET运行时,或者配置文件没写全。看日志永远是最快的定位方式。
| 现象 | 优先检查 | 常见解决 |
|---|---|---|
| 插件完全没加载 | LogOutput.log 插件GUID |
确认放对目录、版本匹配 |
| 启动即崩 | 异常堆栈 | 检查目标框架、非法访问 |
| 日志中文乱码 | 编码/代码页 | 用UTF-8查看或改系统代码页 |
| 目录生成但插件不生效 | 游戏更新覆盖 | 重新解压注入文件 |
4.2 中文乱码:先分清是“日志乱码”还是“游戏文本乱码”
“bepinex乱码”被搜烂了,但这个关键词其实对应两种完全不同的情况。
第一种是BepInEx控制台或日志文件里的中文乱码。Windows中文环境默认代码页是GBK,而很多开发者用UTF-8编码看文件,所以中文直接变成“锟斤拷”一类的东西。解决方式有几种:
- 插件日志尽量用英文写,简单又稳。
- 查看
LogOutput.log时,用支持编码切换的编辑器把它按UTF-8打开。 - 想让控制台不乱码,可以在启动游戏前在同一终端执行
chcp 65001,切到UTF-8代码页。
第二种是游戏内mod文本乱码,比如你做汉化或新增UI文本,游戏里显示乱码。这通常和编码或字体有关,游戏内部可能按GBK解析字符串,而UI字体缺少对应字形。处理方向是去查字符串的编码来源,而不是在BepInEx配置里反复开关控制台。
我个人的习惯是:日志里中文能少用就少用,不是不支持,而是跨机器、跨编码排错时,英文最省心。真遇到“看起来像乱码”的内容,先打开十六进制视图看原始字节,比瞎猜编码强。
4.3 BepInEx 6 + IL2CPP下的典型异常和处理
BepInEx 6在IL2CPP游戏上工作时,程序要经历“托管代码与原生代码互操作”这一层,异常表现和Mono时代差异很大。我遇到最多的,就是启动黑窗里刷一堆带 Il2CppInterop 字眼的异常,这种八成是平台位宽不匹配,或者漏装了对应的运行时前置。
另外,新版本BepInEx默认启用的“安全模式”也会拦截插件。这个机制本意是防止加载来源不明的DLL,但对开发者来说,实时调试时非常烦人。处理办法是在BepInEx配置里把你自己插件GUID加入允许列表,或者临时关闭安全模式。我一般只在调试阶段关,正式使用还是恢复默认,因为安全机制的出发点没错。
还有一个常见坑是“游戏自动更新后插件突然失效”。Unity游戏更新会覆盖根目录下的可执行文件和相关DLL,BepInEx注入文件被覆盖后,插件自然加载不出来。这种问题别在插件代码里钻牛角尖,重新解压一次BepInEx就完事了。
4.4 安装后没生成BepInEx文件夹的情况
如果游戏根目录里该有BepInEx文件夹却始终没有,排除了版本平台不对后,最大嫌疑就是解压层级错了。举例来说,游戏主程序位于 D:\Games\MyGame\MyGame.exe,那么 winhttp.dll 必须位于 D:\Games\MyGame\winhttp.dll,而不是 D:\Games\MyGame\MyGame\winhttp.dll。
很多解压工具默认生成“压缩包同名目录”,点一次“解压到当前文件夹”,就多套了一层目录。此时BepInEx的“门禁”文件根本不在exe同一层,游戏启动时完全不会碰到它。
另一个可能性是杀毒软件或Windows Defender隔离了 winhttp.dll。如果文件明明解压过,但重启游戏后没有任何反应,去安全软件隔离区翻一翻。做mod期间,把游戏根目录设为信任目录能省不少事。
5. 进阶一步:用Harmony补丁真正改变游戏逻辑
插件能加载、日志能输出,这是mod制作的起点。真正让mod“有用”的,是让游戏行为发生变化,这通常要借助Harmony补丁。
5.1 Harmony为什么重要:在方法前后动手脚
Harmony补丁的基本思想不复杂:游戏里某个方法原本是A函数,你可以插入“前缀(Prefix)”在A之前执行,也可以插入“后缀(Postfix)”在A之后执行。对绝大多数mod来说,这两个钩子已经能覆盖大部分需求。
举个例子,想改一款Unity游戏的伤害计算。假设游戏内部有 DamageCalculator.Calculate(Character attacker, Character target) 方法,返回int类型伤害值。我可以写一个后缀补丁,把结果乘1.5倍:
csharp复制using HarmonyLib;
using UnityEngine;
[HarmonyPatch(typeof(DamageCalculator), nameof(DamageCalculator.Calculate))]
static class DamagePatch
{
static void Postfix(ref int __result)
{
__result = Mathf.FloorToInt(__result * 1.5f);
}
}
然后在插件里用Harmony扫描并注册补丁:
csharp复制var harmony = new Harmony("com.myname.damagepatch");
harmony.PatchAll();
插件加载后,游戏里所有伤害计算都会经过这个补丁。Postfix 里的 ref int __result 是Harmony提供的魔法参数,可以直接改写原方法的返回值。
选择Harmony的理由也很现实:在IL2CPP模式下你几乎没法直接改 GameAssembly.dll,而在Mono模式下即使能改DLL,游戏更新一次就白改了。Hook方式则保留了原方法,游戏更新后往往也只是换个方法签名,重新对齐一次成本低很多。
5.2 配置项:让mod可调,而不是写死
给mod加可调配置,是mod从“自己用”走向“给别人用”的关键一步。BepInEx自带的配置系统很好用。比如把上面的伤害倍率做成配置项:
csharp复制ConfigEntry<float> DamageMultiplier;
private void Awake()
{
DamageMultiplier = Config.Bind("Damage", "Multiplier", 1.5f, "伤害倍率,修改后重启生效");
}
首次运行后,BepInEx会在 BepInEx/config 目录下生成一个与插件GUID同名的 .cfg 文件。玩家可以直接用记事本改里面的数值,重启游戏生效。配置项写得清晰,后续维护成本会低很多。
我的习惯是:凡是要展示给别人看的mod,所有“魔法数值”都做成配置项,哪怕一开始只给默认值。否则过几天有人问“我想改成3倍伤害怎么弄”,你总不能让人家去反编译DLL。
5.3 几个崩溃前兆:版本、对象生命周期和线程
Harmony补丁写多了,有几个高频崩溃点值得专门记下来。
MissingMethodException 最常见。通常是游戏更新后方法签名变了,但补丁还引用旧签名。遇到这个,去反编译游戏当前版本,确认方法名和参数类型,同步改代码。
AccessViolationException 在IL2CPP游戏上比较多。很多时候是你明明访问到了一个“看起来有效”的游戏对象,但它已经从原生层销毁了。Harmony补丁里做遍历和查询,先判空再访问,必要时用 try/catch 包住整个补丁体,防止一个异常把整个游戏带崩。
线程问题也比较隐蔽。Unity主线程负责渲染和大部分逻辑,但Harmony补丁运行在被Patch方法所在的线程,不一定是主线程。补丁里直接调用Unity API时,可能会得到“只能从主线程访问”的报错。处理办法是补丁里不要直接碰Unity API,把数据塞进一个队列,在插件自己的 Update 里消费。
这三类问题,我在真实mod项目里都踩过。日志系统会忠实记录这些异常,所以排查时别急着一次次重启游戏,先读一遍 LogOutput.log,基本能省一半时间。
说点我的真实使用体会:BepInEx最容易被忽视的宝藏是它那个 LogOutput.log。很多人遇到问题第一反应是拍视频发群里问,其实把日志拖出来自己看一遍,版本号、插件加载顺序、异常堆栈都有。游戏出任何问题,第一反应都应该是看日志,而不是反复重启碰运气。你手上有日志,就等于拿到了游戏进程内部的一份现场口供,剩下无非是版本对齐、路径对齐、逻辑对齐这三件事。把这些基本功练熟,在Unity引擎加BepInEx这条mod路上,你很快就能从“照教程装”变成“自己会排错”。
