🎮

C#のMono.Cecil(自分が)入門

2023/12/08に公開

「神戸電子専門学校 ゲーム技研部 Advent Calendar 2023」8日目の記事です。

https://qiita.com/advent-calendar/2023/kdgamegiken

はじめに

チームでUnityを使ったゲーム開発をしているのですが、その中でMono.Cecilというものに触れる機会があったので、使い方等を備忘録として残したいと思います。

Mono.Cecilとは

https://qiita.com/pCYSl5EDgo/items/4146989d08e169dde81d

詳しいことはこちらの神記事に書かれているので、ここではざっくり説明しています。

https://github.com/jbevain/cecil

超ざっくり言うと、生成された(ECMA準拠の)アセンブリ(*.dll)に対して、後からなんでも編集出来ちゃう(黒魔術)ライブラリです。

似たような(?)ライブラリとしてSource Generator(Roslyn)がありますが、違う点としてこちらはソースコードを生成します。

実際に使ってみる

Mono.Cecilを使うには、UPMからインポートする必要があります。

WindowsPackage Manager+Add package from git URL...com.unity.nuget.mono-cecilを入力してインポートします。

そして使い始めるのに、いろいろ準備が必要です。Editor/SourceILPPの中に作っていきます。

まず、ILPostProcessorを使うためにAssembly Definitionを作成します。ここで注意なのですが、名前はUnityで始まり、[.CodeGen .Compiler .CodeGen.Tests .Compiler.Tests]のいずれかで終わる必要があります。今回はUnity.SourceILPP.CodeGenという名前にしました。設定ですが、GUIで操作するのは面倒くさいので直接書きます。
Unity.SourceILPP.CodeGen(asmdef)を選択し、InspectorOpenをクリックすればエディタで編集できるようになります。

{
    "name": "Unity.SourceILPP.CodeGen",
    "rootNamespace": "",
    "references": [],
    "includePlatforms": [
        "Editor"
    ],
    "excludePlatforms": [],
    "allowUnsafeCode": false,
    "overrideReferences": true,
    "precompiledReferences": [
        "Mono.Cecil.dll",
        "Mono.Cecil.Mdb.dll",
        "Mono.Cecil.Pdb.dll",
        "Mono.Cecil.Rocks.dll"
    ],
    "autoReferenced": false,
    "defineConstraints": [],
    "versionDefines": [
        {
            "name": "com.unity.nuget.mono-cecil",
            "expression": "(0,1.11.4)",
            "define": "CECIL_CONSTRAINTS_ARE_TYPE_REFERENCES"
        }
    ],
    "noEngineReferences": false
}

このように書きました。
次にヘルパーを書くために、CodeGenHelpers.csを作成します。

using System.IO;
using System.Linq;
using System.Reflection;
using Mono.Cecil;
using Mono.Cecil.Cil;
using Unity.CompilationPipeline.Common.ILPostProcessing;

internal static class CodeGenHelpers
{
    public static AssemblyDefinition AssemblyDefinitionFor(ICompiledAssembly compiledAssembly, out PostProcessorAssemblyResolver assemblyResolver)
    {
        assemblyResolver = new PostProcessorAssemblyResolver(compiledAssembly);
        var readerParameters = new ReaderParameters
        {
            SymbolStream = new MemoryStream(compiledAssembly.InMemoryAssembly.PdbData),
            SymbolReaderProvider = new PortablePdbReaderProvider(),
            AssemblyResolver = assemblyResolver,
            ReflectionImporterProvider = new PostProcessorReflectionImporterProvider(),
            ReadingMode = ReadingMode.Immediate
        };

        var assemblyDefinition = AssemblyDefinition.ReadAssembly(new MemoryStream(compiledAssembly.InMemoryAssembly.PeData), readerParameters);

        //apparently, it will happen that when we ask to resolve a type that lives inside Unity.Netcode.Runtime, and we
        //are also postprocessing Unity.Netcode.Runtime, type resolving will fail, because we do not actually try to resolve
        //inside the assembly we are processing. Let's make sure we do that, so that we can use postprocessor features inside
        //Unity.Netcode.Runtime itself as well.
        assemblyResolver.AddAssemblyDefinitionBeingOperatedOn(assemblyDefinition);

        return assemblyDefinition;
    }

