☰
Harmony 运行时修补核心:CodeInstruction 完全指南——Transpiler 指令模型、操作数与标签/异常块实战
2026/10/7 16:17:38 网站建设 项目流程
  • 开发工具

【免费下载链接】Harmony

A library for patching, replacing and decorating .NET and Mono methods during runtime

项目地址:https://gitcode.com/gh_mirrors/ha/Harmony
点击查看免费下载

导读:本文围绕 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,其公开字段非常精简:

字段类型(当前实现)含义
opcodeOpCode该指令的操作码,如Ldarg_0、Call、Ret
operandobject操作数,可以是Type、FieldInfo、MethodInfo、ConstructorInfo、数值、字符串、Label、LocalBuilder等
labelsList<Label>定义在该指令上的全部标签(早期文档中描述为Label[],当前源码为List<Label>,见 CodeInstruction.cs)
blocksList<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 抽象边界的关键:

  1. 跳转的操作数不能是数值,必须使用Label。这保证了当其他 Mod 在你之前或之后插入了指令后,你的跳转目标依然"指向原来的位置"而不是"指向原来的偏移"。这是多 Mod 共存机制的基石。
  2. SignatureHelper的支持最多算实验性的,官方不建议依赖它。
  3. 应避免用索引(下标)来引用局部变量。因为其他 Transpiler 完全可能在你的代码之前插入/删除局部变量相关的指令,索引值会失效。

2.1 绝对不要直接调用ILGenerator.Emit()

文档中有一句被加粗强调的禁令:

Do not useILGenerator.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出现在两个位置:

  1. 作为跳转指令的 operand(例如Brtrue、Br、Ble_Un的 operand 是一个Label);
  2. 作为目标指令的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枚举包括:

枚举值语义
BeginExceptionBlocktry 块开始
BeginCatchBlockcatch 块开始(catchType指明捕获的异常类型,默认object)
BeginExceptFilterBlock异常过滤器块开始
BeginFaultBlockfault 块开始(无论是否异常都执行,但拿不到异常对象)
BeginFinallyBlockfinally 块开始
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(); } }

这段代码浓缩了本文前面所有概念的最佳实践:

  1. 先物化再修改:new List<CodeInstruction>(instructions),避免在遍历的同时修改集合;
  2. 以Ret为"锚点"分段:Ret天然把方法体切成若干基本块,用它做搜索边界比数偏移量稳健得多;
  3. 以字符串常量作为识别特征:operand as string == "TooBigCaravanMassUsage",这正是文档中"从既有代码中寻找显著且独特的特征"建议的具体化;
  4. 边界上改用Nop而非直接删首指令:由于可能存在跳转到删除区间第一条指令的跳转,把它替换为Nop能保持跳转目标仍合法——这是 7.3 节"处理跳转配对"思想的实战体现;
  5. 使用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

项目地址:https://gitcode.com/gh_mirrors/ha/Harmony
点击查看免费下载

相关推荐

上一篇:5步终极指南:用开源OmenSuperHub彻底掌控惠普游戏本性能
下一篇:如何快速配置开源性能工具:OmenSuperHub完整轻量级优化指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询