# 说在前面

学习做 Mod 其实是因为我以为游戏卡关,被机关堵住了路。等到 Mod 做完之后,才发现其实只是我走错了路……
BepInEx 是适用于 Unity 游戏的 Mod 框架,基本思路就是 Hook 方法,能够修改方法返回值或者方法体来达到自定义修改的目的。同时提供了反射工具来获取实例的私有变量以及私有方法等相关内容。
制作 Mod 大概需要满足以下条件:

  1. Unity 游戏
  2. 游戏未加密 / 混淆
  3. 会编写 C# 应用
  4. 能够使用反编译工具

# 相关资料

  • BepInEx - Github
  • BepInEx 文档
  • Harmony 文档
  • dnspy
  • 宵夜的 Mod 系列教学

# 安装 BepInEx5 框架

  1. 进入 BepInEx 的 Release,下载最新的 BepInEx5 框架,现在游戏应该都是 x64 的了
  2. 解压压缩包内容至游戏根目录,使 winhttp.dll 与游戏主 exe 同一级
  3. 修改 BepInEx\config\BepInEx.cfgLogging.Console 下的 Enabled 为 true
  4. 启动游戏主程序,若此时额外弹出个命令行窗口则说明安装成功

# 插件编写

# 确认可以编写 Mod

第一步当然是确认编写 Mod 的前提条件啦,本文只适用于 Unity 游戏的 Mod,像是虚幻引擎,自研引擎等都是不能用 BepInEx 做 Mod 的。

  1. 进入游戏文件夹
  2. 搜索 Assembly-CSharp.dll

如果文件能够搜到,就是 Unity 游戏,接下来检查是否加密了,将上一步搜索到的 dll 文件扔进 dnspy,展开类目录,随便查看一个类,如果能够正常看到代码就说明未加密
dnspy
此时便可以进行下一步

# 寻找 Hook 目标

做 Mod 前需要明确自己想要什么功能,之后再代码中找到对应实现,思考更改怎么做才能实现你的目的。
比如我想要怪物击杀后获得的经验量翻倍,那么可以去寻找怪物死亡之后计算产出的函数;想要无限生命,有很多思路可以实现,比如将生命值变成很大,一时半会扣不完;或者可以修改碰撞检测的函数,让伤害与角色不发生碰撞检测。
以击杀经验翻倍为例,首先要寻找怪物死亡的回调,搜索关键字大概就是 die death 一类的,搜索范围选择方法

经过在搜索结果里寻找,最终锁定在 DoDieThings 这个方法上,此处开发者也写了 Log 来帮助我们确认这个方法是处理怪物死亡的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if (this.OnDeathCall != null)
{
this.OnDeathCall();
}
else
{
Debug.Break();
Debug.LogError(string.Concat(new string[]
{
"EnemyAI死亡,但沒有功能註冊死亡處理事件.會造成無法正常死亡! death EnemyAI(",
base.transform.name,
"). on death CurrentStateName(",
this.CurrentStateName,
"). Path(",
ExtendFunc.GetAllParentNamePath(base.transform),
") Scene(",
SceneManager.GetActiveScene().name,
")"
}), this);
}

同时也发现了我们的目标代码

1
2
3
4
5
6
7
this.DropBooties();
base.ClearGhost();
GlobalValues.onEndUltimateTime = (Action)Delegate.Remove(GlobalValues.onEndUltimateTime, new Action(this.EndUltimateTimeHandler));
EnemyDectectionManager.Instance.BGMUnRegist(this);
EnemyDectectionManager.Instance.FlagUnRegist(this);
SuspicionUIManager.Instance.UnRegist(this);
PlayerController2D.Instance.AddExp(this.Exp); // 此处为角色增加经验