    private class PostProcessorReflectionImporterProvider : IReflectionImporterProvider
    {
        public IReflectionImporter GetReflectionImporter(ModuleDefinition moduleDefinition)
        {
            return new PostProcessorReflectionImporter(moduleDefinition);
        }
    }
    private class PostProcessorReflectionImporter : DefaultReflectionImporter
    {
        private const string k_SystemPrivateCoreLib = "System.Private.CoreLib";
        private readonly AssemblyNameReference m_CorrectCorlib;

        public PostProcessorReflectionImporter(ModuleDefinition module) : base(module)
        {
            m_CorrectCorlib = module.AssemblyReferences.FirstOrDefault(a => a.Name == "mscorlib" || a.Name == "netstandard" || a.Name == k_SystemPrivateCoreLib);
        }

        public override AssemblyNameReference ImportReference(AssemblyName reference)
        {
            return m_CorrectCorlib != null && reference.Name == k_SystemPrivateCoreLib ? m_CorrectCorlib : base.ImportReference(reference);
        }
    }
}

アセンブリを解決するヘルパーを作成します。PostProcessAssemblyResolver.csを作成します。

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using Mono.Cecil;
using Unity.CompilationPipeline.Common.ILPostProcessing;

public class PostProcessorAssemblyResolver : IAssemblyResolver
{
    private readonly string[] m_AssemblyReferences;
    private readonly Dictionary<string, AssemblyDefinition> m_AssemblyCache = new Dictionary<string, AssemblyDefinition>();
    private readonly ICompiledAssembly m_CompiledAssembly;
    private AssemblyDefinition m_SelfAssembly;

    public PostProcessorAssemblyResolver(ICompiledAssembly compiledAssembly)
    {
        m_CompiledAssembly = compiledAssembly;
        m_AssemblyReferences = compiledAssembly.References;
    }

    public void Dispose() { }

    public AssemblyDefinition Resolve(AssemblyNameReference name)
        => Resolve(name, new ReaderParameters(ReadingMode.Deferred));

    public AssemblyDefinition Resolve(AssemblyNameReference name, ReaderParameters parameters)
    {
        lock (m_AssemblyCache)
        {
            if (name.Name == m_CompiledAssembly.Name)
            {
                return m_SelfAssembly;
            }

            var fileName = FindFile(name);
            if (fileName == null)
            {
                return null;
            }

            var lastWriteTime = File.GetLastWriteTime(fileName);
            var cacheKey = $"{fileName}{lastWriteTime}";
            if (m_AssemblyCache.TryGetValue(cacheKey, out var result))
            {
                return result;
            }

            parameters.AssemblyResolver = this;

            var ms = MemoryStreamFor(fileName);
            var pdb = $"{fileName}.pdb";
            if (File.Exists(pdb))
            {
                parameters.SymbolStream = MemoryStreamFor(pdb);
            }

            var assemblyDefinition = AssemblyDefinition.ReadAssembly(ms, parameters);
            m_AssemblyCache.Add(cacheKey, assemblyDefinition);

            return assemblyDefinition;
        }
    }

    private string FindFile(AssemblyNameReference name)
    {
        var fileName = m_AssemblyReferences.FirstOrDefault(r => Path.GetFileName(r) == $"{name.Name}.dll");
        if (fileName != null)
        {
            return fileName;
        }

        // perhaps the type comes from an exe instead
        fileName = m_AssemblyReferences.FirstOrDefault(r => Path.GetFileName(r) == $"{name.Name}.exe");
        if (fileName != null)
        {
            return fileName;
        }

        //Unfortunately the current ICompiledAssembly API only provides direct references.
        //It is very much possible that a postprocessor ends up investigating a type in a directly
        //referenced assembly, that contains a field that is not in a directly referenced assembly.
        //if we don't do anything special for that situation, it will fail to resolve.  We should fix this
        //in the ILPostProcessing API. As a workaround, we rely on the fact here that the indirect references
        //are always located next to direct references, so we search in all directories of direct references we
        //got passed, and if we find the file in there, we resolve to it.
        return m_AssemblyReferences
            .Select(Path.GetDirectoryName)
            .Distinct()
            .Select(parentDir => Path.Combine(parentDir, $"{name.Name}.dll"))
            .FirstOrDefault(File.Exists);
    }

    private static MemoryStream MemoryStreamFor(string fileName)
    {
        return Retry(10, TimeSpan.FromSeconds(1), () =>
        {
            byte[] byteArray;
            using var fileStream = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
            byteArray = new byte[fileStream.Length];
            var readLength = fileStream.Read(byteArray, 0, (int)fileStream.Length);
            if (readLength != fileStream.Length)
            {
                throw new InvalidOperationException("File read length is not full length of file.");
            }

            return new MemoryStream(byteArray);
        });
    }

