はじめに:UnityエンジンにおけるModdingの動作原理
Unityゲームエンジンは、マルチプレイヤーサバイバルゲームの『Valheim』や『Lethal Company』から、高度なシミュレーションゲームである『Cities: Skylines II』や『Dyson Sphere Program』に至るまで、現代のインディーおよびミドルクラスPCタイトルの膨大なシェアを占めています。独自のバイナリやBethesda製エンジンのプラグインレコード(esp/esm)に依存する従来のModdingとは異なり、Unity作品のModdingは.NETおよびC#のソフトウェアエコシステムと深く結びついています。
Unityゲームを改変する際、ディスク上のオリジナルリソースファイルを直接上書きすることは稀です。現代の主流な手法は、実行時におけるインメモリのアセンブリ動的書き換えとメソッドフック(In-Memory Method Hooking)です。このエコシステムの中核を成しているのが、オープンソースのプラグインブートストラップフレームワークであるBepInEx(Bepis Injector Extensible)と、Andreas Pardeike氏によって開発された動的パッチライブラリHarmonyです。
しかし、Mod製作者やプレイヤーは、異なるゲーム間でModを導入しようとする際に、起動直後のクラッシュやフックの不発といった互換性トラブルに直面しがちです。その根本原因は、Unityが採用するコンパイル方式、すなわち従来のMonoバックエンドと先行コンパイルされたIL2CPP(Intermediate Language to C++)バックエンドという決定的なアーキテクチャの断絶にあります。
スクリプティングバックエンドの分岐:MonoとIL2CPPの内部構造
UnityプロジェクトをビルドしてPC向け実行ファイルを出力する際、開発者は2つの根本的に異なるスクリプティングバックエンドから一方を選択します:
【C#ソースコード:ゲームのメインロジック】
│
▼
【Roslyn / Unity C#コンパイラ】 ---> 共通中間言語 (CIL バイトコード)
│
├─────────────────────────────────────────┐
▼ ▼
【Mono バックエンドの経路】 【IL2CPP バックエンドの経路】
- CILをマネージドアセンブリ(DLL)として保持 - IL2CPPがCILをC++コードへと全量トランスパイル
- Assembly-CSharp.dll 等をそのまま出力 - ネイティブコンパイラが GameAssembly.dll を出力
- 実行時にMono JIT仮想マシンがバイトコード実行 - メタデータを別個の global-metadata.dat に分離
- 完全なリフレクション情報、逆コンパイルが容易 - 純粋な機械語ネイティブバイナリ、CLR仮想マシン不在
Monoスクリプティングバックエンド
Monoモードでビルドされたゲームでは、ゲームプレイロジックやAIスクリプトなどのすべてが、標準的な.NET中間言語(CIL)としてコンパイルされ、Managed/Assembly-CSharp.dllなどのDLLに格納されます。
- 実行時仮想マシン:ゲーム起動時、ゲームに同梱されたMono JIT(Just-In-Time)仮想マシンがCILバイトコードを逐次機械語へ変換・実行します。
- 完全なメタデータの維持:クラス名、メソッドシグネチャ、プライベートフィールド、データ型定義のすべてがアセンブリのメタデータ内に完全に保持されます。
- Mod開発の容易性:dnSpyやILSpyなどの逆コンパイルツールを用いれば、オリジナルのC#コードをほぼ100%の可読性で閲覧・解析でき、リフレクションを通じて任意のメソッドを容易にフックできます。
IL2CPPスクリプティングバックエンド
一方、IL2CPPでビルドされたタイトルでは、ビルドパイプラインが根本から異なります:
- C++へのトランスパイル:生成されたCILバイトコードは、Unity独自のIL2CPPツールチェーンによって数万行におよぶネイティブC++ソースコードへと変換されます。
- ネイティブ機械語への直コンパイル:変換されたC++コードは、プラットフォームネイティブのC++コンパイラによって機械語バイナリである
GameAssembly.dllへと直接コンパイルされます。 - メタデータの剥離と隠蔽:実行時の共通言語ランタイム(CLR)は完全に排除されます。型情報や文字列定義は、別ファイルである
global-metadata.datへと抽出・暗号化されます。 - Moddingへの影響:メモリ上に存在するのは純粋なCPU機械語命令のみであり、直接フックできる.NETアセンブリが存在しません。通常のC# Mod DLLをそのまま配置しても、それを実行する仮想マシン環境が存在しないため完全に動作しません。
BepInExブートストラップの仕組みと動的プロキシ注入
保護されたネイティブなUnityプロセス内に外部Modコードを読み込ませるため、BepInExはWindowsの**DLLプロキシハイジャック(DLL Proxy Hijacking)**を活用しています。
Doorstopによるプロキシ読み込みチェーン
Windows OSは実行ファイルが必要とする動的リンクライブラリ(DLL)を検索する際、システムディレクトリ(C:\Windows\System32)よりもアプリケーション自身の直下ディレクトリを最優先で探索します:
- 偽装プロキシDLLの配置:BepInExはゲームの実行ファイルと同じフォルダに、OS標準ライブラリと同名の偽装DLL(
winhttp.dll、version.dll、あるいはdoorstop.dll)を配置します。 - メインスレッドの乗っ取り:OSローダーがゲームを起動する際、正規のシステムライブラリではなく、ローカルの偽装
winhttp.dllが優先的にメモリへロードされます。 - ランタイムの起動:偽装DLLはUnity Doorstopを呼び出し、Unityエンジンのメイン初期化処理が開始される前に、
BepInEx.Preloader.dllおよびBepInEx.Core.dllをプロセスメモリ空間へと割り込ませます。 - プラグインの探索と実行:ランタイム環境の構築後、BepInExはデバッグコンソールを起動し、ログ収集パイプラインを初期化して、
BepInEx/plugins/フォルダ内のコンパイル済みModアセンブリを一括ロードします。
BepInEx 5とBepInEx 6:IL2CPP Interopの架け橋
- BepInEx 5:Monoゲーム向けの定番安定ブランチです。
BaseUnityPluginを継承したModが、ゲーム内蔵のMonoランタイム上で直接稼働します。 - BepInEx 6:MonoとIL2CPPの双方に対応すべく再設計された新世代ブランチです。IL2CPPゲーム向けにはIl2CppInterop(旧Unhollower)を統合しています。初回起動時に
GameAssembly.dllとglobal-metadata.datを自動解析し、BepInEx/interop/内に数千個の仮想プロキシC#アセンブリを動的生成します。これにより、Mod製作者はネイティブC++メモリ空間を意識することなく、通常のオブジェクト指向C#コードでModを記述できます。
Harmony動的パッチ:非破壊的インメモリフックの技術
BepInExによってメモリ上に展開されたModは、ゲーム内部の関数を改変するためにHarmonyライブラリを使用します。Harmonyはディスク上のバイナリを一切書き換えず、JITコンパイル時にメモリ上のメソッドプロローグ(先頭命令)を直接書き換えることで処理をインターセプトします。
4つの主要パッチタイプ
【ゲーム本来のメソッド呼び出し:例 PlayerController.TakeDamage(float amount)】
│
▼
【Harmony Prefix パッチ】 ---> true(本来の処理を続行)または false(本来の処理をスキップ)
│
▼
【ゲーム本来のメソッド実行:HP減算処理】
│
▼
【Harmony Postfix パッチ】 ---> 戻り値 (__result) の取得・改変やステータス更新
│
▼
【Harmony Finalizer パッチ】 ---> 未処理の例外を捕捉しゲームクラッシュを防止
- Prefixパッチ(
[HarmonyPrefix]):- 本来のメソッドが実行される直前に割り込みます。
- 引数の検証や書き換えが可能です(例:ダメージ適用前に引数
amountを半減させる)。 - 実行の短絡(Short-circuit):Prefixが
falseを返した場合、本来のメソッド処理を完全にスキップして即座に関数を終了させることができます。
- Postfixパッチ(
[HarmonyPostfix]):- 本来のメソッドが完了した直後に実行されます。
ref __resultパラメータを通じて、本来のメソッドの戻り値を読み取ったり、別の値に書き換えたりできます。- 副作用イベントのトリガー(例:アイテム作成完了時に実績を付与する)に最適です。
- Transpilerパッチ(
[HarmonyTranspiler]):- 最も高度なパッチタイプです。対象メソッドの生のCILバイトコードを
IEnumerableのCodeInstructionシーケンスとして直接操作します。 - JITコンパイル前のバイトコードレベルで、特定の命令(OpCode)をピンポイントで挿入・削除・置換できます。
- 最も高度なパッチタイプです。対象メソッドの生のCILバイトコードを
- Finalizerパッチ(
[HarmonyFinalizer]):- メソッド全体を包む動的な
try/finallyブロックとして機能します。予期せぬ例外エラーをトラップして抑制し、Modのエラーによるゲームの強制終了(デスクトップクラッシュ)を防止します。
- メソッド全体を包む動的な
トラブルシューティングマトリクス:Unity Modの不具合診断
Modが正常に読み込まれない、あるいはクラッシュが発生した場合は、以下の診断基準に従って原因を特定してください:
| 不具合の症状 | アーキテクチャ上の根本原因 | 診断指標 | 対処手順 |
|---|---|---|---|
| コンソールが出ない/Modが無視される | プロキシDLLがOSローダーによって読み込まれていない | BepInEx/LogOutput.logが生成されない | フォルダ直下にwinhttp.dllがあるか確認;アクセス権限を点検 |
| 起動直後に即時クラッシュ (IL2CPP) | IL2CPP製ゲームに誤ってMono用のBepInEx 5を導入した | フォルダ直下にGameAssembly.dllが存在する | BepInEx/を全削除し、BepInEx 6 x64 IL2CPP専用ビルドを導入 |
| ゲームのアプデ後に全Modがエラー | ゲーム本体の更新によりInteropプロキシキャッシュが不一致 | ログにTypeLoadExceptionやメソッド不在エラーが多発 | BepInEx/interop/フォルダを削除してプロキシを再生成させる |
| 特定Modの機能が無効化される | 複数のModが同一メソッドにPrefixを当ててfalseを返した | Mod Aは動くがMod Bが沈黙する;ログ上でパッチ競合 | Modの読み込み順序を変更するか、非破壊的なTranspiler版へ移行 |
| アンチチートによる起動遮断 | EACやBattlEyeがプロキシDLLインジェクションを不正検知 | 「Untrusted system file」エラーやプロセスの即時キル | 公式オフライン起動オプションを利用するかMod用起動フラグを付与 |
Mod環境を構築したり、アセンブリ競合を解決する際、365Findsが提供する詳細なアーキテクチャ解説を参照することで、表面的な試行錯誤を避け、内部ランタイムの仕組みに基づいた確実なトラブルシューティングが可能になります。MonoとIL2CPPの実行モデルの違いを正しく理解し、安全なメモリパッチ手法を適用することが、UnityゲームのMod環境を長期にわたり安定稼働させるための不可欠な基礎知識です。