增加经验的方法很明确,甚至可以直接再加个功能叫经验 + 3(
此处增加的经验数值为怪物的属性,给这个数值乘以对应的值即可,我们能够想到两种思路:

  1. 修改方法体,在 AddExp 的参数后乘以对应的倍数
  2. 调用方法前将这个属性变化数倍

第一种方法需要修改这个方法体的 IL,困难一些;而第二种方法在实现上是比较简单的,只需要运用 HarmonyX 的补丁功能即可实现。

# 创建插件项目

创建项目时推荐使用 BepInEx 官方的模板来生成项目,之后我们再进行相对应的修改。
以下内容将介绍使用模板创建项目的方法

  1. 打开命令行,输入 dotnet new -i BepInEx.Templates::2.0.0-be.2 --nuget-source https://nuget.bepinex.dev/v3/index.json
    等待模板安装成功后,应当输出以下内容,我们将使用 BepInEx 5 Plugin 模板来进行插件制作
1
2
3
4
5
6
7
Template Name                    Short Name               Language  Tags
------------------------------- ----------------------- -------- ------------------------------------------
BepInEx 5 Plugin bepinex5plugin [C#] BepInEx/BepInEx 5/Plugin
BepInEx 6 .NET Core Plugin bep6plugin_coreclr [C#] BepInEx/BepInEx 6/Plugin/CoreCLR/.NET Core
BepInEx 6 .NET Framework Plugin bep6plugin_netfx [C#] BepInEx/BepInEx 6/Plugin/.NET Framework
BepInEx 6 Unity Il2Cpp Plugin bep6plugin_unity_il2cpp [C#] BepInEx/BepInEx 6/Plugin/Unity/Il2Cpp
BepInEx 6 Unity Mono Plugin bep6plugin_unity_mono [C#] BepInEx/BepInEx 6/Plugin/Unity/Mono
  1. 在要创建目录的文件夹打开命令行,输入 dotnet new bepinex5plugin -n 插件名称 ,之后再输入 dotnet restore 插件名称
  2. 通过以上两步应当创建了一个具有两个文件插件项目,使用 IDE 打开项目,可以看到 Plugin.cs 中有以下内容
1
2
3
4
5
6
7
8
9
10
11
12
13
using BepInEx;

namespace MyFirstPlugin;

[BepInPlugin(MyPluginInfo.PLUGIN_GUID, MyPluginInfo.PLUGIN_NAME, MyPluginInfo.PLUGIN_VERSION)]
public class Plugin : BaseUnityPlugin
{
private void Awake()
{
// Plugin startup logic
Logger.LogInfo($"Plugin {MyPluginInfo.PLUGIN_GUID} is loaded!");
}
}
  1. 在特性的 PLUGIN_GUID 处按 F12 可进入插件信息修改插件名称、版本等信息
  2. 修改项目的.net framework 版本,默认为 3.5 过低,建议修改至 4.5 以上
  3. 添加引用,将 Assembly-CSharp.dll 添加至项目引用中
  4. 尝试重新生成,若无报错则成功

至此,完成了插件项目的创建

# 编写插件逻辑

Harmony 提供了很多方法 Hook 的方法,常用的就是调用前、替换方法体、调用后,分别对应特性为 HarmonyPrefix HarmonyILManipulator HarmonyPostfix
根据上面我们找到的方法,可以编写这样一个 Patch

1
2
3
4
5
6
7
[HarmonyPatch(typeof(EnemyAI), "DoDieThings")] // 此处描述如何定位到Hook方法
[HarmonyPrefix] // 描述为调用方法前
public static bool PatchDoDieThingsPre(EnemyAI __instance)
{
__instance.Exp *= 10;
return true; // 返回true则继续调用原方法,否则将跳过原方法。若跳过原方法,原方法的返回值请记着做赋值
}

__instance 参数为调用方法的实例对象。
我们写到这里时发现报错了,提示 Exp 属性没有 set 方法,此时意识到这个值大概是从数据库实时获取的,再 Exp 处按 F12 进行反编译(VS2022),可以看到提供了两种方法来获取怪物的经验值,一种为自定义经验值,另一种为从数据库实时查询的,转变思路为 HookExp 的 get 方法。

1
2
3
4
5
6
7
8
9
10
11
12
public int Exp
{
get
{
if (UseCustomExp)
{
return Mathf.Max(0, CustomExp);
}

return ExperienceDatabase.Instance.GetEnemyExp(StateRecorder.Instance.fullGameData.CharacterLevel, Level, EnemyType);
}
}

修改之后 Patch 的代码变为以下样子

1
2
3
4
5
6
[HarmonyPatch(typeof(EnemyAI), "Exp", MethodType.Getter)] // 描述为Exp属性的get
[HarmonyPostfix] // 描述为调用方法之后
public static void PatchExp(ref int __result)
{
__result *= 10;
}

此处的 __result 为返回的结果,我们在这里直接修改结果即可。
之后,将补丁生效,通过在 Awake 方法内添加 Harmony.CreateAndPatchAll(typeof(Plugin)); 来使补丁生效
最终,生成 dll,只需要取我们的插件 dll,其余依赖都不需要,将这个文件复制进 BepInEx\plugins 即可
file
启动游戏验证功能

# Traverse 反射工具

Traverse 工具用于挖掘私有字段、私有方法等无法通过实例来调用的。使用方法也十分简单

1
2
3
4
Traverse.Create(ControllerInstance).Field("transform").GetValue(); // 获取实例的字段
Traverse.Create(ControllerInstance).Property("transform").GetValue(); // 获取实例的属性
// 对应的也有SetValue来设置值
Traverse.Create(ControllerInstance).Method("transform").GetValue(); // 调用方法并获取返回值

# 热键功能

在插件主体内新建一个名为 Update 的方法,与 Unity 内帧更新应该是相同的方法,在这里可以执行按键监测功能,比如可以这样

1
2
3
4
5
6
7
8
9
10
11
void Update()
{
if (Input.GetKeyDown(KeyCode.F11)) //GetKey可以监测按键一直按下,GetKeyDown监测按下一次
{
PlayerController2D.Instance.AddExp(100);
}
else if(Input.GetKeyDown(KeyCode.F10) && Input.GetKey(KeyCode.LeftControl)) // 对于组合按键需要有一侧使用GetKey来进行检测
{
PlayerController2D.Instance.AddExp(1000);
}
}

这样便实现了每按一次 F11 就加 100 经验,按 LCtrl+F10 经验加 1000 的功能了

# IL 修改

挖坑...

# 添加 UI

挖坑...

# Harmony 补丁参数列表

常用参数:

参数描述
__result返回值,需要用 ref,且类型为原函数返回值类型
__instanceobject,调用方法的实例对象,类型也可以是实例的类型
__argsobject [],方法参数数组
__runOriginalbool,是否执行原方法,对于调用前 Hook 来说也可以用返回值来标志

其余参数请见 Wiki

# 圣女之歌 ZERO2 Mod

# 不受伤害

1
2
3
4
5
6
7
8
9
[HarmonyPatch(typeof(HitTrigger), "CheckTouch")]
[HarmonyPostfix]
public static void PatchCheckTouch(object[] __args, ref bool __result)
{
if (__result && __args.Length > 0 && __args[0] is HitTrigger atkHit && atkHit.TriggerType == TriggerBase.TriggerTypeEnum.Enemy)
{
__result = false;
}
}

原理:再原碰撞检测执行之后,进入 Patch,若伤害来源为敌人,则取消碰撞判定,这样便实现了敌人打不到我,但我能打到敌人的效果

# 无限宠物飞行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static PetSkillBase SkillInstance { get; set; }
void Update()
{
if (SkillInstance is PetSkillFly fly)
{
fly.BasicCatchFlyTime = 9999;
}
}
[HarmonyPatch(typeof(PetSkillBase), "OnSkill")]
[HarmonyPrefix]
public static void PatchOnSkill(PetSkillBase __instance)
{
SkillInstance = __instance;
}

简单的数值替换,其中在 Update 中一直修改值,但要求只有宠物技能实例为飞行技能时才生效

# 跳过开场动画

1
2
3
4
5
6
[HarmonyPatch(typeof(ProgramStart), "Start")]
[HarmonyPrefix]
public static void PatchStart(ProgramStart __instance)
{
__instance.firstScene = "Title";
}

游戏开始时,不载入开场动画的 Scene,而是直接载入游戏主菜单,这个 Scene 的名称可以通过以下补丁来获取

1
2
3
4
5
6
[HarmonyPatch(typeof(StateRecorder), "DoLoadscene")]
[HarmonyPrefix]
public static void PatchDoLoadscene(object[] __args)
{
PublicLogger.LogInfo($"Loadscene: {__args[0]}");
}

其中 PublicLoggerAwake 时暴露的私有日志对象,可以将日志输出至 BepInEx 命令行窗口。
这样在游戏载入过程中,你就可以看到所有 Scene 的名称了

# 快速进入游戏

1
2
3
4
5
6
7
8
9
[HarmonyPatch(typeof(TitleAnimationController), "OpeningAnimationCoroutine")]
[HarmonyPrefix]
public static void PatchOpeningAnimationCoroutine(object[] __args)
{
if (__args[0] is Action action)
{
action?.Invoke();
}
}

OpeningAnimationCoroutine 为主菜单动画结束回调函数。
这个游戏的主菜单有个很长的动画,还不能按键来跳过,很烦,于是就跳过了。
原理:原本在动画播放完成之后才调用此回调函数,但是 Hook 了动画播放方法,在方法进入之前我先给他调用了,这样便可以跳过冗长的动画直接进入存档载入界面

# 位置瞬移

1
2
var p = ControllerInstance.player.position;
ControllerInstance.transform.position = new Vector3 { x = p.x + 10, y = p.y, z = p.z };

可以放在按键监测里

更新于 阅读次数