    private static MemoryStream Retry(int retryCount, TimeSpan waitTime, Func<MemoryStream> func)
    {
        try
        {
            return func();
        }
        catch (IOException)
        {
            if (retryCount == 0)
            {
                throw;
            }

            Console.WriteLine($"Caught IO Exception, trying {retryCount} more times");
            Thread.Sleep(waitTime);

            return Retry(retryCount - 1, waitTime, func);
        }
    }

    public void AddAssemblyDefinitionBeingOperatedOn(AssemblyDefinition assemblyDefinition)
    {
        m_SelfAssembly = assemblyDefinition;
    }
}

これで準備は以上です。

関数の中身を変更する

Assets/ScriptsSourceILPPTest.csを作成します。

using UnityEngine;

public class SourceILPPTest : MonoBehaviour
{
    private void Start()
    {

    }
}

中身のないStart関数を書いています。ここにMonoCecilで処理を挿入していきます。
Editor/SourceILPPSourceILPP.csを作成します。

using Mono.Cecil;
using Mono.Cecil.Cil;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Unity.CompilationPipeline.Common.Diagnostics;
using Unity.CompilationPipeline.Common.ILPostProcessing;

public class SourceILPP : ILPostProcessor
{
    private readonly List<DiagnosticMessage> m_Diagnostics = new();

    public override ILPostProcessor GetInstance() => this;

    public override bool WillProcess(ICompiledAssembly compiledAssembly)
        => compiledAssembly.Name == "Assembly-CSharp";

    public override ILPostProcessResult Process(ICompiledAssembly compiledAssembly)
    {
        if (WillProcess(compiledAssembly) == false)
        {
            return null;
        }

        var assemblyDefinition = CodeGenHelpers.AssemblyDefinitionFor(compiledAssembly, out var assemblyResolver);
        if (assemblyDefinition != null)
        {
            try
            {
                assemblyDefinition.MainModule.GetTypes()
                    .Where(t => t.Name == "SourceILPPTest")
                    .ToList()
                    .ForEach(t => SourceILPPTest(assemblyDefinition, t));
            }
            catch(Exception e)
            {
                m_Diagnostics.Add(new DiagnosticMessage
                {
                    DiagnosticType = DiagnosticType.Error,
                    MessageData = e.Message
                });
            }
        }

        var pe = new MemoryStream();
        var pdb = new MemoryStream();

        var writerParameters = new WriterParameters
        {
            SymbolWriterProvider = new PortablePdbWriterProvider(),
            SymbolStream = pdb,
            WriteSymbols = true
        };

        assemblyDefinition?.Write(pe, writerParameters);

        return new ILPostProcessResult(new InMemoryAssembly(pe.ToArray(), pdb.ToArray()), m_Diagnostics);
    }

    private void SourceILPPTest(AssemblyDefinition assemblyDefinition, TypeDefinition typeDefinition)
    {
        // Get "Start()"
        var startMethod = typeDefinition.Methods.FirstOrDefault(m => m.Name == "Start");
        if (startMethod != null)
        {
            var instructions = new List<Instruction>();
            var processor = startMethod.Body.GetILProcessor();
            // Debug.Log("Hello ILPP");
            instructions.Add(processor.Create(OpCodes.Ldstr, "Hello ILPP"));
            instructions.Add(processor.Create(OpCodes.Call, assemblyDefinition.MainModule.ImportReference(typeof(UnityEngine.Debug).GetMethod("Log", new[] { typeof(string) }))));
            instructions.Reverse();
            instructions.ForEach(instruction => processor.Body.Instructions.Insert(0, instruction));
        }
    }
}

こちらもコード量が多いですが、実際に書き換えの部分を実装している部分はSourceILPPTest()になります。

適当にSceneに配置して実行してみます。

ログが表示されました。

ILSpyの方でも確認してみると、処理が挿入されているのが確認できます。ちなみにUnityのアセンブリのパスは./Library/ScriptAssemblies/Assembly-CSharp.dllです。

関数を追加する

もちろん関数の追加もできます。今回はShowMessage()という関数を作成し[ContextMenu("Execute")]という属性を付けてInspectorから実行できるようにしました。また、Start()でも呼び出すようにしています。

