Open17

BepinEx IL2CPP Modding 覚え書き

eaieai

BepInEx を用いたIL2CPP Moddingに関する日本語のメモが少なかったので書いておく

使用したソフトウェアのバージョン情報

  • Visual Studio Code 1.67.2
  • 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)

https://docs.bepinex.dev/master/articles/dev_guide/plugin_tutorial/index.html

latestは5.xに関するものなのでmasterを参照すること

eaieai

テンプレートから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が実行されてる

eaieai

GUID、名前、バージョンを変える

Plugin.csを開くとPluginクラスにBepinPluginアノテーションが付いていて、PluginInfoからGUIDやNAME, VERSIONを取得して渡していることがわかる。

Plugin.cs
[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>も使えて、たとえば

MyPlugin.csproj
    <VersionPrefix>1.0.0</VersionPrefix>
    <VersionSuffix>alpha</VersionSuffix>

と指定すると

MyPlugin.csproj
    <Version>1.0.0-alpha</Version>

と同じような扱いになり、PluginInfo.cs内の定義も

PluginInfo.cs
public const string PLUGIN_VERSION = "1.0.0-alpha";

になる

https://blog.beachside.dev/entry/2019/06/06/190000

またVersionはSemantic Versioningに従う必要がある


最終的に以下のようになるはず

MyPlugin.csproj
<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <AssemblyName>com.example.MyPlugin</AssemblyName>
        <Product>MyPlugin</Product>
        <VersionPrefix>1.0.0</VersionPrefix>
        <VersionSuffix>beta</VersionSuffix>
eaieai

ビルドしてみる

コードに問題がなければ

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

ビルドのたびにゲーム落としてコピーして……とかもっと凝ったことしたいならビルドスクリプトを書くといいのかもしれないけどよくわからない

eaieai

ゲームのアセンブリを読み込む

TODO: unhollowedにあるdllを参照に追加する。Private属性を付けておくとビルド先が汚れない。他人がビルドできるようにLibなどのディレクトリをつくり相対パスにしておくとよい。
あるいはゲームのルートを環境変数で指定させるようにする

<ItemGroup>
    <Reference Include="Assembly-CSharp" Private="False">
        <HintPath>$(GAMENAME_ROOT)\BepInEx\unhollowed\Assembly-CSharp.dll</HintPath>
    </Reference>
</ItemGroup>
eaieai

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;
    }
}
eaieai

Windowの中のGUI.Button, GUILayout.Buttonが動かない

もはや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();
    }
}

そんなことあるかよ

eaieai

自作のCoroutineがStartCoroutineで実行できない

System.Collections.Generic.IEnumeratorを返すメソッドをコルーチンとしてMonoBehaviour.StartCoroutineの引数に入れて呼ぼうとするとIl2CppSystem.Collections.Generic.IEnumeratorにしろって怒られる

これはBepInEx.Unity.IL2CPP.Utils.Collectionsをインポートして、coroutine.WrapToIl2Cpp()を呼ぶといい感じに変換できて渡せる

参考: https://github.com/BepInEx/BepInEx/blob/46e297f210c27e9b4d26e7cd326625b53c83faa0/Runtimes/Unity/BepInEx.Unity.IL2CPP/Utils/MonoBehaviourExtensions.cs

トイトイ

MonoBehaviour の使い方

IL2CPP 環境での MonoBehaviour の使い方についてわかったことをまとめます。

前提

  • 対象のゲーム: Unity 2021.3.14f1
  • BepInEx 6.0.0-be.674
  • BepInEx.PluginInfoProps 2.1.0

GameObject に AddComponent する方法

GameObjectAddComponent する前に ClassInjector.RegisterTypeInIl2Cpp<T>() を呼び出す必要があります。

以下は静的コンストラクターを使い自動で呼び出す実装例です。

MyMonoBehaviour.cs
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!");
        }
    }
}
Plugin.cs
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>() の呼び出しは不要です。

Plugin.cs
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 ほにゃらら系の実行時エラーになる)。
トイトイ

イベントの登録の仕方

IL2CPP 環境でのイベントの登録の仕方についてわかったことを SceneManager.sceneLoaded を事例としてまとめます。

通常の Unity 環境では、以下のように書いていると思います。

MyMonoBehaviour.cs
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 環境では上記はコンパイルエラーになります。試行錯誤した結果、以下の通り書けることがわかりました。

MyMonoBehaviour.cs
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 の UnityActiondelegate です。
  • IL2CPP の UnityActionIl2CppSystem.MulticastDelegate を継承したクラスです。
    • UnityActionAction からの暗黙的変換を持っています。
トイトイ

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.LoadAssetUnity.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);
}