- 开发工具
【免费下载链接】Harmony
A library for patching, replacing and decorating .NET and Mono methods during runtime
导读:本文围绕 Harmony 补丁体系中 Transpiler(IL 级代码改写器)的操作核心
CodeInstruction,系统讲解其指令模型、操作数约束、局部变量与标签的使用方式、异常(try/catch)边界标记,以及常见陷阱与调试手段。结合仓库源码(Harmony/Public/CodeInstruction.cs、Harmony/Tools/Extensions.cs、Harmony/Internal/Emitter.cs)与官方示例,读者将掌握如何在目标方法 IL 中安全地插入、删除、替换指令,并让多个补丁在同一个方法上共存。文末提供一个可复制、可运行的完整 Transpiler 实战方案。
1. 从 Transpiler 到 CodeInstruction:为什么需要这样一层抽象
在 Harmony 的补丁体系中,Transpiler 是一种"后编译"阶段的改写器:它不在运行时被调用,而是在方法被打补丁时被 Harmony 执行一次,接收目标方法的 IL 指令列表,处理后返回一份修改后的列表,最终由 Harmony 重新Emit()出替换方法。
而CodeInstruction就是这个改写流程的"工作马"(workhorse)。按照官方文档的定义,它是围绕 .NETSystem.Reflection.Emit命名空间的一层抽象:
- 它封装了OpCode(操作码)与其operand(操作数);
- 它刻意隐藏了底层 Emit 中大量"具体而琐碎"的细节,目的是让多个同时修补同一方法的 Mod 可以共存。
正因如此,一些底层 Emit 允许的写法(例如"向前跳 4 条指令"这种基于索引/字节偏移的跳转)在 Harmony 中不被允许,取而代之的是一套更稳健的概念:Label、LocalBuilder、ExceptionBlock。
从当前仓库源码看,CodeInstruction的类定义位于 Harmony/Public/CodeInstruction.cs,其公开字段非常精简:
| 字段 | 类型(当前实现) | 含义 |
|---|---|---|
opcode | OpCode | 该指令的操作码,如Ldarg_0、Call、Ret |
operand | object | 操作数,可以是Type、FieldInfo、MethodInfo、ConstructorInfo、数值、字符串、Label、LocalBuilder等 |
labels | List<Label> | 定义在该指令上的全部标签(早期文档中描述为Label[],当前源码为List<Label>,见 CodeInstruction.cs) |
blocks | List<ExceptionBlock> | 定义在该指令上的异常块边界标记 |
注意:原文文档中把
labels/blocks描述为数组,当前仓库实现已改为List<Label>/List<ExceptionBlock>(CodeInstruction.cs)。使用方式完全一致,API 层面依然通过labels、blocks字段访问。
2. 操作数(Operand):与 Emit 一致的部分与受限的部分
文档明确指出,CodeInstruction的操作数与ILGenerator.Emit()的大多数参数保持一致的用法。你可以放心地使用以下类型作为操作数:
Type(类型元数据)FieldInfo(字段元数据)MethodInfo(方法元数据)ConstructorInfo(构造器元数据)- 数值与字符串:
Int64/Int32/Int16/Single/Double/String/Byte
但有一部分操作数受到限制,这是理解 Harmony 抽象边界的关键:
- 跳转的操作数不能是数值,必须使用
Label。这保证了当其他 Mod 在你之前或之后插入了指令后,你的跳转目标依然"指向原来的位置"而不是"指向原来的偏移"。这是多 Mod 共存机制的基石。 SignatureHelper的支持最多算实验性的,官方不建议依赖它。- 应避免用索引(下标)来引用局部变量。因为其他 Transpiler 完全可能在你的代码之前插入/删除局部变量相关的指令,索引值会失效。
2.1 绝对不要直接调用ILGenerator.Emit()
文档中有一句被加粗强调的禁令:
Do not use
ILGenerator.Emit()。
虽然ILGenerator.Emit()也能生成 IL 代码,但 Harmony 本身就是建立在 Emit 之上的一层抽象:Harmony 会接收你返回的IEnumerable<CodeInstruction>,经过内部处理后由它自己调用Emit()来生成替换方法。如果你在 Transpiler 中擅自使用Emit(),就会绕过 Harmony 的指令管理流程,导致指令状态混乱。
ILGenerator在 Transpiler 中的唯一合法用途,是调用其辅助方法:
DefineLabel():创建新的跳转标签;DeclareLocal():声明新的局部变量(得到LocalBuilder)。
这两者分别对应下文 3、4 两节。
2.2 官方建议:复用与复制现有操作数
对于标签和局部变量,官方文档给出的通用策略是:
复用并复制已有指令上的操作数。在现有代码中寻找一个具有显著特征的、独一无二的位置,从那里"抓取"操作数。
这样做的价值在于抗变更(change-resistant):当目标方法的 IL 被编译器版本或游戏更新改动后,你引用的Label/LocalBuilder依然随指令整体移动,而不是基于易失效的偏移量或索引。
3. 局部变量:数字索引与LocalBuilder两种形态
你收到的指令列表中,既可能包含以数字索引引用的局部变量(例如Ldloc_2、Stloc.2),也可能包含以LocalBuilder对象引用的。Harmony不会改动原始指令的 operand,因此你的 Transpiler 必须对这两种形态都做好准备:
- 创建新局部变量:调用注入进来的
ILGenerator.DeclareLocal(Type)获得LocalBuilder; - 复用已有局部变量:直接复制某条现有指令的 operand。
当前仓库为这个场景提供了配套的静态工厂方法(CodeInstruction.cs):
// 加载/存储局部变量:自动选择最短指令形式(Ldloc_0/1/2/3、Ldloc_S、Ldloc 等) var load = CodeInstruction.LoadLocal(index, useAddress: false); var store = CodeInstruction.StoreLocal(index); // 加载/存储参数:同样自动选择 Ldarg_0/1/2/3、Ldarg_S、Ldarg、Starg_S 等 var loadArg = CodeInstruction.LoadArgument(index, useAddress: false); var storeArg = CodeInstruction.StoreArgument(index);此外还有配套的反向查询扩展方法LocalIndex()与ArgumentIndex()(见 Harmony/Tools/Extensions.cs),可以从一条ldloc/stloc指令反推其目标索引。当编译器把局部变量指令压成短形式(Ldloc_0这种无操作数的形式)时,用这些方法解析索引比手工 switch 更可靠。
4. 标签(Labels):跳转的正确打开方式
CIL 中的跳转全部通过Label完成。在 Harmony 里,Label出现在两个位置:
- 作为跳转指令的 operand(例如
Brtrue、Br、Ble_Un的 operand 是一个Label); - 作为目标指令的
labels字段内容——它标记"哪条指令是这个标签所指的位置"。
跳转的约定:创建跳转时,你指定跳转 OpCode 和Label作为 operand,然后把该Label追加到目标指令的labels列表中。
创建新标签:使用ILGenerator.DefineLabel()获得Label,将它放入目标指令的labels字段,再用这个Label作为跳转指令的 operand。
下面是一个"跳过一段代码"的典型用法(注入ILGenerator generator):
static IEnumerable<CodeInstruction> Transpiler(IEnumerable<CodeInstruction> instructions, ILGenerator generator) { var codes = new List<CodeInstruction>(instructions); // 找到某个位置,在其后插入一个无条件跳转和一个新标签 var jumpLabel = generator.DefineLabel(); codes.Insert(0, new CodeInstruction(OpCodes.Br, jumpLabel)); // 跳到新标签处 codes[1].labels.Add(jumpLabel); // 标签落在原第一条指令上 return codes.AsEnumerable(); }为了不让标签处理变得繁琐,Harmony 在 Harmony/Tools/Extensions.cs 提供了一组标签工具扩展:
| 扩展方法 | 作用 |
|---|---|
WithLabels(params Label[] labels) | 给指令追加标签并返回自身,便于链式调用 |
ExtractLabels() | 取出指令上的全部标签并清空原列表 |
MoveLabelsTo(CodeInstruction other)/MoveLabelsFrom(other) | 把标签从一条指令整体移动到另一条 |
这些方法尤其用于第 6 节要讲的"插入指令导致标签错位"的常见陷阱。
5. Try/catch 边界:blocks字段与ExceptionBlock
用指令构造方法体时,异常处理结构的边界必须被显式声明。Harmony 会根据你标记的边界自动生成对应的元信息,你只需要把不同类型的边界标记放进指令的blocks字段即可。
blocks的类型为ExceptionBlock[](当前实现为List<ExceptionBlock>),元素是 Harmony/Public/ExceptionBlock.cs 中定义的:
public class ExceptionBlock(ExceptionBlockType blockType, Type catchType = null) { public ExceptionBlockType blockType; public Type catchType = catchType ?? typeof(object); }其中ExceptionBlockType枚举包括:
| 枚举值 | 语义 |
|---|---|
BeginExceptionBlock | try 块开始 |
BeginCatchBlock | catch 块开始(catchType指明捕获的异常类型,默认object) |
BeginExceptFilterBlock | 异常过滤器块开始 |
BeginFaultBlock | fault 块开始(无论是否异常都执行,但拿不到异常对象) |
BeginFinallyBlock | finally 块开始 |
EndExceptionBlock | 异常块整体结束 |
重点限制:官方文档与源码注释(ExceptionBlock.cs)都明确指出——filter 块不受支持,因为当前无法在动态生成的方法中构造它。在枚举中保留BeginExceptFilterBlock更多是为了枚举完整性,实际打补丁时不要使用它。
从底层实现看,Harmony/Internal/Emitter.cs 的MarkBlockBefore()/MarkBlockAfter()会把每个边界类型映射到ILGenerator的对应方法:
BeginExceptionBlock→il.BeginExceptionBlock()BeginCatchBlock→il.BeginCatchBlock(block.catchType)BeginFaultBlock→il.BeginFaultBlock()BeginFinallyBlock→il.BeginFinallyBlock()EndExceptionBlock→il.EndExceptionBlock()
下面是在注入的代码外围包裹 try/catch 的示例,配合WithBlocks扩展(Extensions.cs):
var tryBlock = new ExceptionBlock(ExceptionBlockType.BeginExceptionBlock); var catchBlock = new ExceptionBlock(ExceptionBlockType.BeginCatchBlock, typeof(SomeException)); var endBlock = new ExceptionBlock(ExceptionBlockType.EndExceptionBlock); var start = new CodeInstruction(OpCodes.Ldarg_0) { blocks = [tryBlock] }; var end = new CodeInstruction(OpCodes.Ret) { blocks = [catchBlock, endBlock] };仓库测试 HarmonyTests/Patching/Transpiling.cs 中就有类似的实战:在注入的Ldarg_0指令上直接以对象初始化器{ blocks = blocks }挂载异常块,随后依次注入字段读取、字符串与Call指令,验证了带blocks的指令可以被正常重发(re-emit)。
6. 便捷方法:CodeInstructionExtensions 指令匹配工具箱
为了"创建、搜索、比较"指令,Harmony 在 Harmony/Tools/Extensions.cs 定义了CodeInstructionExtensions静态类,对CodeInstruction提供大量扩展方法。由于 operand 的类型是object,直接==比较既容易出错又不优雅,这些扩展方法正是为了解决这个痛点。完整清单见 docs/api/HarmonyLib.CodeInstructionExtensions.html,下面按用途分组说明:
6.1 操作数/指令比较
| 方法 | 用途 |
|---|---|
OperandIs(object value) | 判断 operand 是否与给定值相等(对整数/浮点数做跨类型数值比较,例如Ldc_I4_0与Ldc_I4, 0视为相等) |
Is(OpCode opcode, object operand) | 等价于opcode == op && operand 相等的快捷组合 |
IsValid(this OpCode) | 判断 OpCode 是否已初始化(Size > 0) |
6.2 参数/局部变量匹配(自动覆盖短形式)
| 方法 | 用途 |
|---|---|
IsLdarg(int? n = null) | 匹配任意形式的Ldarg*,可选校验索引 n |
IsLdarga(int? n = null)/IsStarg(int? n = null) | 匹配取地址加载 / 存储参数 |
IsLdloc(LocalBuilder variable = null) | 匹配任意形式的Ldloc*(含Ldloca*),可选校验变量 |
IsStloc(LocalBuilder variable = null) | 匹配任意形式的Stloc* |
LocalIndex()/ArgumentIndex() | 从指令反查索引 |
编译器经常会因为局部变量数量少而把Ldloc, 2优化成Ldloc_2,这些方法把同语义的短形式、长形式统一起来,是你写"抗变更"匹配逻辑的利器。
6.3 调用与字段匹配
| 方法 | 用途 |
|---|---|
Calls(MethodInfo method) | 判断指令是否为Call/Callvirt且调用指定方法 |
LoadsField(FieldInfo field, bool byAddress = false) | 匹配Ldfld/Ldsfld(或Ldflda/Ldsflda) |
StoresField(FieldInfo field) | 匹配Stfld/Stsfld |
Branches(out Label? label) | 判断是否为跳转指令,若是则输出其目标Label |
6.4 常量加载匹配
| 方法 | 用途 |
|---|---|
LoadsConstant() | 是否为任意常量加载指令 |
LoadsConstant(long number) | 是否加载指定整数常量(自动识别Ldc_I4_0~8、Ldc_I4_M1、Ldc_I4_S、Ldc_I4、Ldc_I8) |
LoadsConstant(double number) | 是否加载指定浮点常量(Ldc_R4/Ldc_R8) |
LoadsConstant(Enum e) | 是否加载指定枚举常量 |
LoadsConstant(string str) | 是否为Ldstr且字符串相等 |
6.5 官方典型示例:搜索 + 插入
文档引用的典型示例(完整代码见 Harmony/Documentation/examples/patching-transpiler.cs)演示了"查找Stfld someField,在其前插入一次Call MyExtraMethod"的经典模式:
static FieldInfo f_someField = AccessTools.Field(typeof(SomeType), nameof(SomeType.someField)); static MethodInfo m_MyExtraMethod = SymbolExtensions.GetMethodInfo(() => Tools.MyExtraMethod()); // looks for STDFLD someField and inserts CALL MyExtraMethod before it static IEnumerable<CodeInstruction> Transpiler(IEnumerable<CodeInstruction> instructions) { var found = false; foreach (var instruction in instructions) { if (instruction.StoresField(f_someField)) { yield return new CodeInstruction(OpCodes.Call, m_MyExtraMethod); found = true; } yield return instruction; } if (found is false) ReportError("Cannot find <Stdfld someField> in OriginalType.OriginalMethod"); }要点拆解:
- 用
AccessTools.Field/SymbolExtensions.GetMethodInfo(来自 Harmony/Tools/AccessTools.cs、Harmony/Tools/SymbolExtensions.cs)在编译期稳定地拿到元数据对象,而不是用字符串硬编码,可避免手写拼写错误; yield return流式遍历,天然保留所有原有指令的顺序;StoresField()一步完成 opcode 与 operand 的双重匹配;- 末尾对"没找到锚点"的情况显式报错——锚点缺失时宁可报错也不静默通过,这是 Transpiler 健壮性的重要习惯。
7. 常见陷阱(Pitfalls):labels 与 blocks 的"移花接木"
文档用较大篇幅警告了几类高频错误,这里结合源码逐一展开:
7.1 删除带标签/异常块的指令,却不同步处理配对
最常见的错误是:直接移除一条带有labels或blocks的指令,却不去处理对应的配对。结果会产生一个指向"不存在位置"的标签,或者残缺的异常边界,最终导致动态方法编译失败——错误信息类似于"instruction will want to jump to a label that is not assigned to any other instruction"。
7.2 复制指令时把 labels/blocks 一起复制
另一个常见错误是复制一条现有指令来生成新指令,从而把它的labels与blocks字段也一并复制过来。这会得到多个重复定义的标签/边界,同样是 CIL 校验不允许的。
注意:当前仓库的
CodeInstruction拷贝构造函数确实会复制 labels 与 blocks(CodeInstruction.cs),而Clone()则明确重置 labels 与 blocks 为空(CodeInstruction.cs)。所以官方默认建议是:需要一份"干净"指令时使用Clone()(或Clone(opcode)/Clone(operand)),它返回轻量副本,避免重复定义。
7.3 插入指令时的"顺移"规律
CIL 对未定义的标签和重复定义的标签都非常敏感。通常只要把labels、blocks字段的内容"跟着走"就能解决:
典型场景:在某处插入一条新指令,把旧指令向后推一个下标。此时旧指令上的 labels/blocks 会随它一起移动。解决方法是——把标签/边界从旧指令挪到新插入的指令上(如果新指令语义上才是那个"锚点位置"的话)。
这正是MoveLabelsTo/MoveLabelsFrom/MoveBlocksTo/MoveBlocksFrom四个扩展方法存在的意义:它们把"取出"与"追加"合并成一步,杜绝了漏清空旧列表的低级错误。
7.4 开启 Harmony 调试日志,让错误无处遁形
当遇到莫名其妙的编译错误时,打开 Harmony 的调试日志是最高效的定位手段:
- 在代码中设置
Harmony.DEBUG = true(该静态开关定义于 Harmony/Public/Harmony.cs); - 或者在补丁类上标注
[HarmonyDebug]特性(定义于 Harmony/Public/Attributes.cs)。
日志会输出你生成的全部指令、标签与异常块。从 Harmony/Internal/Emitter.cs 的MarkLabel()、MarkBlockBefore()实现可以看到,调试模式下每个Label(格式Label{hash})、每条指令、每个.try/.catch/.finally/.fault边界都会被写入FileLog——包括BeginCatchBlock这类由ILGenerator隐式插入Leave指令的边界也会被"模拟"记入日志。对照日志里的标签哈希值与指令布局,你能快速判断是哪个跳转悬空、哪对边界缺失。
8. 完整实战:结合示例仓库的 Caravan 方案
文档教程(详见 Harmony/Documentation/articles/patching-transpiler.md)以一个 Rimworld 方法Dialog_FormCaravan.CheckForErrors()为例,目标是从 IL 中删除一段"载重超限"的检查代码。其删除策略是:
搜索
Ret指令;对每个Ret向后搜到下一个Ret,查找字符串"TooBigCaravanMassUsage"的出现;若命中,则继续找到其后的Ret,删除从第一个Ret之后到第二个Ret(含)之间的全部代码。
仓库中的完整实现位于 Harmony/Documentation/examples/patching-transpiler.cs,这里给出核心片段并逐行解读:
[HarmonyPatch(typeof(Dialog_FormCaravan))] [HarmonyPatch(nameof(Dialog_FormCaravan.CheckForErrors))] public static class Dialog_FormCaravan_CheckForErrors_Patch { static IEnumerable<CodeInstruction> Transpiler(IEnumerable<CodeInstruction> instructions) { var foundMassUsageMethod = false; var startIndex = -1; var endIndex = -1; var codes = new List<CodeInstruction>(instructions); for (var i = 0; i < codes.Count; i++) { if (codes[i].opcode == OpCodes.Ret) { if (foundMassUsageMethod) { endIndex = i; // include current 'ret' break; } else { startIndex = i + 1; // exclude current 'ret' for (var j = startIndex; j < codes.Count; j++) { if (codes[j].opcode == OpCodes.Ret) break; var strOperand = codes[j].operand as string; if (strOperand == "TooBigCaravanMassUsage") { foundMassUsageMethod = true; break; } } } } } if (startIndex > -1 && endIndex > -1) { // we cannot remove the first code of our range since some jump actually jumps to // it, so we replace it with a no-op instead of fixing that jump (easier). codes[startIndex].opcode = OpCodes.Nop; codes.RemoveRange(startIndex + 1, endIndex - startIndex - 1); } return codes.AsEnumerable(); } }这段代码浓缩了本文前面所有概念的最佳实践:
- 先物化再修改:
new List<CodeInstruction>(instructions),避免在遍历的同时修改集合; - 以
Ret为"锚点"分段:Ret天然把方法体切成若干基本块,用它做搜索边界比数偏移量稳健得多; - 以字符串常量作为识别特征:
operand as string == "TooBigCaravanMassUsage",这正是文档中"从既有代码中寻找显著且独特的特征"建议的具体化; - 边界上改用
Nop而非直接删首指令:由于可能存在跳转到删除区间第一条指令的跳转,把它替换为Nop能保持跳转目标仍合法——这是 7.3 节"处理跳转配对"思想的实战体现; - 使用
OpCodes常量:OpCodes.Ret、OpCodes.Nop均来自System.Reflection.Emit,与CodeInstruction.opcode直接比较即可。
9. 总结
CodeInstruction是 Harmony Transpiler 世界的"积木块"。掌握好它的四个关键维度——操作数(可用的元数据类型与跳转/局部变量限制)、局部变量(数字索引与LocalBuilder双形态)、标签(operand + labels 字段的双向约定)、异常边界(blocks 字段与ExceptionBlockType),再配合CodeInstructionExtensions的匹配工具箱与Clone()/MoveLabelsTo/MoveBlocksTo等安全操作,你就能写出既精确、又能在多 Mod 共存与目标代码频繁更新下保持健壮的 Transpiler。
遇到疑难时记住最后一道防线:打开Harmony.DEBUG或[HarmonyDebug],让 Harmony 把生成的每一条指令、每一个标签与异常边界都写进日志,一切都会变得清晰。更多 API 细节可查阅 HarmonyLib.CodeInstruction 与 HarmonyLib.CodeInstructionExtensions 的在线文档,完整示例代码则收录在 Harmony/Documentation/examples/patching-transpiler.cs,测试用例可参考 HarmonyTests/Patching/Transpiling.cs。
- 开发工具
【免费下载链接】Harmony
A library for patching, replacing and decorating .NET and Mono methods during runtime
相关推荐
终极Harmony实战指南:轻松掌握.NET和Mono运行时方法修补技巧
终极Harmony实战指南:轻松掌握.NET和Mono运行时方法修补技巧 Harmony是一款强大的.NET和Mono运行时方法修补库,它允许开发者在不修改原始
开发工具Harmony深度解析:.NET运行时动态方法修补实战指南
Harmony深度解析:.NET运行时动态方法修补实战指南 技术原理与架构设计 Harmony库的核心价值在于其能够在运行时对.NET和Mono应用程序进行非侵
开发工具探索Marko运行时标签系统:DOM操作与状态管理的终极指南
探索Marko运行时标签系统:DOM操作与状态管理的终极指南 Marko作为一款基于HTML的声明式语言,其核心优势在于通过简洁的标签语法实现高效的Web应用开
前端后端Web框架
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考