跳至正文

Tiger 的 IL 教程

标签:

前言

在学习这篇教程之前你需要了解:IL 是什么,以及如何编写与解读 IL 代码。相关内容可以参考这个站内的教程

当然如果看不懂的话我也推荐这个站外的教程。这个教程由 Celeste(蔚蓝)的 modder 撰写(原帖),虽然游戏不同,但两个游戏所使用的语言都是 C#,而且它们上钩子的方法是高度一致的,那里也有对 IL 的教学。顺便做个安利,Celeste 是一个有着教科书级别的操作手感的平台跳跃游戏,快去试试吧!

如果你觉得上面的教程都太长了,而且你的理解能力足够好,你也可以读一读这个稍微精简一些的讲解…

运算栈

在 IL(Intermediate Language,即中间语言)代码中, 大量操作符本质上都是在操作一个栈,通常将其称为运算栈(也有运算堆栈,评估栈等称法)。以下出现的 ”栈” 都特指这个栈,而非普通的数据结构中的栈,或者内存中的栈等。

栈是方法独立的,也就是说当方法开始时栈是空的,而方法结束时栈中的元素个数必须为 0(无返回值时)或 1(有返回值时),否则会报错(InvalidProgramException)。

在 IL 中每个方法都会有一句 .maxstack n,这表示请求在这个方法的执行过程中栈有确保 n 个元素的空间。不过在写 IL 钩子时可以不用关注它,因为在挂上钩子后的重新编译时它会被自动设置。

IL 指令

IL 指令由操作符和操作数(也有称参数)组成,不过某些操作符不带有操作数。例如 add 就不带操作数,而 ldarg 就需要操作数,如 ldarg 10。注意也有像 ldarg.1 这种操作符,这时这个 1 是操作符名称的一部分,而不是操作数,这个操作符本身是不带有操作数的。

关于指令需要数据的说明

使用指令会将需要的数据弹出,也就是说需要预先将它需要的值压栈,后面以 “需要 xxx” 的方式所描述的,都是指的这个。

另外当需要多个数时,其顺序一般为压栈的顺序,而实际上的出栈顺序与压栈相反。例如说需要 a 和 b,那么压栈时是先 a 再 b,但实际上执行指令时是先取出的 b,再取出 a。

参数及其存取

注:这里的参数指的是方法的参数,而不是 IL 指令的参数(这篇教程统一称其为操作数)。

参数的序号从 0 开始。对于实例方法,参数 0 是 this,对于静态方法,参数 0 则直接是第一个参数,之后的则按照方法的参数列表排列。注意 params 参数是以数组形式传入的单个参数。

使用 ldarg.ildarg i 以将参数压栈,starg.istarg i 则需要一个值,将此值赋给参数(实际上就是将栈顶元素出栈并将其赋值给参数),ldarga i 以将参数的地址压栈(常用于结构体),其中 i 为参数的序号。

另外当参数为 refoutin 参数时,参数本身其实是以地址的形式传入的,需要以 ldindstind 等方式间接使用参数的值(见下面的其它指令)。

局部变量及其存取

若 IL 方法有局部变量,那么在 IL 方法中可以看到有一个 .locals init 块,其中就包含了这个方法中要用到的所有局部变量,其序号也是从 0 开始的。

使用 ldloc.ildloc i 以将局部变量压栈,stloc.istloc i 以将栈顶元素出栈并赋值给局部变量,ldloca i 以将局部变量的地址压栈,其中 i 为局部变量的序号。

使用常量

ldc.i4 valueldc.i8 valueldc.r4 valueldc.r8 valueldstr value 以压入一个 intlongfloatdoublestring 常量。

常用的运算符

addsubmuldivrem 需要两个数,然后将其 和 / 差 / 积 / 商 / 余数 压栈。

ceqcgtclt 也需要两个数,若 相等 / 大于 / 小于 的结果压栈(真即为 1,假即为 0)。

上面那些用以比较的指令都还有一个带 .un 后缀的版本,表示无符号整型或未经排序的浮点型的比较。

andorxor 则是将两个数的 与 / 或 / 异或 压栈。

negnot为一元运算符,只需要一个数,将其 取反 / 非 压栈。

使用成员

调用方法

使用 call methodcallvirt method 以调用方法,其中 callvirt 会在该方法为虚方法时查找调用重写后的方法。不过一般对于普通成员方法的调用,C# 编译器也会生成 callvirt 指令,这是因为 callvirt 会先检查目标类型,在调用对象为 null 时就直接抛出异常(NullReferenceException)。

在调用前需要将此方法的参数按上文中提到的参数的序号的顺序依次压栈,(如果为实例方法则首先要将此方法所需的实例压栈),如果方法有返回值则调用完后会将返回值压栈。

注意当参数为 refoutin 参数时需要通过 ldargaldloca 等方式压入对应参数的地址。

其实还有一个 calli 操作符也是用来调用方法的,但是我既没用过也没遇到过,所以就不讲啦~

调用属性

调用属性其实和方法是一致的,比如带有 setget 访问器的 Property 属性,那么实际上会被编译为两个方法 set_Propertyget_Property ,对属性的调用就是通过这两个方法完成的。

字段存取

使用 ldsfld field 以将静态字段压栈,stsfld field 以将栈顶的值弹出并赋予静态字段;ldfld field 对应实例的字段,它会先取出栈顶的元素,将其作为此实例,然后获取字段,stfld field 则需要一个实例和一个值,将值赋给此实例的对应字段。

索引器 / 数组元素

对于索引器,实际上会被编译为 get_Item 方法。

