【Unity】Mod形式的Dll及AssetBundle外部加载插件
综述本插件利用Mono.cecil静态注入模块(BepInEx包含的一个dll)实现在Unity游戏预加载(PreLoader)阶段的Dll修补工作,用以达到通过同版本Unity创建AssetBundle时候,无法打包脚本导致的游戏运行过程中利用Harmony等动态注入模块通过Hook函数或其他方式加载外部AssetBundle中的GameObject出现如下图所示的脚本缺失问题(The refe
综述
本插件利用Mono.cecil静态注入模块(BepInEx包含的一个dll)实现在Unity游戏预加载(PreLoader)阶段的Dll修补工作,用以达到通过同版本Unity创建AssetBundle时候,无法打包脚本导致的游戏运行过程中利用Harmony等动态注入模块通过Hook函数或其他方式加载外部AssetBundle中的GameObject出现如下图所示的脚本缺失问题(The referenced script on this Behaviour is missing!)。
使用方法
Github源码连接:点击此处查看
目录结构
只给出了与项目中所给例子相匹配的目录结构,具体结构自行结合实际修改。
- BepInEx
-
config
-
core
-
patches
- PatchMod.dll
- PatchModInfo.dll
- YamlDotNet.dll
-
plugins
- RankPanel_Trigger.dll
- BundleLoader
- BundleLoader.dll
- PatchModInfo.dll
- YamlDotNet.dll
-
- doorstop_config.ini
- winhttp.dll
- PatchMod
- PatchMod.cfg
- RankPanel
- mods.yml
- Dlls
- Assembly-CSharp.dll
- AseetBundles
- rankpanel.ab
- 其他文件
构建
将 PatchMod.dll
放入 BepInEx\patchers
文件夹中,将 BundleLoader.dll
放入 BepInEx\plugins
文件夹中。
对应Mod包的结构参考 PatchMod_Example.zip
进行开发,将解压后的 PatchMod
文件夹放入游戏根目录中。
目录中包含 PatchMod.cfg
与各个Mod的包文件。
PatchMod.cfg
文件内容如下:
[General]
# 是否预先加载进内存,预先加载进去可以防止其他Assembly-csharp加载
preLoad=true
# 是否将修补后的Dll输出到本地,用于调试查看
save2local=false
样板Mod中包含一个排行榜Mod,其打包过程如下:自己根据所要开发插件的游戏的Unity版本,用相同版本开发出组件并编写脚本,将要加入到游戏内的 Object
打包为 AssetBundle
,并记住其名字,然后插件项目整体进行构建,得到插件项目的 Assembly-csharp.dll
,放到文件夹内。
在这里我的Dll文件放到了 Dlls
文件夹下,AssetBundle文件放到了 Resources
文件夹下,并在 mod.yml
(拓展名为 .yml
的文件即可)内编辑Mod相关设置。
# Mod名
name: 排行榜面板
# Dll读取路径
dlls:
- Dlls/Assembly-CSharp.dll
# AssetBundle读取路径
resources:
- AseetBundles/rankpanel.ab
进入游戏后在BepInEx控制台内即可以看到相关插件输出内容以及Mod组件加载列表。此后再根据其他插件Hook某些触发调用 Object
即可。本项目内自带一个测试本用例的插件,亦可以下载完整版测试用例【金庸群侠传X】进行测试。
具体实现
首先是了解BepInEx插件,这是一个用于Unity/XNA游戏的外挂程序。
我们此次主要涉及三个部分:
- 预加载时的Dll修补(Mono.cecil实现)
- 游戏加载完成后的Bundle读取管理(Unity自带的Bundle管理机制)
- 游戏内触发(Harmony2的动态修补Dll)
预加载修补
此部分具体参考的是IL-Repack项目,这是一个利用C#反射机制来进行Dll合并的项目(前身是IL-Merge,现已启用),IL-Repack
的修补工作是通过魔改的一个Mono.cecil
实现,本项目采用原版Mono.cecil
模仿其实现。
using Mono.Cecil;
using Mono.Cecil.Cil;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace PatchMod
{
internal class MergeDll
{
//修复总函数
internal static void Fix(string repairDllPath,ref AssemblyDefinition patchAssembly)
{
//修复用的文件(包含添加进去的内容)
AssemblyDefinition repairAssembly = AssemblyDefinition.ReadAssembly(repairDllPath);
//TODO:下面所有方法只修补二者MainModule.
MergeDll.FixModuleReference(patchAssembly.MainModule, repairAssembly.MainModule);//修复引用
foreach (TypeDefinition typeDef in repairAssembly.MainModule.Types)
{
//修复类型
MergeDll.FixType(patchAssembly.MainModule, typeDef, (module, belongTypeDef, fieldDef) =>
{
MergeDll.FixField(module, belongTypeDef, fieldDef);
}, (module, belongTypeDef, methodDef) =>
{
MergeDll.FixMethod(module, belongTypeDef, methodDef);
});
}
}
//修复Dll引用,将source添加到target中
internal static void FixModuleReference(ModuleDefinition target, ModuleDefinition source)
{
foreach (ModuleReference modRef in source.ModuleReferences)
{
string name = modRef.Name;
//如果存在重名则跳过修补
if (!target.ModuleReferences.Any(y => y.Name == name))
{
target.ModuleReferences.Add(modRef);
}
}
foreach (AssemblyNameReference asmRef in source.AssemblyReferences)
{
string name = asmRef.FullName;
//如果存在重名则跳过修补
if (!target.AssemblyReferences.Any(y => y.FullName == name))
{
target.AssemblyReferences.Add(asmRef);
}
}
}
//修复自定义类型,将source添加到target中
//TODO:目前只能添加不同命名空间的类型
internal static void FixType(ModuleDefinition target, TypeDefinition source, Action<ModuleDefinition, TypeDefinition, FieldDefinition> func_FixFeild, Action<ModuleDefinition, TypeDefinition, MethodDefinition> func_FixMethod)
{
//不合并同名Type
//TODO:是否添加合并同名Type判断?
if (!target.Types.Any(x => x.Name == source.Name))
{
//新建Type
//如果是自定义Type直接Add会导致报错,因为属于不同的模块,
//TODO:暂时没用unity工程的Assembly-csharp测试,不知道直接添加可否成功?
//只向模块添加类型
TypeDefinition importTypeDefinition = new(source.Namespace, source.Name, source.Attributes) { };
//修复基类引用关系
//例如 Component : MonoBehaviour
if (source.BaseType != null)
{
importTypeDefinition.BaseType = source.BaseType;
}
target.Types.Add(importTypeDefinition);
//添加类型下的字段
foreach (FieldDefinition fieldDef in source.Fields)
{
func_FixFeild.Invoke(target, importTypeDefinition, fieldDef);
}
//添加类型下的方法
foreach (MethodDefinition methodDef in source.Methods)
{
func_FixMethod.Invoke(target, importTypeDefinition, methodDef);
}
}
}
//修复类型中的Field
internal static void FixField(ModuleDefinition target, TypeDefinition typeDef, FieldDefinition fieldDef)
{
FieldDefinition importFieldDef = new(fieldDef.Name, fieldDef.Attributes, target.ImportReference(fieldDef.FieldType, typeDef));
typeDef.Fields.Add(importFieldDef);
importFieldDef.Constant = fieldDef.HasConstant ? fieldDef.Constant : importFieldDef.Constant;
importFieldDef.MarshalInfo = fieldDef.HasMarshalInfo ? fieldDef.MarshalInfo : importFieldDef.MarshalInfo;
importFieldDef.InitialValue = (fieldDef.InitialValue != null && fieldDef.InitialValue.Length > 0) ? fieldDef.InitialValue : importFieldDef.InitialValue;
importFieldDef.Offset = fieldDef.HasLayoutInfo ? fieldDef.Offset : importFieldDef.Offset;
#if DEBUG
Log($"Add {importFieldDef.FullName} to {typeDef.FullName}");
#endif
}
//修复类型中的Method
internal static void FixMethod(ModuleDefinition target, TypeDefinition typeDef, MethodDefinition methDef)
{
MethodDefinition importMethodDef = new(methDef.Name, methDef.Attributes, methDef.ReturnType);
importMethodDef.ImplAttributes = methDef.ImplAttributes;
typeDef.Methods.Add(importMethodDef);
#if DEBUG
Log($"Add {importMethodDef.FullName} to {typeDef.FullName}");
#endif
//复制参数
foreach (ParameterDefinition gp in methDef.Parameters)
{
ParameterDefinition importPara = new(gp.Name, gp.Attributes, gp.ParameterType);
importMethodDef.Parameters.Add(importPara);
#if DEBUG
Log($"Add Parameter {importPara.Name} to {importMethodDef.FullName}");
#endif
}
//修复Method函数体
ILProcessor ilEditor = importMethodDef.Body.GetILProcessor();
if (methDef.HasBody)
{
importMethodDef.Body = new Mono.Cecil.Cil.MethodBody(importMethodDef);
}
//TODO:没看懂,照搬
if (methDef.HasPInvokeInfo)
{
if (methDef.PInvokeInfo == null)
{
// Even if this was allowed, I'm not sure it'd work out
//nm.RVA = meth.RVA;
}
else
{
importMethodDef.PInvokeInfo = new PInvokeInfo(methDef.PInvokeInfo.Attributes, methDef.PInvokeInfo.EntryPoint, methDef.PInvokeInfo.Module);
}
}
//函数体参数
foreach (VariableDefinition var in methDef.Body.Variables)
{
importMethodDef.Body.Variables.Add(new VariableDefinition(target.ImportReference(var.VariableType, importMethodDef)));
}
importMethodDef.Body.MaxStackSize = methDef.Body.MaxStackSize;
importMethodDef.Body.InitLocals = methDef.Body.InitLocals;
importMethodDef.Body.LocalVarToken = methDef.Body.LocalVarToken;
//修复函数覆写
foreach (MethodReference ov in methDef.Overrides)
importMethodDef.Overrides.Add(target.ImportReference(ov, importMethodDef));
//修改函数返回
importMethodDef.ReturnType = target.ImportReference(methDef.ReturnType, importMethodDef);
importMethodDef.MethodReturnType.Attributes = methDef.MethodReturnType.Attributes;
importMethodDef.MethodReturnType.Constant = methDef.MethodReturnType.HasConstant ? methDef.MethodReturnType.Constant : importMethodDef.MethodReturnType.Constant;
importMethodDef.MethodReturnType.MarshalInfo = methDef.MethodReturnType.HasMarshalInfo ? methDef.MethodReturnType.MarshalInfo : importMethodDef.MethodReturnType.MarshalInfo;
//TODO:CustomAttribute还就那个不会
foreach (var il in methDef.Body.Instructions)
{
#if DEBUG
Log($"Add IL {il.OpCode.OperandType} - {il.ToString()}");
#endif
Instruction insertIL;
if (il.OpCode.Code == Code.Calli)
{
var callSite = (CallSite)il.Operand;
CallSite ncs = new(target.ImportReference(callSite.ReturnType, importMethodDef))
{
HasThis = callSite.HasThis,
ExplicitThis = callSite.ExplicitThis,
CallingConvention = callSite.CallingConvention
};
foreach (ParameterDefinition param in callSite.Parameters)
{
ParameterDefinition pd = new(param.Name, param.Attributes, target.ImportReference(param.ParameterType, importMethodDef));
if (param.HasConstant)
pd.Constant = param.Constant;
if (param.HasMarshalInfo)
pd.MarshalInfo = param.MarshalInfo;
ncs.Parameters.Add(pd);
}
insertIL = Instruction.Create(il.OpCode, ncs);
}
else switch (il.OpCode.OperandType)
{
case OperandType.InlineArg:
case OperandType.ShortInlineArg:
if (il.Operand == methDef.Body.ThisParameter)
{
insertIL = Instruction.Create(il.OpCode, importMethodDef.Body.ThisParameter);
}
else
{
int param = methDef.Body.Method.Parameters.IndexOf((ParameterDefinition)il.Operand);
insertIL = Instruction.Create(il.OpCode, importMethodDef.Parameters[param]);
}
break;
case OperandType.InlineVar:
case OperandType.ShortInlineVar:
int var = methDef.Body.Variables.IndexOf((VariableDefinition)il.Operand);
insertIL = Instruction.Create(il.OpCode, importMethodDef.Body.Variables[var]);
break;
case OperandType.InlineField:
insertIL = Instruction.Create(il.OpCode, target.ImportReference((FieldReference)il.Operand, importMethodDef));
break;
case OperandType.InlineMethod:
insertIL = Instruction.Create(il.OpCode, target.ImportReference((MethodReference)il.Operand, importMethodDef));
//FixAspNetOffset(nb.Instructions, (MethodReference)il.Operand, parent);
break;
case OperandType.InlineType:
insertIL = Instruction.Create(il.OpCode, target.ImportReference((TypeReference)il.Operand, importMethodDef));
break;
case OperandType.InlineTok:
if (il.Operand is TypeReference reference)
insertIL = Instruction.Create(il.OpCode, target.ImportReference(reference, importMethodDef));
else if (il.Operand is FieldReference reference1)
insertIL = Instruction.Create(il.OpCode, target.ImportReference(reference1, importMethodDef));
else if (il.Operand is MethodReference reference2)
insertIL = Instruction.Create(il.OpCode, target.ImportReference(reference2, importMethodDef));
else
throw new InvalidOperationException();
break;
case OperandType.ShortInlineBrTarget:
case OperandType.InlineBrTarget:
insertIL = Instruction.Create(il.OpCode, (Instruction)il.Operand);
break;
case OperandType.InlineSwitch:
insertIL = Instruction.Create(il.OpCode, (Instruction[])il.Operand);
break;
case OperandType.InlineR:
insertIL = Instruction.Create(il.OpCode, (double)il.Operand);
break;
case OperandType.ShortInlineR:
insertIL = Instruction.Create(il.OpCode, (float)il.Operand);
break;
case OperandType.InlineNone:
insertIL = Instruction.Create(il.OpCode);
break;
case OperandType.InlineString:
insertIL = Instruction.Create(il.OpCode, (string)il.Operand);
break;
case OperandType.ShortInlineI:
if (il.OpCode == OpCodes.Ldc_I4_S)
insertIL = Instruction.Create(il.OpCode, (sbyte)il.Operand);
else
insertIL = Instruction.Create(il.OpCode, (byte)il.Operand);
break;
case OperandType.InlineI8:
insertIL = Instruction.Create(il.OpCode, (long)il.Operand);
break;
case OperandType.InlineI:
insertIL = Instruction.Create(il.OpCode, (int)il.Operand);
break;
default:
throw new InvalidOperationException();
}
//ilEditor.InsertAfter(importMethodDef.Body.Instructions.Last(),ilEditor.Create(OpCodes.Nop));
importMethodDef.Body.Instructions.Add(insertIL);
#if DEBUG
Log($"Add IL {il.OpCode.OperandType} - {insertIL.ToString()}");
#endif
}
importMethodDef.IsAddOn = methDef.IsAddOn;
importMethodDef.IsRemoveOn = methDef.IsRemoveOn;
importMethodDef.IsGetter = methDef.IsGetter;
importMethodDef.IsSetter = methDef.IsSetter;
importMethodDef.CallingConvention = methDef.CallingConvention;
}
}
}
具体调用过程请查看源码,此处只给出实现过程。首先我们合并引用和命名空间(此处只将外部不同命名空间合并而未做同名命名空间合并处理,因此简单不少),然后利用C#反射机制生成同样的类和方法、变量名,然后对于类的具体实现通过中间代码IL
,同样借助反射复制过来(这一段直接抄IL-Repack
的,因为自己不是很了解IL
中间代码)。
Bundle读取管理
首先我们应该要做的是找一个和我们需要修改的游戏同版本的Unity
来进行Bundle
的打包,此处给出一种打包的实现,更为完善的还请百度一下。
[MenuItem("AssetsBundle/Build AssetBundles")]
static void BuildAllAssetBundles()//进行打包
{
// Bundle输出目录
string dir = "../AssetBundles_Generate";
//判断该目录是否存在
if (Directory.Exists(dir) == false) Directory.CreateDirectory(dir);//在工程下创建AssetBundles目录
//参数一为打包到哪个路径,参数二压缩选项 参数三 平台的目标
BuildPipeline.BuildAssetBundles(dir, BuildAssetBundleOptions.UncompressedAssetBundle, BuildTarget.StandaloneWindows);
}
生成Bundle
之后,我们同样需要读取它,读取AssetBundle
的方式很多,因为此处是本地加载,所以我没有写异步加载处理(也许会存在一些问题),具体实现如下:
using BepInEx;
using BepInEx.Configuration;
using BepInEx.Logging;
using PatchModInfo;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
namespace BundleLoader
{
[BepInPlugin("com.EasternDay.BundleLoader", "Mod的Bundle加载器示例", "0.0.1")]
public class BundleLoader : BaseUnityPlugin
{
// 日志记录
private readonly static new ManualLogSource Logger = new("BundleLoader");
// MOD文件读取路径
private static readonly string modIndexPath = Path.Combine(Paths.GameRootPath, "PatchMod");
//插件配置
private static readonly List<ModInfo> mods = new(); //Mod目录
public static readonly Dictionary<string, Object> objects = new(); //游戏物体列表
private void Awake()
{
// BepInEx将自定义日志注册
BepInEx.Logging.Logger.Sources.Add(Logger);
}
private void Start()
{
//读取mods
foreach (string dir in Directory.GetDirectories(modIndexPath))
{
ModInfo curInfo = ModInfo.GetModInfo(dir);
mods.Add(curInfo);
foreach (string bundlePath in curInfo.Resources)
{
// 提示:Bundle打包请使用同版本Unity进行
foreach (Object obj in AssetBundle.CreateFromFile(bundlePath).LoadAllAssets())
{
Logger.LogInfo($"加载MOD资源:{dir}-{obj.name}");
objects.Add(obj.name, obj);
}
}
}
}
}
}
此处引入了一个自己定义的ModInfo
类,并在游戏开始时读取所有Bundle
并存储到字典中按照物体名来索引,后期在通过其他方式加载调用即可。
游戏内触发
运行时修补是修改方法而不永久修补它们的过程。运行时修补发生在游戏运行时,并且在 .NET
上可以非常广泛地完成。
BepInEx
附带 HarmonyX 和 MonoMod.RuntimeDetour
来执行运行时修补。我选择使用HarmonyX
进行修补,这是Harmony
的一个分支,有兴趣当然看看最新版是最好的。
using BepInEx;
using HarmonyLib;
using JX_Plugin;
using JyGame;
using UnityEngine;
using UnityEngine.UI;
namespace RankPanel_Trigger
{
[HarmonyPatch(typeof(RoleStatePanelUI), "Refresh")]
class RoleStatePanelUI_Refresh_Patch
{
public static bool Prefix(RoleStatePanelUI __instance)
{
if (!__instance.transform.FindChild("HardIcon").GetComponent<Button>())
{
__instance.transform.FindChild("HardIcon").gameObject.AddComponent<Button>();
}
__instance.transform.FindChild("HardIcon").GetComponent<Button>().onClick.RemoveAllListeners();
__instance.transform.FindChild("HardIcon").GetComponent<Button>().onClick.AddListener(() =>
{
GameObject go = (GameObject)GameObject.Instantiate(BundleLoader.BundleLoader.objects["RankPanel"]); //生成面板
go.transform.SetParent(GameObject.Find("MapRoot/Canvas").transform);
go.name = "RankPanel";
go.SetActive(true);
go.GetComponent<RankPanel>().SetContent("排行榜", LuaManager.Call<string>("RankPanel_Content", new object[0]));
});
return true;
}
}
[BepInPlugin(PluginInfo.PLUGIN_GUID, PluginInfo.PLUGIN_NAME, PluginInfo.PLUGIN_VERSION)]
[BepInDependency("com.EasternDay.BundleLoader")]
public class Plugin : BaseUnityPlugin
{
private static Harmony harmony = new("JX_Decode_Patch");
private void Awake()
{
//Hook所有代码
harmony.PatchAll();
// 控制台提示语
Logger.LogInfo($"插件 {PluginInfo.PLUGIN_GUID} 成功Hook代码!");
}
}
}
这里使用的是自动补丁,自动PatchAll()
,然后我们进行一个RoleStatePanelUI
的前挂补丁(Prefix)修补,并在修补完成后继续执行原函数(Prefix
函数返回一个bool
值,如果返回true
,则代表原函数可以继续执行,如果返回false
,则代表拦截原函数)。此处只给出一个小例子,具体的更多操作可以查看官方文档或者在我开源的代码里的解密插件可以看到更多用法。
温馨提示
采用本插件开发时,请注意 Nuget
的包和引用库版本务必和自己的 Unity
版本相匹配.
参考项目
名称 | 大概参考文件 |
---|---|
IL-Repack | ILRepack/ILRepack.cs 及其所关联的文件 |
dnSpy | 反编译器,使用下面的dnlib完成IL合并,最终没有选用,但有一定的参考意义 |
dnlib | 最终没有选用,但有一定的参考意义 |
更多推荐
所有评论(0)