private void SourceILPPTest(AssemblyDefinition assemblyDefinition, TypeDefinition typeDefinition)
{
    // Create "ShowMessage()"
    var showMessageMethod = new MethodDefinition(
        "ShowMessage",
        MethodAttributes.Public | MethodAttributes.HideBySig,
        assemblyDefinition.MainModule.ImportReference(typeof(void)));
    {
        // Add "[ContextMenu("Execute")]"
        var contextMenuAttribute = new CustomAttribute(
            assemblyDefinition.MainModule.ImportReference(typeof(UnityEngine.ContextMenu).GetConstructor(new[] { typeof(string) })));
        contextMenuAttribute.ConstructorArguments.Add(
            new CustomAttributeArgument(assemblyDefinition.MainModule.ImportReference(typeof(string)), "Execute"));
        showMessageMethod.CustomAttributes.Add(contextMenuAttribute);

        // Body
        var instructions = new List<Instruction>();
        var processor = showMessageMethod.Body.GetILProcessor();
        instructions.Add(processor.Create(OpCodes.Ldstr, "Hello ILPP"));
        instructions.Add(processor.Create(OpCodes.Call, assemblyDefinition.MainModule.ImportReference(typeof(UnityEngine.Debug).GetMethod("Log", new[] { typeof(string) }))));
        instructions.Add(processor.Create(OpCodes.Ret));

        instructions.Reverse();
        instructions.ForEach(instruction => processor.Body.Instructions.Insert(0, instruction));

        // Add "ShowMessage()" to type
        typeDefinition.Methods.Add(showMessageMethod);
    }

    // Get "Start()" and add "ShowMessage()" call
    var startMethod = typeDefinition.Methods.FirstOrDefault(m => m.Name == "Start");
    if (startMethod != null)
    {
        var instructions = new List<Instruction>();
        var processor = startMethod.Body.GetILProcessor();
        instructions.Add(processor.Create(OpCodes.Ldarg_0));
        instructions.Add(processor.Create(OpCodes.Call, showMessageMethod));
        instructions.Add(processor.Create(OpCodes.Nop));
        instructions.Add(processor.Create(OpCodes.Ret));

        instructions.Reverse();
        instructions.ForEach(instruction => processor.Body.Instructions.Insert(0, instruction));
    }
}

これで実行するとHello ILPPと表示されます。

また、Inspectorから実行することも出来ます。

ILSpyでも確認できました。

関数の中身を削除する

関数の中身を完全に削除することもできます。

using UnityEngine;

public class SourceILPPTest : MonoBehaviour
{
    private void Start()
    {
        try
        {
            Debug.Log("Hello from SourceILPPTest!");
        }
        catch (System.Exception e)
        {
            Debug.LogError(e);
        }
    }
}

適当な処理を書きました。tryを書いた理由は後ほど説明します。

private void SourceILPPTest(AssemblyDefinition assemblyDefinition, TypeDefinition typeDefinition)
{
    // Get "Start()" and remove all instructions
    var startMethod = typeDefinition.Methods.FirstOrDefault(m => m.Name == "Start");
    if (startMethod != null)
    {
        var processor = startMethod.Body.GetILProcessor();

        processor.Body.ExceptionHandlers.Clear();
        processor.Body.Instructions.Clear();

        processor.Emit(OpCodes.Nop);
        processor.Emit(OpCodes.Ret);
    }
}

削除している部分はprocessor.Body.ExceptionHandlers.Clear();processor.Body.Instructions.Clear();になります。try文が含まれている場合、processor.Body.Instructions.Clear();のみを書いているとエラーになります。なので、processor.Body.ExceptionHandlers.Clear();も書いてあげる必要があります。

こちらもILSpyで確認できました。

おわりに

2023/12/08 誤字を修正

https://github.com/Unity-Technologies/com.unity.netcode.gameobjects/tree/develop/com.unity.netcode.gameobjects/Editor/CodeGen

ヘルパーに関してはこのライブラリを参考にしました。

なんでもできる反面、やはりとてもデバッグしづらいです。もちろんBreakPointは付けられませんし、どこでエラーが出ているのかわからないときもありました。
Mono.Cecilは関数の事で使うことがあったので、関数のことしか紹介しませんでしたが、結構色々な事ができるらしいので、(今後また使うことがあれば)勉強していきたいと思います。

神戸電子専門学校ゲーム技術研究部

Discussion