对于数组,则一般是使用 ldelem.i4 获取,它需要在栈中依次压入数组和索引,然后压入数组元素的值;stelem.i4 则用以设置数组元素的值,它需要在栈中依次压入数组,索引和需要设置的值。至于它们的后缀,也有 .rx, .ux 的形式,iru 代表索引的类型,为整型,浮点型或无符号整型,数字 x 代表索引的字节数,其可能的取值为 1,2,4 或 8。(实际上可能的后缀为 i1i2i4i8r4r8u1u2u4

跳转指令

所有的跳转指令都带有操作数,表示要跳转的指令。这一块会省略此操作数。

beqbgebgtblebltbne 需要两个数,当 相等 / 大于等于 / 大于 / 小于等于 / 小于 时跳转。

上面那些用以比较的指令都还有一个带 .un 后缀的版本,表示无符号整型或未经排序的浮点型的比较。

关于 “未经排序的浮点型”…

实际上我也不知道是什么,微软文档上写的 “unodered float values”,但是也没有给解释。

经过我在 SharpLab 的测试,当指令与 C# 中所用的比较运算符相符时(ge,gt,le,lt 分别对应 >=,>,<=,<)使用的是不带 .Un 的版本,不符时则带。不过 beq 没有带 .Un 的版本,bne 则只有带 .Un 的版本。顺带来说 ceq 也没有带 .Un 的版本,而 cne 则不存在(储存两个值不相等是通过 ceqldc.i4.0ceq 实现的)。

比如 if (a < b),实际上是大于等于时跳转,所用的就是 bge.un;而如果是 if (!(a >= b))while (a >= b),同样是大于等于时跳转,但使用的就是 bge(不带 .Un)。

brtruebrfalse 需要一个数,当其 非零 / 为零 时跳转。

br 为直接跳转,又称无条件跳转。

switch 的操作数是要跳转的指令数组,它需要一个数,然后会跳转到数组中以这个数为下标的指令(注意从 0 开始)。

其它指令

ret 标志着方法的结束,此时需要栈中的元素个数正好为 0(无返回值时)或 1(有返回值时)。

pop 会直接弹出栈顶元素,通常用于处理方法没有用到的返回值。

newobj .ctor 会创建一个实例,并将其压入栈中,.ctor 为构造函数,若有参数则从栈中获取。

dup 会弹出栈顶元素,然后将其压入两次。

conv.xx 代表类型转换,如 conv.i4 代表转换为 intconv.r8 代表转换为 double

ldind.xx 需要一个指针,然后将指针指向的元素压栈。xx 表示元素的类型,如 ldind.i4 表示指针指向的是 intldind.ref表示指针指向的是引用类型。

stind.xx 需要一个指针和一个值,然后将此值赋给指针指向的元素。

box 为装箱,取出栈顶的值类型元素,将其转化为引用类型再放入栈中。

unbox 为拆箱,取出栈顶的引用类型元素,将其转化为值类型再放入栈中。

.s 系列指令

上述的绝大多数带有操作数的指令都有一个带 .s 后缀的版本,称为短格式版本,此版本的操作数会更短一些,但实际上与原版本(长格式版本)所做的事情是一样的,只是可以稍微优化一下 IL 占用的空间而已。

所有指令

这里是所有的 IL 指令:OpCodes 的微软官方文档

C# 语句与 IL 指令的转换

具体就不细讲了,有什么想了解的都可以直接去 SharpLab 上试(SharpLab 的介绍在下面)。这里只简单提一句,C# 语句中的各种条件控制语句(ifswitchfor 等)实际上都是由跳转指令完成的,本质上与带有条件判断的 goto 语句一样。当然这并不是在鼓励使用 goto 语句就是了。

一个简单的例子

有这样一串代码:

using System;
public class User
{
    public static bool UseExclamation;
    string? name;
    public string? Name{
        get => name;
        set => name = value;
    }
    public string Hello(int index){
        return $"[{index}] Hello {name}" + (UseExclamation ? "!" : null);
    }
}
public class Program
{
    int a = 4;
    public void Test(int b){
        User user = new(){
            Name = "Tigerzzz"
        };
        User.UseExclamation = true;
        if (a - b > 2){
            Console.Write(user.Hello(a));
        }
    }
}

它的 Test 函数所对应的 IL 代码如下:

.method public hidebysig 
    instance void Test (
        int32 b
    ) cil managed 
{
    // Method begins at RVA 0x20cc
    // Code size 52 (0x34)
    .maxstack 3
    .locals init (
        [0] class User user
    )

    IL_0000: newobj instance void User::.ctor()
    IL_0005: dup
    IL_0006: ldstr "Tigerzzz"
    IL_000b: callvirt instance void User::set_Name(string)
    IL_0010: stloc.0
    IL_0011: ldc.i4.1
    IL_0012: stsfld bool User::UseExclamation
    IL_0017: ldarg.0
    IL_0018: ldfld int32 Program::a
    IL_001d: ldarg.1
    IL_001e: sub
    IL_001f: ldc.i4.2
    IL_0020: ble.s IL_0033

    IL_0022: ldloc.0
    IL_0023: ldarg.0
    IL_0024: ldfld int32 Program::a
    IL_0029: callvirt instance string User::Hello(int32)
    IL_002e: call void [System.Console]System.Console::Write(string)

    IL_0033: ret
} // end of method Program::Test

以下为截图:

IL 讲解示例的 SharpLab 截图

以下为演示视频,建议配合暂停使用:

IL 讲解示例的演示视频

此外再推荐一个网站:SharpLab。这里可以在线转换 C# 代码与 IL 代码,对于理解 IL 代码很有帮助。此外它还可以做到直接查看代码的运行结果等操作,具体就不细讲了。

SharpLab 网页展示

如何挂上 On 或 IL 钩子

挂 On 钩子和挂 IL 钩子的方法是一致的,基本有以下三种方法:

On_Xxx 和 IL_Xxx

对于 TML 本身的大多数公共方法,都可以用 On_Xxx 或者 IL_Xxx 的方式挂上 On 钩子或 IL 钩子。

例如我想给 Player.ItemCheck 上一个 IL 钩子,可以直接写:

IL_Player.ItemCheck += IL_Player_ItemCheck;

至于其中的 IL_Player_ItemCheck 是什么我放在下面来讲。

MonoModHooks

详见这篇教程:

以下仍以给 Player.ItemCheck 上 IL 钩子为例:

var flags = BindingFlags.Public | BindingFlags.Instance;    // 公开的实例方法
var itemCheckMethod = typeof(Player).GetMethod("ItemCheck", flags);
MonoModHooks.Modify(itemCheckMethod, IL_Player_ItemCheck);

Hook 和 ILHook

通过以上两种方式挂上的钩子 TML 都会保证卸载,而第一种方式可以通过 -= 的方式手动卸载,但第二种方式就无法在挂上之后手动卸载。现在介绍一种更加自由的方式来做出修改:HookILHook,对应 On 钩子和 IL 钩子,它们都位于 MonoMod.RuntimeDetour 命名空间下。下面主要介绍一下 ILHookHook 其实也是类似的):

bool applyByDefault = true;   // applyByDefault 参数表示是否在实例化时就挂上钩子,默认 true
DetourConfig config = new("Test ILHook");   // 主要用以配置挂钩子的先后顺序, 默认为 null
ILHook ilHook = new(itemCheckMethod, IL_Player_ItemCheck[, config][, applyByDefault]);
if(ilHook.IsApplied) {  // 判断这个钩子有没有挂上
    ilHook.Undo();      // 卸载钩子
}
else {
    ilHook.Apply();     // 挂上钩子
}

注意通过这种方式挂上的钩子需要自己保证卸载与释放(ilHook.Dispose())。

钩子的先后顺序

可以注意到 HookILHook 的构造函数中都有一个 DetourConfig 参数,可以由它来决定挂在同一方法上的钩子的执行顺序。

总的来说,所有带有 DetourConfig 的钩子的执行在不带 DetourConfig 的钩子之前,带有 DetourConfig 的钩子的执行顺序由 DetourConfig 决定,不带 DetourConfig 的钩子的执行顺序即挂上钩子的顺序。(TML 的钩子全部不带有 DetourConfig 参数,也就是说通过上面说的前两种方法上的钩子的执行顺序必然在带有 DetourConfig 的钩子之后)

