C#のMono.Cecil(自分が)入門
「神戸電子専門学校 ゲーム技研部 Advent Calendar 2023」8日目の記事です。
はじめに
チームでUnityを使ったゲーム開発をしているのですが、その中でMono.Cecil
というものに触れる機会があったので、使い方等を備忘録として残したいと思います。
Mono.Cecilとは
詳しいことはこちらの神記事に書かれているので、ここではざっくり説明しています。
超ざっくり言うと、生成された(ECMA準拠の)アセンブリ(*.dll)に対して、後からなんでも編集出来ちゃう(黒魔術)ライブラリです。
似たような(?)ライブラリとしてSource Generator(Roslyn)
がありますが、違う点としてこちらはソースコードを生成します。
実際に使ってみる
Mono.Cecil
を使うには、UPMからインポートする必要があります。
Windows
→Package 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)
を選択し、Inspector
のOpen
をクリックすればエディタで編集できるようになります。
{
"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/Scripts
にSourceILPPTest.cs
を作成します。
using UnityEngine;
public class SourceILPPTest : MonoBehaviour
{
private void Start()
{
}
}
中身のないStart関数を書いています。ここにMonoCecil
で処理を挿入していきます。
Editor/SourceILPP
にSourceILPP.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 誤字を修正
ヘルパーに関してはこのライブラリを参考にしました。
なんでもできる反面、やはりとてもデバッグしづらいです。もちろんBreakPointは付けられませんし、どこでエラーが出ているのかわからないときもありました。
Mono.Cecil
は関数の事で使うことがあったので、関数のことしか紹介しませんでしたが、結構色々な事ができるらしいので、(今後また使うことがあれば)勉強していきたいと思います。
Discussion