BepinEx IL2CPP Modding 覚え書き
BepInEx を用いたIL2CPP Moddingに関する日本語のメモが少なかったので書いておく
使用したソフトウェアのバージョン情報
- Visual Studio Code 1.67.2
- プラグイン ms-dotnettools.csharp 1.25.0
- Visual Studio Community 2022 17.1.0
- 対象のゲーム: Unity 2018.4.36f1
- BepInEx 6.0.0-be.567 (BLEEDING EDGE Build #567 from 5d77398a5c3a707ae6de0eb7121d0b8cc5a53b0c at master)
なぜVSとVSCodeを両方使うのか
dotnet初心者なのでプロジェクトプロパティなどはGUIでいじれたほうがいいからVSを使う。
しかしコード自体はエディタに慣れているのでVSCodeで書く。Copilotも優秀だし。
BepInExのドキュメント(master)
latestは5.xに関するものなのでmasterを参照すること
テンプレートからPluginを作成する
dotnet new -i BepInEx.Templates --nuget-source https://nuget.bepinex.dev/v3/index.json
でテンプレートをインストールした後
dotnet new bep6plugin_il2cpp -n MyPlugin
で作成。il2cppテンプレートの場合Unityバージョンやターゲットフレームワークは指定しなくていい
VSCodeで開くとrestoreしろって出てきたから したらエラーが全部消えた。nodeでいうnpm install
みたいなことだと思う。
たぶんdotnet restore
が実行されてる
GUID、名前、バージョンを変える
Plugin.cs
を開くとPluginクラスにBepinPluginアノテーションが付いていて、PluginInfoからGUIDやNAME, VERSIONを取得して渡していることがわかる。
[BepInPlugin(PluginInfo.PLUGIN_GUID, PluginInfo.PLUGIN_NAME, PluginInfo.PLUGIN_VERSION)]
public class Plugin : BasePlugin
これらを変更したい時、PluginInfoの定義を参照するとobj内のPluginInfo.cs
にたどり着くが、変更するべきはこれではない。obj内はキャッシュのようなものであるため触るべきではない(らしい)。
テンプレートのpropsに定義されている通り、これらは{プラグイン名}.csproj
に記載されている<AssemblyName>
, <Product>
, <Version>
が参照されている。それらを変えるとobj内のPluginInfo.cs
が自動で更新されて、Plugin.cs
で参照されている値も更新される。
GUID
<AssemblyName>
が参照される。
通常は逆ドメイン名で書くべきなのでそのようにする。
ドメイン持ってない人はNAMEとおなじでもいいけど他のmodと被ってはいけないので気をつける。
他のmodがこのmodをこのmodとして認識する手段なので一度リリースしたら変えてはいけない。
Name
<Product>
が参照される。
テンプレートから作った場合{プラグイン名}.csprojに無いので追加する。
わかりやすい名前なら何でもいい。
Version
<Version>
が参照される。
<VersionPrefix>
や<VersionSuffix>
も使えて、たとえば
<VersionPrefix>1.0.0</VersionPrefix>
<VersionSuffix>alpha</VersionSuffix>
と指定すると
<Version>1.0.0-alpha</Version>
と同じような扱いになり、PluginInfo.cs
内の定義も
public const string PLUGIN_VERSION = "1.0.0-alpha";
になる
またVersionはSemantic Versioningに従う必要がある
最終的に以下のようになるはず
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>com.example.MyPlugin</AssemblyName>
<Product>MyPlugin</Product>
<VersionPrefix>1.0.0</VersionPrefix>
<VersionSuffix>beta</VersionSuffix>
ビルドしてみる
コードに問題がなければ
dotnet build
でビルドできる。
成功すると{プラグイン名}.dll
が/bin/Debug/netstandard2.1
の下に生まれるのでゲームのBepinEx/plugin
に入れると機能するはず。
シンボリックリンクを貼るといちいちコピーしなくて良くて便利。でもゲーム起動中ビルドできなくなる。
mklink G:\Game\BepInEx\plugins\MyPlugin.dll C:\Users\Eai\ghq\github.com\eai04191\MyPlugin\MyPlugin\bin\Debug\netstandard2.1\MyPlugin.dll
ビルドのたびにゲーム落としてコピーして……とかもっと凝ったことしたいならビルドスクリプトを書くといいのかもしれないけどよくわからない
ゲームのアセンブリを読み込む
TODO: unhollowedにあるdllを参照に追加する。Private属性を付けておくとビルド先が汚れない。他人がビルドできるようにLibなどのディレクトリをつくり相対パスにしておくとよい。
あるいはゲームのルートを環境変数で指定させるようにする
<ItemGroup>
<Reference Include="Assembly-CSharp" Private="False">
<HintPath>$(GAMENAME_ROOT)\BepInEx\unhollowed\Assembly-CSharp.dll</HintPath>
</Reference>
</ItemGroup>
getter, setterのないフィールドを書き換える
例えばこのようなクラスがあり
class OriginalClass {
private int _field;
public void OriginalMethod() {
this._field = 123;
}
}
HarmonyPatchでOriginalMethod()
が参照する_field
の値を書き換えたい場合。
Harmonyはプロパティのgetter/setterをPatchすることができるが、IL2CPPのフィールドはプロパティではないためgetter/setterがない。
[HarmonyPatch(typeof(OriginalClass), nameof(OriginalClass._field), MethodType.Getter))]
のように指定しても
Method OriginalClass OriginalClass::get__field() is a field accessor, it can't be patched.
というエラーがDetourから返される。
実はパッチで__instance
という名前の引数を受け取るとthis
相当にアクセスできるのでそこから書き換えられる。
[HarmonyPatch(typeof(OriginalClass), nameof(OriginalClass.OriginalMethod))]
public static class OriginalMethodPatch
{
static void Postfix(OriginalClass __instance)
{
__instance._field = 234;
}
}
GUI.Button
, GUILayout.Button
が動かない
Windowの中のもはやBepinEx関係ないんだけど
public class UI : MonoBehaviour
{
void OnGUI()
{
GUI.Window(
0,new Rect(0,0,100,100),WindowFunction,"Sample Window");
}
void WindowFunction(int windowID)
{
GUI.DragWindow();
if (GUI.Button(new Rect(0, 20, 100, 80), "Button"))
{
Debug.Log("Button");
};
}
}
こんな感じでドラッガブルなWindowの中にあるボタンが動かなかった(クリックできるが、クリックしてもDebug.Log()が呼ばれてないようだった)
実はDragWindow()が悪くて、最後に書くと動く
public class UI : MonoBehaviour
{
void OnGUI()
{
GUI.Window(
0,new Rect(0,0,100,100),WindowFunction,"Sample Window");
}
void WindowFunction(int windowID)
{
- GUI.DragWindow();
if (GUI.Button(new Rect(0, 20, 100, 80), "Button"))
{
Debug.Log("Button");
};
+ GUI.DragWindow();
}
}
そんなことあるかよ
自作のCoroutineがStartCoroutineで実行できない
System.Collections.Generic.IEnumeratorを返すメソッドをコルーチンとしてMonoBehaviour.StartCoroutineの引数に入れて呼ぼうとするとIl2CppSystem.Collections.Generic.IEnumeratorにしろって怒られる
これはBepInEx.Unity.IL2CPP.Utils.Collections
をインポートして、coroutine.WrapToIl2Cpp()
を呼ぶといい感じに変換できて渡せる
MonoBehaviour の使い方
IL2CPP 環境での MonoBehaviour
の使い方についてわかったことをまとめます。
前提
- 対象のゲーム: Unity 2021.3.14f1
- BepInEx 6.0.0-be.674
- BepInEx.PluginInfoProps 2.1.0
GameObject に AddComponent する方法
GameObject
に AddComponent
する前に ClassInjector.RegisterTypeInIl2Cpp<T>()
を呼び出す必要があります。
以下は静的コンストラクターを使い自動で呼び出す実装例です。
using Il2CppInterop.Runtime.Injection;
using UnityEngine;
namespace MyPlugin
{
public class MyMonoBehaviour : MonoBehaviour
{
static MyMonoBehaviour()
{
ClassInjector.RegisterTypeInIl2Cpp<MyMonoBehaviour>();
}
void Awake()
{
Debug.Log("Awake has been called!");
}
}
}
using BepInEx;
using BepInEx.Unity.IL2CPP;
using UnityEngine;
namespace MyPlugin
{
[BepInPlugin(MyPluginInfo.PLUGIN_GUID, MyPluginInfo.PLUGIN_NAME, MyPluginInfo.PLUGIN_VERSION)]
public class Plugin : BasePlugin
{
public override void Load()
{
var myGameObject = new GameObject("MyGameObject");
// 以下の実行前に ClassInjector.RegisterTypeInIl2Cpp<MyMonoBehaviour> が実行される。
myGameObject.AddComponent<MyMonoBehaviour>();
}
}
}
実行時に以下のようなログが出ます。
[Info :Il2CppInterop] Registered mono type MyPlugin.MyMonoBehaviour in il2cpp domain ← これが ClassInjector.RegisterTypeInIl2Cpp<MyMonoBehaviour> のログだと思う。
[Message: Unity] Awake has been called!
BasePlugin の AddComponent を使う方法
BasePlugin
には AddComponent
メソッドがあります。
この方法を使用する場合、ClassInjector.RegisterTypeInIl2Cpp<T>()
の呼び出しは不要です。
using BepInEx;
using BepInEx.Unity.IL2CPP;
namespace MyPlugin
{
[BepInPlugin(MyPluginInfo.PLUGIN_GUID, MyPluginInfo.PLUGIN_NAME, MyPluginInfo.PLUGIN_VERSION)]
public class Plugin : BasePlugin
{
public override void Load()
{
AddComponent<MyMonoBehaviour>();
}
}
}
この方法では、Il2CppUtils
が管理するグローバルな GameObject
に対象の MonoBehaviour
を追加するようです。
また、その際に必要に応じて ClassInjector.RegisterTypeInIl2Cpp<T>()
を呼んでくれます。
補足
-
GameObject
を作る場合、以下のように DontSave を付けた方が良いかもしれない。-
← 超絶勘違い。確認用に作ったAwake
は呼ばれるがStart
/Update
等が呼ばれなかった。BasePlugin.AddComponent
を追った先でやっていたことをまねた。GameObject
が起動直後のスプラッシュ画面シーンに巻き込まれて死んでいただけだった。DontSave を付けるとシーンから切り離されて死ななくなり動いたように感じただけ。動きは通常の Unity と同じと思っていいと思う。
-
-
ScritableObject
を作る場合もClassInjector.RegisterTypeInIl2Cpp<T>()
が必要になる。 - プレーンクラスはそのまま使っても大丈夫そう。
- 実装のない abstract メソッドを絡ませることはできない(VTable ほにゃらら系の実行時エラーになる)。
もしかしたらまた勘違いの可能性もあるが GameObject.DontDestroyOnLoad
が効かないかもしれない。
代わりに hideFlags
に HideFlags.HideAndDontSave
を付与するとシーンのアンロードに巻き込まれて削除されるのを防げる。
イベントの登録の仕方
IL2CPP 環境でのイベントの登録の仕方についてわかったことを SceneManager.sceneLoaded を事例としてまとめます。
通常の Unity 環境では、以下のように書いていると思います。
using UnityEngine;
using UnityEngine.SceneManagement;
namespace MyPlugin
{
public class MyMonoBehaviour : MonoBehaviour
{
void OnSceneLoaded(Scene scene, LoadSceneMode mode) {
Debug.Log($"Scene loaded: {scene.name}, Mode: {mode}");
}
void OnEnable()
{
SceneManager.sceneLoaded += OnSceneLoaded;
}
void OnDisable()
{
SceneManager.sceneLoaded -= OnSceneLoaded;
}
}
}
IL2CPP 環境では上記はコンパイルエラーになります。試行錯誤した結果、以下の通り書けることがわかりました。
using System;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace MyPlugin
{
public class MyMonoBehaviour : MonoBehaviour
{
// Remove を有効にするために UnityAction の実体として持つ
private UnityAction<Scene, LoadSceneMode> onSceneLoaded;
void Awake()
{
onSceneLoaded = (UnityAction<Scene, LoadSceneMode>)OnSceneLoaded;
}
void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
Debug.Log($"Scene loaded: {scene.name}, Mode: {mode}");
}
void OnEnable()
{
SceneManager.sceneLoaded += onSceneLoaded;
}
void OnDisable()
{
SceneManager.sceneLoaded -= onSceneLoaded;
}
}
}
なぜか?
IL2CPP と通常の Unity 環境での UnityEngine.Events
の実装が異なるためです。
- 通常の Unity の
UnityAction
はdelegate
です。 - IL2CPP の
UnityAction
はIl2CppSystem.MulticastDelegate
を継承したクラスです。-
UnityAction
はAction
からの暗黙的変換を持っています。
-
Il2CppInterop(旧 Il2CppAssemblyUnhollower)について
概要
BepInEx を構成する中核の1つ。
IL2CPP のネイティブコード(=ゲーム本体および各種 Unity の機能)と .NET のマネージドコード(=自分で書いた BepInEx プラグインのコード)の双方向の橋渡しをしてくれる。
BepInEx でコードをいっぱい書くなら避けては通ることのできない存在である。
双方向の橋渡しをより具体的にすると以下の通り。
- .NET マネージドコードから IL2CPP ネイティブコードの呼び出し
- BepInEx/interop(旧 BepInEx/unhollowed)配下の DLL 経由で機能を呼び出す。
- IL2CPP ネイティブコードから .NET マネージドコードを呼び出す
- ClassInjector(Awake や Start や Update などが該当) / UnityAciton(StartCoroutine や SceneManager.sceneLoaded への登録等が該当) 経由で機能を呼び出してもらう。
慣れるまで(慣れても)よくわからないエラーがいっぱいでるので自前で DLL をビルドできるようにしておく必要があるかもしれない。
ログのスタックトレースからソースを追って真の問題を追跡したり、問題を明確にするために追加のログを仕込んだり。標準のエラーログだけでは何が問題でどうしたらいいのかは少なくとも現時点の私には判断できない。
.NET マネージドコードから IL2CPP ネイティブコードの呼び出しについて
Il2CppInterop は interop 配下の DLL を作ってくれている機能でもある。
interop 配下の DLL が持っている機能は基本的に IL2CPP ネイティブコードを呼び出す単なるブリッジである。従って、機能の実体は基本的に持っていない。
しかし、一部の機能は実体を伴っていることがある。これは Il2CppInterop の unstrip という機能によるものである。unstrip を理解するには IL2CPP の stripping という機能をまずは理解しなければならない。
stripping とは未使用の機能を削除するという IL2CPP の機能である。これによって IL2CPP ネイティブコードは全ての Unity API をサポートするとは限らない。これに対して unstrip とは削除された機能の復旧を試みる機能である。
unstrip は通常の Unity の DLL を元に復元を試みるため実体を伴う。しかし、これにより全ての機能を復旧できるわけではない点には注意が必要である。復旧に失敗したメソッドを呼んだ際には「System.NotSupportedException: Method unstripping failed」が発生する。
そのような場合でも取れる選択肢はあるかもしれない(ないかもしれない)。例えば
- 他の機能の組み合わせで実現可能なものであれば別途実装する。
- 他に代替する機能を探して乗り換える。
などである。
IL2CPP ネイティブコードから .NET マネージドコードの呼び出しについて
ClassInjector は対 IL2CPP ネイティブコード用の疑似クラスをメモリ上に作ってくれる機能である。
これによって IL2CPP ネイティブコードは .NET マネージドコードのクラスのメソッドを理解できるようになり、Awake や Start や Update などを呼んでくれるようになる。
IL2CPP ネイティブコードから呼び出されるメソッドの実体のことを Il2CppInterop は Trampoline と呼んでいるようだ。スタックトレースに Trampoline という単語を見かけたら、それは IL2CPP ネイティブコードから .NET マネージドコードの橋渡しをしてくれているものだと思えば良い。
ClassInjector は対象となるクラスに定義された全てのメソッド・プロパティを疑似クラスに定義しようとするが失敗することがある。具体的には IL2CPP ネイティブコードと対応付いていない型を引数や戻り値に使っているメソッド・プロパティは処理できない。
しかし、基本的にはそのようなメソッドは IL2CPP ネイティブコードから呼び出されることはないはずなので疑似クラスに定義する必要もない。そのような場合は [HideFromIl2Cpp] 属性クラスをメソッドやプロパティに設定すれば疑似クラスへの定義対象外となり問題を回避できる。
また、UnityAction によるイベントについては UnityAction 自体が Trampoline の機能を持っているためイベントとして追加するメソッド自体は [HideFromIl2Cpp] 属性クラスを付与してかまわない。StartCoroutine も内部的には UnityAction を使っているので IEnumerator を返すコルーチンに [HideFromIl2Cpp] 属性クラスを設定してかまわない。IL2CPP ネイティブコードは IEnumerator を処理できないので、[HideFromIl2Cpp] 属性クラスを設定しない状態で ClassInjector に掛けると警告が出る。
Il2CppType.From による System.Type → Il2CppSystem.Type 変換
動的な型を GameObject.AddComponent(Type)
で指定したい場合がある。
IL2CPP の場合、指定する Type は System.Type
ではなく Il2CppSystem.Type
になっている。
System.Type
から Il2CppSystem.Type
を得るには Il2CppType.From
を使おう。
new GameObject().AddComponent(Il2CppType.From(t));
Il2CppObjectBase.Cast<T> によるキャスト
IL2CPP ネイティブコードが管理するインスタンスのキャストには Il2CppObjectBase.Cast<T>
を使用する。
以下に事例を示す。
AssetBundle bundle = AssetBundle.LoadFromMemory(/* 省略:任意の方法でバイト配列としてアセットバンドルを取得 */);
Shader shader = bundle.LoadAsset(CUSTOM_SHADER_NAME).Cast<Shader>();
AssetBundle.LoadAsset
は Unity.Object
を返す。それを例えば UnityEngine.Shader
にキャストするには上記のように書くことができる。
補足
この Il2CppObjectBase.Cast<T>
は .NET マネージドコードのクラスの継承関係とは無関係に機能する。
IL2CPP ネイティブコードのインスタンスの実体は .NET マネージドコードの管理外にある。
.NET マネージドコード上のクラスは IL2CPP ネイティブコードのメソッドの呼び出しを補助する単なるラッパーである。
Il2CppObjectBase
インスタンスの本体はおおよそ IL2CPP ネイティブコードのインスタンスへのポインターでしかなく .NET マネージドコードの継承関係は IL2CPP ネイティブコードにとって何も意味がないからだ。
これを逆手にとって Unity.Object
でも何でもいいから取得できるなら取得して、その後で自由にキャストするという選択肢が採れる。
最初から欲しい型でインスタンスを取得できればもちろん問題ない。
しかし、IL2CPP の制約でその API が塞がれていることが稀によくあるので、知っておくと抜け道の1つになる。
なんなら Il2CppObjectBase
クラスを自分で拡張した独自のクラスを作りそれにキャストしたって良い。作り方は Il2CppInterop DLL の中を見ればおおよそ想像が付くはずだ。
これを応用すると IL2CPP の stripping によって削除されてしまったメソッドを独自実装して復活させることだってできる。それを組み立てるのに必要な下位の API が残ってくれていればの話ではあるが…。
struct は、この範疇の外にあるらしく取り扱いに難がある。struct 周辺はまだよくわかっておらず、あきらめて他の方法を探すことがしばしばある。
【Windows 限定】ゲーム内処理を minhook でフックして書き換える。
Harmony ではゲーム本体のネイティブコードには手が出せません。
しかし、Windows 限定ですが minhook ならゲーム内処理をフックして書き換えられる可能性があります。
以下は UnityEngine.Application::Quit をフックし、ゲーム終了操作時にログを差し込むサンプルです。
ゲームの終了操作に反応してログが出ることを確認済みです。
動かすには minhook をビルドして手に入れたMinHook.x64.dll
をプラグイン本体と同じディレクトリに配置します。
using System;
using System.Runtime.InteropServices;
using BepInEx;
using BepInEx.Unity.IL2CPP;
using Il2CppInterop.Runtime;
using UnityEngine;
namespace MinhookPluginExample;
[BepInPlugin(MyPluginInfo.PLUGIN_GUID, MyPluginInfo.PLUGIN_NAME, MyPluginInfo.PLUGIN_VERSION)]
public class Plugin : BasePlugin
{
// Application.Quit delegate definition
private delegate void QuitDelegate();
private static QuitDelegate originalQuit;
private static GCHandle quitHookHandle;
public override void Load()
{
// Initialize MinHook
int initializeResult = MH_Initialize();
if (initializeResult != 0)
{
Debug.LogError($"MinHook initialization failed: {initializeResult}");
return;
}
Debug.Log("MinHook initialization succeeded");
// Get the function pointer for Application.Quit
IntPtr quitPtr = IL2CPP.il2cpp_resolve_icall("UnityEngine.Application::Quit");
Debug.Log($"QuitPtr: {quitPtr}");
// Create the hook
QuitDelegate quitHookDelegate = new QuitDelegate(QuitHook);
quitHookHandle = GCHandle.Alloc(quitHookDelegate); // Pin the delegate
IntPtr quitHookPtr = Marshal.GetFunctionPointerForDelegate(quitHookDelegate);
int createHookResult = MH_CreateHook(quitPtr, quitHookPtr, out IntPtr originalPtr);
if (createHookResult != 0)
{
Debug.LogError($"MH_CreateHook failed: {createHookResult}");
return;
}
Debug.Log("MH_CreateHook succeeded");
// Create the delegate for the original function
originalQuit = Marshal.GetDelegateForFunctionPointer<QuitDelegate>(originalPtr);
// Enable the hook
int enableHookResult = MH_EnableHook(quitPtr);
if (enableHookResult != 0)
{
Debug.LogError($"MH_EnableHook failed: {enableHookResult}");
return;
}
Debug.Log("MH_EnableHook succeeded");
}
private static void QuitHook()
{
Debug.LogWarning("Application.Quit was called!");
originalQuit();
}
[DllImport("MinHook.x64.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern int MH_Initialize();
[DllImport("MinHook.x64.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern int MH_CreateHook(IntPtr pTarget, IntPtr pDetour, out IntPtr ppOriginal);
[DllImport("MinHook.x64.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern int MH_EnableHook(IntPtr pTarget);
}