DetourConfig 构造函数:DetourConfig(string id, int? priority = null, IEnumerable<string> before = null, IEnumerable<string> after = null, int subPriority = 0);

其中 id 为你给这个钩子的标识符(自己取名)。

beforeafter 内写其他钩子的 id,表示你希望在哪些钩子前,哪些钩子后。如果有相互依赖,那么以后挂上的钩子为准;如果有循环依赖,那么会直接报错。以图论的话来说,对同一个方法的相同类型的钩子构成一个有向图,产生 2 圈时会以新生成的边为准,产生长度不小于 3 的圈时会直接报错。

priority 代表钩子执行的优先级,在 beforeafter 允许的情况下,priority 越大,钩子就越先执行。prioritynull 时优先级视为最小(比负数还小)。

subPriority 为钩子的第二优先级,在 priority 相同的情况下会再比较此值。

对于 On,在挂上或卸载一个钩子时会给钩子排序,每次执行此方法时会按此顺序依次执行钩子。

对于 IL,在挂上或卸载一个钩子时会先给所有钩子排序,然后重写生成原方法,再按此顺序对重新生成的方法执行所有钩子,最后再编译。执行此方法时则会直接执行编译出来的方法。

挂钩子的位置

一般是写在对 Mod.Load() 的重写中,或者对 ModSystem.Load() 的重写中。如果要在 ModSystem 中写而且需要保证在 Mod.Load() 之后可以写在对 ModSystem.OnModLoad() 的重写中。

对于需要自己手动卸载的内容,则是 Mod.Unload()ModSystem.Unload() 或者 ModSystem.OnModUnload()Mod.Unload() 前)。

如何写 IL 钩子

可以注意到上面的三种方法中都有一个 IL_Player_ItemCheck,这个实际上就是我们要写的 IL 钩子,它的实现是这样的:

void IL_Player_ItemCheck(ILContext il) {
    //这里写 IL 钩子的内容
}

与 On 不同,不论修改的方法是静态的还是实例的,也不论修改的方法有什么参数或者返回值,IL 钩子的参数和返回值都是固定的。

下面会讲述如何编写这个方法。

要用到的类

ILContext(位于 MonoMod.Cil)

代表 IL 的上下文,IL 钩子获得的唯一参数就是这个。写 IL 钩子的本质就是对它进行修改,不过基本上对它的修改都是通过下文中的 ILCursor 完成的。若无特殊说明,下文中的 il 一律指 ILContext 实例。注意当同一个方法有多个 IL 钩子时,这个 il 很可能已经经其他钩子修改过了。

Instruction(位于 Mono.Cecil.Cil)

代表一个 IL 指令,由操作码与操作数(可能没有)组成,有时我也会将其称为 IL 语句。

var instrs = il.Instrs;         // 获得 ILContext 中的所有 IL 指令
Instruction instr = instrs[0];  // 这里只是一个示例, 一般不通过这种方法获得 IL 指令
Show(instr.Previous);     // 获得上一句 (不要给它赋值)
Show(instr.Next);         // 获得下一句 (不要给它赋值)
// instrs 在被修改时 (包括但不限于直接操作 instrs 或者使用 ILCursor 来修改) 会自动修改受影响的 instruction 的 Previous 和 Next, 所以不用担心两个属性会有问题, 也不要去手动设置这两个值
Show(instr.OpCode);       // 获得或设置操作码
Show(instr.Operand);      // 获得或设置操作数

ILLabel(位于 MonoMod.Cil)

表示一个标签,各种用以跳转的 IL 指令的操作数都是它(实际上各种跳转指令的操作数是 Instruction,不过在挂每个 IL 钩子前 MonoMod 都会将所有 Instruction 类型的操作数转换为 ILLabel,再在执行完此 IL 钩子后立即将所有 ILLabel 类型的操作数转换为 Instrction,包括 switch 指令会用到的数组操作数(参见 ILContext.Invoke 的源码))。

使用 il.Labels 以获得 IL 上下文中的所有标签;label.Target 以获得此标签指向的指令;label.Branches 以获得所有操作数是此标签的语句(它的实现为搜索 il.Instrs 获得,所以多次使用时最好先缓存下它的结果)。

需要注意的是跳转指令不是仅仅对应于 C# 的 goto 语句,而是要泛用的多,任何 C# 的流程控制语句(iffor 等)都会使用到跳转指令,也就是说一个 IL 中往往会有很多的标签。

OpCodes(位于 Mono.Cecil.Cil)

这个静态类中定义了各种可能会用到的 IL 操作码(Mono.Cecil.Cil.OpCode),如 OpCodes.AddaddOpCodes.Ldc_I4_Sldc.i4.s

ILCursor(位于 MonoMod.Cil)

IL 指针,用于在 IL 中定位,插入,修改及删除 IL 指令。这个是写 IL 钩子时最常使用的东西,下面再详细讲它的用法。

指针,指令与标签的位置关系

这一块中指针专指 IL 指针(ILCursor),指令专指 IL 指令(Instruction),标签专指 IL 标签(ILLabel)。

指针与指令:指针的位置处于指令之间,包括第一句指令前和最后一句指令后,也就是说指针的前一句和下一句是相邻的两句。不过如果说指针指向一条指令,那么指的应该是这条指令是指针的下一句。

标签与指令:标签指向一个指令,可以看作在指令之前的位置。在使用跳转指令跳转到这个标签后接下来执行的就是此标签指向的指令。可能会有多个标签指向同一条指令,但指令本身不保存指向自己的标签。

指针与标签:当指针与标签指向同一条指令时,默认是指针在标签之前。不过指针内存有一个 _afterLabels(私有),指示它在哪些标签之后。

ILCursor 基本用法

  • 获得一个 IL 指针
    • 从 ILContext 中获得:new ILCursor(il)。以这种方法获得的指针位于整个上下文的开头处。
    • 从另一个指针获得:new ILCursor(cursor)。这种方法等价于克隆一个指针。
  • 上一条指令:cursor.Prevcursor.Previous,这两者等价。注意虽然指针在开头时它的值为 null,但是如果将它设置为 null 那么指针将跳到上下文的末尾而不是开头(一个小 bug)。
  • 下一条指令:cursor.Next。当指针位于末尾时它的值为 null,将它设置为 null 也会让指针跳到末尾。
  • 获得上下文:cursor.Context
  • 获得在上下文中的序号:cursor.Index,通过搜索全文获得。

定位

Goto

