7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

KLab EngineerAdvent Calendar 2021

Day 18

Unity Editor で Harmony を使って再生中にコンパイルなしでメソッドの動作を書き換えてみる

Last updated at Posted at 2021-12-17

はじめに

いきなりですがタイトルの「コンパイルなしで」はやや嘘です。
正確には「Assembly-CSharp.dll や Assembly Definition により作成された各 DLL を再コンパイル、再生成することなく、書き換えたいメソッドに相当するコードのコンパイルのみで」です。書き換えたい部分のコンパイルだけは必要になります。

何をするの

という説明だけだとわかりづらいかと思うので、この記事で何をするのかというのだけもう少し具体的に先に示しておくと、たとえば Unity Editor 上で下記のようなプログラムを実行していた場合、

using UnityEngine;

public class SomeGameClass : MonoBehaviour
{
    public string Name { get; private set; }

    void Start()
    {
        Name = "Homu-Konamilk";
    }

    void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            DoSomething(Time.time);
        }
    }

    void DoSomething(float f)
    {
        Debug.Log($"called DoSomething({f})");
    }
}

この MonoBehaviour をゲームオブジェクトにアタッチして再生し、マウスのボタンを押すと下記のようなログが出てきますが、

called DoSomething(1.518882)

再生中に、これに対して下記のようなパッチプログラムを当てることで、

[PatchBody(typeof(SomeGameClass), "DoSomething")]
static void Patch(SomeGameClass instance, float f)
    => Debug.Log($"Rewritten! called DoSomething({f}) of {instance.Name}");

メソッドを書き換えて、その後はマウスのボタンを押すと下記のようなログが出てくるようにする……、

Rewritten! called DoSomething(12.831283) of Homu-Konamilk

というのがこのエントリでやってみることになります。
このパッチの適用は Unity Editor の再生中でも停止中でも可能です。

なお今回の方法で書き換えが可能なのはあくまでUnity Editor 上のみです1.

Harmony って何

タイトルにある Harmony というのは動的に.NETプログラムの挙動を書き換えられるライブラリです。本来はゲームの MOD 作成をサポートするために作られたもののようですが、今回はこれを Unity Editor 上で使用して、実行中のプログラムの動作を書き換えるのに使用します。
ちなみに Harmony は内部的には MonoMod を使用しているらしいです。

Harmony がどういったものか、例とともにもう少し具体的に説明すると、たとえば SomeGameClass.DomeSomething() というメソッドの挙動を変更したい場合は次のようなパッチプログラムを作成して……、

using HarmonyLib;
using UnityEngine;

class Patcher
{
    [HarmonyPrefix]
    [HarmonyPatch(typeof(SomeGameClass), "DoSomething")]
    static bool PatchToSomeGameClass_DoSomething()
    {
        Debug.Log("メソッドが乗っ取られたよ");
        return true;
    }
}

これをコンパイルして適用(適用方法の詳細は割愛)すると、SomeGameClass.DoSomething() メソッドの動作を書き換えられるというわけです。(ただ、もちろん、このパッチプログラムをcsファイルとして保存してUnityにコンパイルさせて……という方法は手間なので今回はとりません)

Roslyn for Scripting も使おう

ところでちょっとメソッドの動作を書き換えたいという場合に、先のパッチプログラムのようにいちいち using UnityEngine; とか class Patcher { ... } とか書くのは面倒ではないですか?
ということで、ここでは Roslyn for Scripting2 も活用していくことにしましょう。

通常のC#のソースファイルではトップレベルにいきなりメソッドを定義したり式やメソッド呼び出しを書いたりすることはできませんが、Roslyn for Scripting を使えばそれが可能になります。

// クラスの定義なしでいきなりメソッドを定義してみたり……
void Hoge()
{
    // ...
}

// それと同レベルのところでいきなりメソッド呼び出したり……
UnityEngine.Debug.Log("OK!");

またこのコードを評価する際に、あらかじめ using しておく namespace を指定しておくことができるので、あらかじめ指定しておいたものについては using を省略できます。

Unity だと Roslyn for Scripting は Unity Package Manager にある com.unity.code-analysis というパッケージを導入することで使用できます。
com.unity.code-analysis の導入については検索するといくらかページが出てきますし、こちらのエントリなどにもまとまっていたので参考にさせていただきましょう。