如果已经有一条指令 instr,可以直接使用 cursor.Goto(instr, moveType = MoveType.Before) 让指针定位到对应位置。其中 moveTypeMonoMod.Cil.MoveType 枚举,它有以下几个取值:

  • Before:指令之前,所有指向此指令的标签之前。默认值一般都是这个。
  • AfterLabel:移动到指令之前,所有指向此指令的标签之后。通常想要在这条指令之前插入指令的话就用这个。相当于先使用 MoveType.Before,然后再调用 cursor.MoveAfterLabels()
  • After:指令之后,下一条指令的所有标签之前。

GotoXxx

不过通常来说我们不会直接就能获得一条指令,而是要在上下文中搜索。基本上常用的方法就这四个:GotoNextTryGotoNextGotoPrevTryGotoPrev,它们都是 ILCursor 的实例方法,会在搜索到第一组符合条件的相关指令时使指针跳转到对应位置。其中带有 Try 前缀的会返回一个 bool 值指示是否找到并跳转,不带有 Try 前缀的会在找不到时直接报错,返回值则是自身;带有 Next 后缀的表示向后搜索, Prev 后缀的表示向前搜索。这四个方法的参数列表是一致的,以下仅以 GotoNext 为例:

GotoNext 的声明:ILCursor GotoNext(MoveType moveType = MoveType.Before, params Func<Instruction, bool>[] predicates);

predicates 表示要找的挨在一起的一组指令分别要满足什么条件,moveType 表示跳到这组指令的什么位置,注意当它为 MoveType.After 时会跳到整组指令之后。

// 注意以下的 instr.MatchXxx 为 MonoMod.Cil.ILPatternMatchingExt 提供的拓展方法
// 使用这个的好处在于例如 MatchLdarg, 它不仅会匹配 ldarg, 还会匹配 ldarg.s, ldarg.0, ldarg.1 等操作符
cursor.GotoNext(i => i.MatchAdd());  //跳到下一条操作符是 add 的指令的前面
// 跳到 "1 + 1" 对应的指令之前,并跳到标签之后
cursor.GotoNext(MoveType.AfterLabels, i => i.MatchLdcI4(1), i => i.MatchLdcI4(1), i => i.MatchAdd());
// 顺带一提 i => i.MatchAdd() 也可以用 ILPatternMatchingExt.MatchAdd 代替
// 比如说如果在文档开头处写上 “using ILEx = MonoMod.Cil.ILPatternMatchingExt;”, 就可以使用 ILEx.MatchAdd 了

当然你也可以以你的方式在 il.Instrs 中搜索并获得 instruction 然后使用 Goto 跳转,不过即使你预先知道这条指令是第几句也请不要使用 il.Instrs[index] 的方式直接获得它,因为其它模组也有可能对此 IL 进行修改。

同样为了兼容性考虑,这里提倡在搜索时尽量搜索一整句 C# 语句对应的一组 IL 指令,修改时也不要破坏这样的一组指令。

FindXxx

ILCursor 还有一组实例方法:FindNextTryFindNextFindPrevTryFindPrev,它们的作用和区别与 GotoXxx 系列类似,只是它们不会让指针直接跳转,而是将找到的位置以 out 参数的方式传出,而且不带有 Try 前缀的没有返回值。它们的参数同样是一致的,以下仅以 FindNext 为例:

FindNext的声明:void FindNext(out ILCursor[] cursors, params Func<Instruction, bool>[] predicates);

predicates 同样表示要找的一组指令分别要满足什么条件,但这组指令不必挨在一起,也不区别前后顺序,只是分别寻找对应的指令而已,但只要有一个没找到就会直接返回或报错,不会继续找剩下的,而 cursors 则是指向找到的指令的一组指针,其长度与传入的 predicates 的长度相同。

SearchTarget

这一块的内容可以选择跳过,只要你知道在连续的使用 GotoXxx 系列方法时不会找到同一条指令就可以了。

ILCursor 有一个属性 SearchTarget,储存它上一次搜索到的指令的位置;若是在下一次搜索的方向上,那么搜索时就会跳过这个方向的第一条指令。

SearchTarget 的类型为 MonoMod.Cil.SearchTarget 枚举,它有 NextPrevNone 三个值,分别代表上一次搜索到的语句的位置在指针后,指针前,和没有上一次搜索。

对于 cursor.Goto 方法 ,它实际上还有一个 setTarget 参数,默认为 false,当它为 false 时会将 SearchTarget 设置为 None,为 true 时则会按照 moveType 设置 SearchTarget:当 moveTypeAfter 时会将 SearchTarget 设置为 Prev(移到指令之后,那么此指令就在指针之前),否则设置为 Next

当调用 GotoXxx 系列方法时,如果是向后查找且 SearchTargetNext 或者向前查找且 SearchTargetPrev,那么就会跳过对应方向上的第一句,而在找到指令后它们就会调用 setTarget 参数为 trueGoto 方法,其中就会设置 SearchTarget 的值。

而当改变指针的位置时,大多都是通过调用 setTargetfalseGoto 完成,例如设置它的 IndexNextPrev 以及调用 Emit 系列方法等,它们都会将 SearchTarget 重置为 None。不过 MoveAfterLabelsMoveBeforeLabels 方法只改变了指针和标签之间的位置关系,而并没有调用 Goto,所以并没有改变 SearchTarget 的值。

插入指令

Emit

使用 cursor.Emit(opcode, operand) 以插入一条语句,其中 opcode 为操作符,operand 为操作数,返回值是指针本身(可以链式调用)。

实际上更常用的是 cursor.EmitXxx(operand) 系列,其中 Xxx 为用大驼峰法表示的操作符名(如 ldc.i4 -> LdcI4)。这样就可以得知操作数具体该使用什么类型了,只是并不支持短格式指令(.s 指令)。

另外还有一个经常用到的方法:cursor.EmitDelegate<T>(T delegate); 可以直接插入一个方法调用,只需要在它之前将它要用到的参数入栈(不需要考虑 this 占据 0 号参数的问题,它不需要这个参数),然后在它之后将返回值(若有)出栈即可。

在使用 Emit 系列方法插入语句后指针会直接跳到插入的语句之后,方便继续插入语句。

某些操作数对应类型

各种跳转指令的操作数使用的标签或者指令,而 switch 的则是对应类型的数组。

callcallvirtnewobj 的操作数一般使用反射获得的 MethodInfo

(ld|st)s?fld 的操作数一般使用反射获得的 FieldInfo

(ld|st)(arg|loc) 的操作数一般使用对应 参数 / 局部变量 的序号。

打标签

使用 cursor.MarkLabel(label) 以使标签 label 指向指针的下一条语句,且放在指针之前;cursor.MarkLabel() 则会新建一个标签,执行上述操作再返回它;cursor.MarkLabel(instr) 则会返回一个指向指令 instr 的标签,且当 instr 为指针指向的指令时会将标签置于指针之前。cursor.DefineLabel() 则只会新建一个标签,但不会设置它的目标。使用以上方法新建标签时,标签都会被自动放入 il.Labels 中。

当插入跳转指令时需要使用标签作为操作数,那么就可以通过以上方式获得。而 EmitXxx 系列的参数其实也可以是指令,不过实际上也会使用 cursor.MarkLabel(instr) 将指令转化为标签。如果你执意要直接用指令作为操作数(不建议这么做),那么只能直接使用 Emit

指针与标签的位置关系和插入指令之间的联系

前面有提到指针内存有一个 _afterLabels 私有字段,用于指示指针在哪些标签后。在调用 Emit 系列方法时指针就会将 _afterLabels 中的所有标签的目标改为插入的指令,然后清空 _afterLabels。实际上说指针在这些标签之后就是因为这个步骤。

使用 cursor.IncomingLabels 以获得指向下一条指令的所有标签(需自行缓存),cursor.MoveAfterLabels() 以跳到下一条指令的所有标签之后,cursor.MoveBeforeLabels() 以跳到下一条指令的所有标签之前。

如果要更加精细地控制指针与标签的位置,那么可以在插入前通过 cursor.IncomingLabels 以获得指向下一条指令的所有标签,在其中筛选出在指针之前的标签,然后在插入后将这些标签的目标改为插入的语句(此时插入的语句位于指针之前,应使用 cursor.Prev 获得)。

创建新的局部变量

使用以下代码来添加局部变量并使用它…

// 假如需要添加一个int类型的局部变量
Type type = typeof(int);

// 通过下面两句就可以将一个局部变量的声明添加到il对应的方法中
VariableDefinition variable = new(il.Method.DeclaringType.Module.ImportReference(type));
il.Body.Variables.Add(variable);

// 然后就可以像对待局部变量一样对待它了
cursor.EmitLdloc(variable);
cursor.EmitStloc(variable);

如果常用这个功能的话你还可以直接封装成方法…

public static class ILExtensions {
    public static VariableDefinition DeclareLocal<T>(this ILContext il)
        => DeclareLocal(il, typeof(T));
    public static VariableDefinition DeclareLocal(this ILContext il, Type type) {
        var typeRef = il.Method.DeclaringType.Module.ImportReference(type);
        VariableDefinition variable = new(typeRef);
        il.Body.Variables.Add(variable);
        return variable;
    }
}

修改及删除指令

为了兼容性考虑不推荐修改或者删除原先存在的指令,下一段落的内容仅供学习使用。

cursor.Remove() 以移除指针的下一条指令,cursor.RemoveRange(num) 以移除接下来的 num 句。如果是直接修改的话,可以直接设置 instr.OpCodeinstr.Operand

当然实际上有兼容性更高的办法,那就是在要修改或者删除的指令前插入无条件跳转,跳转到指令之后,就相当于删除跳过的指令了,而指令还是仍然存在。如果是修改则再在跳转前或跳转到的位置之后添加修改后的指令。

修改指令的示例

假如说一个方法的 0 号局部变量为 int a,现在我们要将方法中的第二处 a += 2; 改为 a += 3;,那么推荐的写法为:

static void IL_SomeMethod(ILContext il) {
    ILCursor cursor = new(il);
    // 寻找第二个 a += 2;
    for (int i = 0; i < 2; ++i) {
        cursor.GotoNext(
            i => i.MatchLdloc0(),
            i => i.MatchLdcI4(2),
            i => i.MatchAdd(),
            i => i.MatchStloc0());
    }
    cursor.MoveAfterLabels();   // 图省事也可以将上面的 GotoNext 的 moveType 参数设置为 MoveType.AfterLabel

    // 添加 a += 3;
    cursor.EmitLdloc0();
    cursor.EmitLdcI4(3);
    cursor.EmitAdd();
    cursor.EmitStloc0();
    // 当然以上 4 句也可以简写为以下 3 句, 只是被修改的方法的性能会下降一些罢了
    // cursor.EmitLdloc0();
    // cursor.EmitDelegate((int i) => i + 3);
    // cursor.EmitStloc0();

    // 添加无条件跳转跳转到 a += 2; 之后
    cursor.EmitBr(il.Instrs[cursor.Index + 4]);

如何找到要修改的 IL 的确切位置与内容

首先你得有一份 TML 的源码。源码可以通过群(521594299)文件,TML 的 github,或者使用反编译软件(ILSpy,DnSpy,dotPeek(JetBrains 的一个重量级(指占用内存很大) .NET 反编译器) 等)反编译 tModLoader.dll 等方式获取(对于 Steam 用户,反编译所需的 tModLoader.dll 可以直接在 Steam 中浏览 TML 的本地文件时找到)。

然后根据你自己的目的,找到源码中你要修改的位置。这个就需要你自己的阅读和理解源码的能力了。可以阅读一下下面这篇文章:

最后在反编译软件中查找对应方法的 IL 代码。

dotPeek 界面展示

示例 – 让 NPC 受到的伤害最低变为 0 点

定位修改位置

层层摸索

对 npc 造成伤害时肯定修改了 npc.life,在 tml 的源码中简单搜索一下修改了 npc.life 的地方(实际上熟悉源码的话应该可以直接定位到 NPC.StrikeNPC)。在VS中搜索 NPC.life 的引用,然后筛选类别为写入:

VS中直接查找修改了 npc.life 的地方

还是有一大串… 继续筛选,对于包含成员中,SetDefaultsSetDefaultsFromNetId 为设置默认值,AI_Xxx_Xxx 为特定 AI,一般不会直接包含受伤代码,UpdateNPC_BuffApplyDOTs 应该为 buff 相关内容,MessageBuffer.GetData 为联机同步相关内容等等直接筛掉:

VS 中查找修改了 npc.life 的地方并经初步筛选

虽然还是有点多,但是可以直接看代码了,受伤应该是 life -= xxx 的形式,当然 += 也有可能,精筛结果:

npc.life 精筛结果(人工筛选)

其中前四个和后两个都是 +=,而它们之后都紧跟着HealEffect,可以判定为回血,跳过,那么可以直接定位到 NPC.StrikeNPC 中:

NPC.StrikeNPC 中直接修改 life 的地方

它们都是将血量减少 num,那么向前搜查它的由来…

向前搜查的过程中可以发现减少血量的代码是包在一个 if (num >= 1.0) 的 if 块中的,这里将是第一处需要作 IL 修改的地方。再往前可以找到:

NPC.StrikeNPC 中给 num 赋值的位置

其中的 hit 则是 NPC.StrikeNPC 的第一个参数,类型为 HitInfo,那么再搜索所有 NPC.StrikeNPC 相关的引用,找到这个 hit 是怎么来的…

在经过逐个检查后(或者以对源码的掌握),会发现与 hit.Damage 相关的都聚集在 NPC.HitModifiers.ToHitInfo 处:

NPC.HitModifiers.ToHitInfo 源码

其中有将 SourceDamage 的最小值限制为1,这里需要修改;而其中的伤害相关部分又都包装在了NPC.HitModifiers.ToHitInfo.GetDamage 里:

NPC.HitModifiers.ToHitInfo.GetDamage 源码

可以注意到其中有三处限制了最小伤害为 1,这三处都要做修改。

另外还注意到 hit.Damage 是一个属性,在属性中也有限制最小值的内容:

NPC.HitHitInfo.Damage 属性源码

顺带有 hit.SourceDamage 也是如此:

NPC.HitInfo.SourceDamage 属性源码

额外考虑

原本伤害就是 0 的弹幕是否造成伤害

伤害不大于 0 的弹幕是不会造成伤害的,而且会直接穿过NPC,仿佛连碰撞检测也没有做。介于有些人可能希望维持现状,有些人则希望修改后这种弹幕也能造成伤害(尽管只有 0 点),那么这里将其作为一个配置项处理。

弹幕的伤害模块是写在 Projectile.Damage 中的,其中有一个 if 块:if (owner == Main.myPlayer),这其中应该就是单端处理的内容,伤害判断应该在里面,其中需要注意的核心部分是这样的:

Projectile.Damage 判断伤害大于 0 的核心代码

其中下面那个判了 hostile 的 if 块可以注意到是玩家碰撞相关的,可以不用管,只需要修改上面那个 if 中的条件即可。

NPC 之间的碰撞伤害

NPC 之间的碰撞伤害由 NPC.GetHurtByOtherNPCs 处理,其源码如下:

NPC.GetHurtByOtherNPCs 源码

可以注意到其中有一句判断 nPC.damage > 0,也就是说只有当 npc 的伤害大于 0 时它才会有碰撞伤害,不用担心伤害为 0 时它仍然会给其它 npc 造成碰撞伤害,因此不用修改相关内容。

SuperArmor

众所周知 TML 为骷髅守卫专门做了个 SuperArmor就连使用 Noitaria 模组都没法破它的甲 再高的伤害打它若不暴击都只有一点血,暴击了就算暴击倍率再高也只有最高4点血,那么作了这个修改后会不会将它变成 0 点呢?让我们找一找 SuperArmor 相关的源码…

可以发现 SuperArmor 相关的内容十分单调,无论是 NPC.HitModifiers.SuperArmor 还是 NPC.SuperArmor 都只有一两处调用,其中起作用的地方也就只有上文提到过的 NPC.HitModifiers.GetDamage 中的一小段:

NPC.HitModifiers.GetDamageSuperArmor 相关片段

如果你想的话也可以修改这里,不过按 TML 的来不暴击时固定造成 1 点伤害也不错,这里就不对它再进行更多的修改了。

确定修改内容

盘点所有需要修改的地方:

NPC.StrikeNPC

if (num >= 1.0) 修改为 if (num >= 0.0)
IL 代码
// if (num >= 1.0)
ldloc.1 // num
ldc.r8 1
blt.un label
dotPeek 截图
NPC.StrikeNPC 修改位置的 IL 代码

NPC.HitModifiers.ToHitInfo

SourceDamage = Math.Max((int)SourceDamage.ApplyTo(baseDamage), 1), 中的 1 修改为 0。
IL 代码
// hitInfo.SourceDamage = Math.Max((int)SourceDamage.ApplyTo(baseDamage), 1);
ldloca.s 1 // hitInfo
ldarg.0 // this
ldflda NPC.HitModifiers.SourceDamage
ldarg.1 // baseDamage
call StatModifier.ApplyTo(float)
conv.i4
ldc.i4.1
call Math.Max(int, int)
call NPC.HitInfo.set_SourceDamage(int)
dotPeek 截图
NPC.HitModifiers.ToHitInfo 参数和局部变量
NPC.HitModifiers.ToHitInfo 修改位置的 IL 代码

NPC.HitModifiers.GetDamage

将其中 3 处将最小伤害限制为 1 的地方修改为限制为 0。
IL 代码
// return Math.Clamp((int)dmg, 1, Math.Min(_damageLimit, 4));
ldloc.s 5 // dmg
conv.i4
ldc.i4.1
ldarg.0  // this
ldfld NPC.HitModifiers._damageLimit
ldc.i4.4
call Math.Min(int, int)
call Math.Clamp(int, int, int)
ret

// damage = Math.Max(damage - damageReduction, 1);
ldloc.0 // damage
ldloc.3 // damageReduction
sub
ldc.r4 1
call Math.Max(float, float)
stloc.0

// return Math.Clamp((int)statModifier.ApplyTo(damage), 1, _damageLimit);
ldloca.s 6 // statModifier (this.FinalDamage)
ldloc.0    // damage
call StatModifier.ApplyTo(float)
conv.i4
ldc.i4.1
ldarg.0    // this
ldfld NPC.HitModifiers._damageLimit
call Math.Clamp(int,int,int)
ret
dotPeek 截图
NPC.HitModifiers.GetDamage 参数和局部变量
NPC.HitModifiers.GetDamage 修改位置 1 的 IL 代码
NPC.HitModifiers.GetDamage 修改位置 2 的 IL 代码
NPC.HitModifiers.GetDamage 修改位置 3 的 IL 代码

NPC.HitInfo.Damage 和 NPC.HitInfo.SourceDamage

NPC.HitInfo.DamageNPC.HitInfo.SourceDamage 属性的 set 访问器中将 Math.Max(value, 1) 改为 Math.Max(value, 0)
IL 代码
// set_Damage: this._damage = Math.Max(value, 1);
ldarg.0 // this
ldarg.1 // value
ldc.i4.1
call Math.Max(int, int)
stfld NPC.HitInfo._damage
ret

// set_SourceDamage: this._sourceDamage = Math.Max(value, 1);
ldarg.0 // this
ldarg.1 // value
ldc.i4.1
call Math.Max(int, int)
stfld NPC.HitInfo._sourceDamage
ret
dotPeek 截图
NPC.HitInfo.set_Damage 的 IL 代码1
NPC.HitInfo.set_Damage 的 IL 代码
NPC.HitInfo.set_SourceDamage 的 IL 代码2
NPC.HitInfo.set_SourceDamage 的 IL 代码

Projectile.Damage

if (flag3) 修改为按照配置判断是判 flag3 还是 damage >= 0
IL 代码
// if (flag3)
ldloc.s 10  // flag3 (this.damage > 0)
brfalse label
dotPeek 截图
Projectile.Damage 参数和局部变量
Projectile.Damage 修改位置的 IL 代码

编写相关代码

首先需要新建一个继承自 ModSystem 的类,然后在其中编写相关内容。

NPC.StrikeNPC

注意需要修改的方法有多个重载,我们需要修改的为:public int StrikeNPC(HitInfo hit, bool fromNet = false, bool noPlayerInteraction = false); ,它对应于 IL_NPC.StrikeNPC_HitInfo_bool_bool

IL 代码修改

原 IL 代码

// if (num >= 1.0)
ldloc.1
ldc.r8 1
blt.un label

预期 IL 代码

// if (num >= 0.0)
ldloc.1
ldc.r8 0
blt.un label
IL 钩子
ILCursor cursor = new(il);
ILLabel bleLabel = null;
cursor.GotoNext(MoveType.AfterLabel,
    i => i.MatchLdloc1(),
    i => i.MatchLdcR8(1.0),
    i => i.MatchBltUn(out bleLabel));
cursor.EmitLdloc1();
cursor.EmitLdcR8(0.0);
// 使用 Target 而不是直接使用标签作为参数是为了创建一个新的标签出来, 防止耦合
cursor.EmitBltUn(bleLabel.Target);
cursor.EmitBr(il.Instrs[cursor.Index + 3]);

NPC.HitModifiers.ToHitInfo

IL 代码修改

原 IL 代码

// hitInfo.SourceDamage = Math.Max((int)SourceDamage.ApplyTo(baseDamage), 1);
ldloca.s 1 // hitInfo
ldarg.0 // this
ldflda NPC.HitModifiers.SourceDamage
ldarg.1 // baseDamage
call StatModifier.ApplyTo(float)
conv.i4
ldc.i4.1
call Math.Max(int, int)
call NPC.HitInfo.set_SourceDamage(int)

预期 IL 代码

// hitInfo.SourceDamage = Math.Max((int)SourceDamage.ApplyTo(baseDamage), 0);
ldloca.s 1 // hitInfo
ldarg.0 // this
ldflda NPC.HitModifiers.SourceDamage
ldarg.1 // baseDamage
call StatModifier.ApplyTo(float)
conv.i4
ldc.i4.0
call Math.Max(int, int)
call NPC.HitInfo.set_SourceDamage(int)
IL 钩子
// 通过反射获得各种需要的 FieldInfo 和 MethodInfo
FieldInfo npcHitModifiersSourceDamage = typeof(NPC.HitModifiers).GetField(nameof(NPC.HitModifiers.SourceDamage));
MethodInfo statModifierApplyTo = typeof(StatModifier).GetMethod(nameof(StatModifier.ApplyTo));
MethodInfo mathMaxInt = typeof(Math).GetMethod(nameof(Math.Max), BindingFlags.Public | BindingFlags.Static, [typeof(int), typeof(int)]);
MethodInfo npcHitInfoSetSourceDamage = typeof(NPC.HitInfo).GetProperty(nameof(NPC.HitInfo.SourceDamage)).GetSetMethod();

ILCursor cursor = new(il);
cursor.GotoNext(MoveType.AfterLabel,
    i => i.MatchLdloca(1),
    i => i.MatchLdarg0(),
    i => i.MatchLdflda(npcHitModifiersSourceDamage),
    i => i.MatchLdarg1(),
    i => i.MatchCall(statModifierApplyTo),
    i => i.MatchConvI4(),
    i => i.MatchLdcI4(1),
    i => i.MatchCall(mathMaxInt),
    i => i.MatchCall(nPCHitInfoSetSourceDamage));
for (Instruction i = cursor.Next; !i.MatchLdcI4(1); i = i.Next) {
    cursor.Emit(i.OpCode, i.Operand);
}
cursor.EmitLdcI4(0);
cursor.EmitCall(mathMaxInt);
cursor.EmitCall(npcHitInfoSetSourceDamage);
cursor.EmitBr(il.Instrs[cursor.Index + 9]);

NPC.HitModifiers.GetDamage

IL 代码修改 1

原 IL 代码

// return Math.Clamp((int)dmg, 1, Math.Min(_damageLimit, 4));
ldloc.s 5 // dmg
conv.i4
ldc.i4.1
ldarg.0  // this
ldfld NPC.HitModifiers._damageLimit
ldc.i4.4
call Math.Min(int, int)
call Math.Clamp(int, int, int)
ret

预期 IL 代码

// return Math.Clamp((int)dmg, 0, Math.Min(_damageLimit, 4));
ldloc.s 5 // dmg
conv.i4
ldc.i4.0
ldarg.0  // this
ldfld NPC.HitModifiers._damageLimit
ldc.i4.4
call Math.Min(int, int)
call Math.Clamp(int, int, int)
ret
IL 钩子第 1 部分
// 通过反射获得各种需要的 FieldInfo 和 MethodInfo
npcHitModifiers_damageLimit= typeof(NPC.HitModifiers).GetField("_damageLimit", BindingFlags.NonPublic | BindingFlags.Instance);
MethodInfo mathMinInt = typeof(Math).GetMethod(nameof(Math.Min), bfps, [typeof(int), typeof(int)]);
MethodInfo mathClampInt = typeof(Math).GetMethod(nameof(Math.Clamp), bfps, [typeof(int), typeof(int), typeof(int)]);

ILCursor cursor = new(il);
cursor.GotoNext(MoveType.AfterLabel,
    i => i.MatchLdloc(5),
    i => i.MatchConvI4(),
    i => i.MatchLdcI4(1),
    i => i.MatchLdarg0(),
    i => i.MatchLdfld(npcHitModifiers_damageLimit),
    i => i.MatchLdcI4(4),
    i => i.MatchCall(mathMinInt),
    i => i.MatchCall(mathClampInt),
    i => i.MatchRet());
for(Instruction i = cursor.Next!; !i.MatchRet(); i = i.Next) {
    if(i.MatchLdcI4(1)) {
        cursor.EmitLdcI4(0);
        continue;
    }
    cursor.Emit(i.OpCode, i.Operand);
}
cursor.EmitBr(il.Instrs[cursor.Index + 8]);
IL 代码修改 2

原 IL 代码

// damage = Math.Max(damage - damageReduction, 1);
ldloc.0 // damage
ldloc.3 // damageReduction
sub
ldc.r4 1
call Math.Max(float, float)
stloc.0

预期 IL 代码

// damage = Math.Max(damage - damageReduction, 0);
ldloc.0 // damage
ldloc.3 // damageReduction
sub
ldc.r4 0
call Math.Max(float, float)
stloc.0
IL 钩子第 2 部分
MethodInfo mathMaxFloat = typeof(Math).GetMethod(nameof(Math.Max), bfps, [typeof(float), typeof(float)]);

// 使用上一部分的 cursor 接着搜索
cursor.GotoNext(MoveType.AfterLabel,
    i => i.MatchLdloc0(),
    i => i.MatchLdloc3(),
    i => i.MatchSub(),
    i => i.MatchLdcR4(1),
    i => i.MatchCall(mathMaxFloat),
    i => i.MatchStloc0());
cursor.EmitLdloc0();
cursor.EmitLdloc3();
cursor.EmitSub();
cursor.EmitLdcR4(0);
cursor.EmitCall(mathMaxFloat);
cursor.EmitStloc0();
cursor.EmitBr(il.Instrs[cursor.Index + 6]);
IL 代码修改 3

原 IL 代码

// return Math.Clamp((int)statModifier.ApplyTo(damage), 1, _damageLimit);
ldloca.s 6 // statModifier (this.FinalDamage)
ldloc.0    // damage
call StatModifier.ApplyTo(float)
conv.i4
ldc.i4.1
ldarg.0    // this
ldfld NPC.HitModifiers._damageLimit
call Math.Clamp(int,int,int)
ret

预期 IL 代码

// return Math.Clamp((int)statModifier.ApplyTo(damage), 0, _damageLimit);
ldloca.s 6 // statModifier (this.FinalDamage)
ldloc.0    // damage
call StatModifier.ApplyTo(float)
conv.i4
ldc.i4.0
ldarg.0    // this
ldfld NPC.HitModifiers._damageLimit
call Math.Clamp(int,int,int)
ret
IL 钩子第 3 部分
MethodInfo statModifierApplyTo = typeof(StatModifier).GetMethod(nameof(StatModifier.ApplyTo));

cursor.GotoNext(MoveType.AfterLabel,
    i => i.MatchLdloca(6),
    i => i.MatchLdloc0(),
    i => i.MatchCall(statModifierApplyTo),
    i => i.MatchConvI4(),
    i => i.MatchLdcI4(1),
    i => i.MatchLdarg0(),
    i => i.MatchLdfld(npcHitModifiers_damageLimit),
    i => i.MatchCall(mathClampInt),
    i => i.MatchRet());
for(Instruction i = cursor.Next!; !i.MatchRet(); i = i.Next) {
    if(i.MatchLdcI4(1)) {
        cursor.EmitLdcI4(0);
        continue;
    }
    cursor.Emit(i.OpCode, i.Operand);
}
cursor.EmitBr(il.Instrs[cursor.Index + 8]);

NPC.HitInfo.Damage 和 NPC.HitInfo.SourceDamage

注意 NPC.HitInfo 是值类型,在作为 this 传参时是使用的 ref 参数传递(也就是说传递的是地址,这也是前面出现了很多带有 a 后缀的指令的原因),而且 IL_Xxx 中不提供对属性的修改,需要使用 MonoModHooksHook

同时还要注意短方法(需要方法体较短,不包含复杂的逻辑控制(循环,switch 等)等条件)可能会被内联的问题,也就是说可能短方法中的代码会被直接整合入它的调用处,而再修改短方法,也就是给短方法上钩子也不会一并修改到原先它的调用处,此时可以对它的调用处所属方法上钩子,即使是没有任何实际作用的钩子,也可以让短方法的修改更新到此处。在这个例子中,只需要让挂上这两个钩子的顺序在挂其它钩子之前即可。

set_Damage 的 IL 代码修改

原 IL 代码

// this._damage = Math.Max(value, 1);
ldarg.0 // this
ldarg.1 // value
ldc.i4.1
call Math.Max(int, int)
stfld NPC.HitInfo._damage
ret

预期 IL 代码

// this._damage = Math.Max(value, 0);
ldarg.0 // this
ldarg.1 // value
ldc.i4.0
call Math.Max(int, int)
stfld NPC.HitInfo._damage
ret
set_SourceDamage 的 IL 代码修改

原 IL 代码

// this._sourceDamage = Math.Max(value, 1);
ldarg.0 // this
ldarg.1 // value
ldc.i4.1
call Math.Max(int, int)
stfld NPC.HitInfo._sourceDamage
ret

预期 IL 代码

// this._sourceDamage = Math.Max(value, 0);
ldarg.0 // this
ldarg.1 // value
ldc.i4.0
call Math.Max(int, int)
stfld NPC.HitInfo._sourceDamage
ret
set_Damageset_SourceDamage 的相同的 IL 钩子
ILCursor cursor = new(il);
cursor.GotoNext(MoveType.After, i => i.MatchLdcI4(1));
cursor.EmitPop();
cursor.EmitLdcI4(0);

Projectile.Damage

注意需要修改的方法有多个重载,我们需要修改的为:public int StrikeNPC(HitInfo hit, bool fromNet = false, bool noPlayerInteraction = false); ,它对应于 IL_NPC.StrikeNPC_HitInfo_bool_bool

以下的 CanDamage 为一个 bool 值,可以通过配置等方式修改。

IL 代码修改

原 IL 代码

// if (flag3)
ldloc.s 10  // flag3 (this.damage > 0)
brfalse label

预期 IL 代码

// if (CanDamage ? damage >= 0 : flag3)
ldarg.0 // this
ldloc 10    // flag3
call delegate // (p, b) => CanDamage ? p.damage >= 0 : b
brfalse label
IL 钩子
ILCursor cursor = new(il);
cursor.GotoNext(MoveType.AfterLabel, i => i.MatchLdloc(10), il => il.MatchBrfalse(out _));
cursor.EmitLdarg0();
cursor.EmitLdloc(10);
cursor.EmitDelegate((Projectile p, bool flag) => CanDamage ? p.damage >= 0 : flag);
cursor.EmitBr(il.Instrs[cursor.Index + 1]);

进行实机测试

测试各种情况…

弹幕和武器对敌对 NPC 的零伤害测试
敌对 NPC 对友好 NPC 的弹幕和碰撞伤害测试
来自玩家的敌对弹幕对友好 NPC 的弹幕伤害测试

下面是 0 伤害弹幕是否能造成伤害的测试。

首先是原版的发条式突击步枪的效果:

原版发条步枪

然后我通过某些方法使它的伤害归零,以下为不重载模组而只是修改配置的情况下它的不同表现:

零伤害弹幕不能造成伤害
零伤害弹幕可以造成伤害

可见这样 IL 修改就完全成功了!

源码

这里附上这个示例的源码:

结语

尽管我上面提了很多的兼容性建议,但那基本也只能让游戏不直接崩溃而已,如果修改了同一处或者相关的逻辑,那么轻则有一方不生效,或者两边都不按照预期工作,重则会有各种逻辑问题,甚至仍然会直接崩掉游戏。也就是说 IL 修改本身的兼容性还是很差的,如果有其他方案尤其是 tml 有提供的接口或方法等时还请尽量选用其他方案,即使是使用 On 一般也比 IL 要好(只要你不在 On 里面把 orig 吃了)。IL 只是在迫不得已,没有其它方案的情况下才使用的,即使通过这篇教程或者其他的方式学会了 IL 也请不要滥用,谨记!(当然如果你觉得某个修改确实很常用但 tml 又确实没有你也可以直接和 tml 提 issue,或者 pr,或者直接混入 tml???)

最后如果仍然有什么疑问或者发现这篇教程有什么问题都可以在评论里说,欢迎在这里讨论相关的话题~

发表回复