ということで作ってみた

ということで Harmony や Roslyn for Scripting などを活用して作ったものがこちらです。

こちらのファイルを Unity プロジェクト内に保存して、先の com.unity.code-analysis の導入記事の内容に従って Microsoft.CodeAnalysis.CSharp.Scripting.dll などの DLL への参照を設定した Assembly Definition を作成してください。
また同様に、Harmony の DLL を追加してこれも参照に追加してください。

すると冒頭で示したようなパッチプログラムを次のようにエディタウィンドウから適用できます。(画像が小さくて申し訳ない)

タイトルなし.gif

他にたとえば、このようにするとメソッドの実行前に特定の処理を行うようにできたり……、

// ↓ PatchBody ではなく PatchPrefix にする
[PatchPrefix(typeof(SomeGameClass), "DoSomething")]
static void PatchPrefix(SomeGameClass instance, float f)
    => Debug.Log($"before call of DoSomething({f}) of {instance.Name}");

また下記のように UnityEngine 内のメソッドにパッチを行うこともできます。
(ただしネイティブ実装のメソッドに対してはパッチを当てることはできません)

[PatchPrefix(typeof(Input), "GetKey")]
static void Patch_Input_GetKey(KeyCode keyCode)
    => Debug.Log($"before call of Input.GetKey({keyCode})");

当然、メソッド外で状態を保持して、それを使用して何か行う、ということも可能です。

static long counter = 0;

[PatchPrefix(typeof(SomeGameClass), "DoSomething")]
static void PatchPrefix(SomeGameClass instance, float f)
{
    counter++;
    Debug.Log($"counter: {counter}");
}

何の役に立つの?

何の役に立つの?というのは本来であれば最初の方に書くべきものかと思うのですが、この位置に書いているのは何の役に立つのかあんまりわからず作ったからです(???)

デバッグのときとか制作さんにUI調整してもらう際に使えるかな?と思って作ってみつつ、まだほとんど実用に回してないので何にどう役立つかはかなり未知数です。
パッチプログラムを書くのが面倒なので、少なくとも今の機能だけだと実用にはけっこう厳しいような気はしてます。ちょっと困ったバグが発生した時の調査に役に立ったりすることはあるでしょうか。
もし何かの役に立った話があったら教えてください。

実用する場合の注意点

今回は「とりあえず作ってみた」という感じなので、たとえばプロパティのgetter/setterには未対応ですし、また特に異常系の処理が雑です(パッチ対象のメソッドがネイティブメソッドだった場合や戻り値の型が一致しなかった場合にHarmony側からエラーが出るのですが、パッと見だとおそらく何のエラーかわかりません)。

また今回の実装ではパッチ内容を EditorPrefs に保存しておいて、アセンブリのリロードが行われる度にパッチを再適用するようになっていますが、たとえばこの状態でプログラムの変更があってパッチ対象のメソッドがなくなったりすると……パッチ対象のメソッドが存在しない状態になりますので、アセンブリのリロードが行われる度にパッチを適用しようとしてエラーが発生する状態になります。

さらに、これはなかなか避けがたい話なのですが、再生したまま Apply を繰り返すと生成された Assembly が溜まり続けてメモリ使用量が増大していくという問題もあります。

その他にもいろいろと問題があるかと思いますので、もし実用する場合はそういった点の対応をある程度行った上で使用することをおすすめします。

おわりに?

プログラムの動的書き換えはなんかいろいろ活用の余地がありそうな気がしつつ、気がしつつもなんかいい活用があんまり思い浮かんでこないですね。なにか良い活用があれば教えて下さい。

  1. 今回は試してないですが「Script Backend」を「Mono」にしてビルドしたバイナリであれば Harmony は適用可能なはずです。また「Script Backend」を「IL2CPP」にしてビルドしたバイナリには Harmony は対応していませんが、InjectFixなどのライブラリを使えば可能らしいです(私は未検証)

  2. Roslyn for Scripting と書いたものの、この機能の正式名称がわからず、まあつまり Microsoft.CodeAnalysis.CSharp.Scripting です。Unity外であれば「C#スクリプト」と呼べばよいのでしょうけど、Unity上だとそもそも普通のC#ソースファイルが「C#スクリプト」と呼ばれていたりするので難しい。

7
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?