注入

Mixin提供了一套丰富的注解库,用于标记不同的注入方式。
Mixin注入原理参考Flippin' Mixins, how do they work?,简而言之,Mixin注入类中的大部分方法,以及某些字段最后会合并到目标类中。

建议配合官方文档使用。

接下来将以花式修改Minecraft窗体标题为例。

创建Mixin注入类

  1. 在json配置文件中指定的包下创建一个新的类(本教程以com.example.mixins为例),类名建议以「Mixin + 目标类名」的形式命名。因为标题相关内容是在Minecraft类中定义的,所以创建一个名为 MixinMinecraft 的类。注入类建议加上abstract关键词。
  2. 在类上添加@Mixin注解,此注解有以下属性:

    • value: 用于标记一个或多个目标类,比如 Minecraft.class
    • targets: 同上,只不过是用于标记非公有类或匿名类,比如 com.example.ExampleClass$1
    • priority: 设置此注入类注入的优先级
    • remap: 如果目标类在MixinGradle中设置的混淆表中不存在,那么建议将此属性设为true,否则编译时会抛出一个警告

      所以一个最基本的注入类如下:

      package com.example.mixins;
      
      import net.minecraft.client.Minecraft;
      import org.spongepowered.asm.mixin.Mixin;
      
      @Mixin(Minecraft.class)
      public abstract class MixinMinecraft {
      
      }
      

开始注入

首先需要了解的一个基本原理:Mixin在运行时会把注入类合并到目标类中去。虽然不是简单的直接合并,不过为了之后的方便解释,目前可以这么理解。
Mixin依靠一套强大而又完备的注解系统运作,通过注解识别如何注入修改方法。
我们还是以修改窗体标题为例,Minecraft窗体标题在createDisplay方法中定义。

  • @Overwrite文档
    相信你看到注解名称就知道这个注解是做什么的了——允许注入方法重写整个目标方法。
    很明显,这种修改方式引起冲突的概率也会比其他注入方式高很多,所以Sponge建议不到万不得已,尽量不要使用它,以及建议为这些注入方法写一个Javadoc,否则编译时会抛出一个警告。但是作为教程,我们还是要说一说这个该怎么用。
    被此注解标记的方法的访问级不能低于目标方法的访问级,但是有一个注意点:其他模组可能使用FMLAT等方式修改目标方法的访问级;注入方法的方法签名要和目标方法一致。

所以我们大概可以这么写:

    @Overwrite
    private void createDisplay() throws LWJGLException {
        Display.setResizable(true);
        Display.setTitle("MyCustomTitle");
        try {
            Display.create((new PixelFormat()).withDepthBits(24));
        } catch (LWJGLException lwjglexception) {
            LOGGER.error("Couldn't set pixel format", lwjglexception);
            try {
                Thread.sleep(1000L);
            } catch (InterruptedException e) {}
            if (this.fullscreen) {
                this.updateDisplayMode();
            }
            Display.create();
        }
    }

且慢!我们引用了几个在Minecraft中的私有字段和方法,然而在MixinMinecraft中这些字段和方法并不存在,但是MixinMinecraft最后是要合并到Minecraft中去的,所以直接访问这些字段和方法本应当是合法的。好在Mixin给我们提供了一个注解,用于标记本应存在于目标类的字段和方法:

  • @Shadow文档
    被这个注解标记的字段用于指示那些本应在目标类的字段,它们最后不会被合并到目标类中去。如果要引用目标类父类的字段,参见融合一章的访问目标类父类成员

所以现在应该变成这样:

package com.example.mixins;

import net.minecraft.client.Minecraft;
import org.apache.logging.log4j.Logger;
import org.lwjgl.LWJGLException;
import org.lwjgl.opengl.Display;
import org.lwjgl.opengl.PixelFormat;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Overwrite;
import org.spongepowered.asm.mixin.Shadow;

@Mixin(Minecraft.class)
public abstract class MixinMinecraft {
    @Shadow
    private static Logger LOGGER; // 我们不必给Shadow字段赋值
    @Shadow
    private boolean fullscreen;

    @Overwrite
    private void createDisplay() throws LWJGLException {
        Display.setResizable(true);
        Display.setTitle("MyCustomTitle");
        try {
            Display.create((new PixelFormat()).withDepthBits(24));
        } catch (LWJGLException lwjglexception) {
            LOGGER.error("Couldn't set pixel format", lwjglexception);
            try {
                Thread.sleep(1000L);
            } catch (InterruptedException e) {}
            if (this.fullscreen) {
                this.updateDisplayMode();
            }
            Display.create();
        }
    }

    @Shadow
    protected abstract void updateDisplayMode();
    // 这就是为什么建议让注入类设为抽象类的原因之一了——不必实现整个方法
    // 但是抽象方法的访问级最低只能是protected。
}

但是我们注意到原本LOGGER字段是final的,但是我们没有给它赋值,所以不能直接加final关键词,因此为了避免我们不小心修改了它,Mixin提供了另外一个注解:

  • @Final文档
    这个注解必须标记在已被@Shadow标记的字段或方法上,并且这个字段或方法原本在目标类中必须被final修饰,如果在程序中修改了被此注解标记的字段,那么编译时会抛出一个错误。

所以现在我们应该这样:

    @Final
    @Shadow
    private static Logger LOGGER;

如果你想要刻意地修改一个final成员,请在@Final注解之后再添加下面这个注解:

  • @Mutable文档
    用于标记一个被@Final标记的成员,表明这个成员可以被修改。

现在还有一个问题,举个例子:
  查阅MCP映射表,我们会发现NetworkPlayerInfo$1下有一个名为field_177224_a的字段,但是我们查找开发环境中的Minecraft代码的时候,是找不到这个field_177224_a的。那么这个字段是什么呢?
  如果我们想要在MixinNetworkPlayerInfo$1中像NetworkPlayerInfo$1.skinAvailable方法中引用NetworkPlayerInfo.this.playerTextures的话,你会发现根本引用不了!这就解释了field_177224_a是什么了:是存放NetworkPlayerInfo实例的字段,这是一个synthetic字段。
  在Java中,对于内部类而言,一定会有一个字段用于存放外部类实例,并且内部类的构造方法的第一个参数一定是用于初始化这个字段的,这样引用外部类.this的时候对于JVM而言相当于在引用这个synthetic字段。
  这个外部类实例字段一般名称都叫this$0(你可能需要借助ByteCodeOutline之类的插件看一看具体叫什么),但是Minecraft本身是做过混淆的,会把this$0混淆成一些毫无意义的字符,MCP反混淆的时候,会给它赋予一个searge name,而到了开发环境中,它就会变成this$0,不同于其他的字段,field_177224_a -> this$0这个映射并不存在于映射表中,而且它在开发环境中也并不显式地存在,所以编译时Mixin就会无从下手。如果我们直接用@Shadow引用它,无论下面哪种方式,都会报错:

@Mixin(targets = "net.minecraft.client.network.NetworkPlayerInfo$1")
public abstract class MixinNetworkPlayerInfo$1 {
    @Shadow
    private NetworkPlayerInfo field_177224_a;
}

@Mixin(targets = "net.minecraft.client.network.NetworkPlayerInfo$1")
public abstract class MixinNetworkPlayerInfo$1 {
    @Shadow
    private NetworkPlayerInfo this$0;
}

这时候,@Shadow中的aliases属性就派上用场了,用于指定一个合成成员的名称,不过这个字段名还是得写成对应searge name,因为生产环境下并没有this$0这种东西:

@Mixin(targets = "net.minecraft.client.network.NetworkPlayerInfo$1")
public abstract class MixinNetworkPlayerInfo$1 {
    @Shadow(aliases = "this$0")
    private NetworkPlayerInfo field_177224_a;
}

这对于@Overwrite中的aliases解释是一致的。 当然了,不仅仅是this$0,在其他很多场合都会出现合成成员,比如方法体内的内部类可以引用方法体内的局部变量,这些局部变量也会在内部类中产生合成成员。

好了,很明显,用@Overwrite有高射炮打蚊子的感觉,我们只是修改一个标题诶!
所以Mixin提供了很多丰富多样的注解,应用于各种姿势修改方法体内的字节码。

现在我们考虑一下还有哪些可行的修改方案可以达到修改标题的目的:

  • 可以在setTitle之后再重新设置一次标题:

      private void createDisplay() throws LWJGLException {
          Display.setResizable(true);
          Display.setTitle("Minecraft 1.12.2");
          this.mixinMethod(); // <--注意看这里
          try {
              Display.create((new PixelFormat()).withDepthBits(24));
          } catch (LWJGLException lwjglexception) {
              LOGGER.error("Couldn't set pixel format", lwjglexception);
              try {
                  Thread.sleep(1000L);
              } catch (InterruptedException e) {}
              if (this.fullscreen) {
                  this.updateDisplayMode();
              }
              Display.create();
          }
      }
    
      private void mixinMethod() {
          Display.setTitle("MyCustomTitle");
      }
    

    针对这种修改方式,Mixin提供了下面这个注解:

    • @Inject文档
      关于这个注解的Wiki: Advanced Mixin Usage Callback Injectors
      这个注解允许模组在目标方法体内合适的位置添加对自己指定的方法调用。
      我们现在就有可能用到的几个属性:
      • id: 指定此注解的识别名称,不设置则默认为注入的目标方法(注入失败时会在日志中显示)
      • method: 用于指定注入的一个或多个目标方法,以下几种写法都是合法的:
        • createDisplay
        • createDisplay()V
        • net.minecraft.client.Minecraft.createDisplay
        • net/minecraft/client/Minecraft.createDisplay
        • Lnet/minecraft/client/Minecraft;createDisplay
        • net.minecraft.client.Minecraft.createDisplay()V
        • net/minecraft/client/Minecraft.createDisplay()V
        • Lnet/minecraft/client/Minecraft;createDisplay()V
          需要注意的是:如果一个类中含有多个同名方法,而你没有指定注入的方法签名,那么Mixin就会只注入匹配到的第一个方法。如果你想注入每个同名方法,可以用下面这种写法:
        • createDisplay* : (其实就是名称末尾加个*
      • slice: 指定一个或多个@Slice注解,用于指定注入位置的存在范围
      • at: 指定一个或多个@At注解,用于指定注入的位置
      • cancellable: 决定目标方法调用完注入方法后是否取消运行接下来的部分并直接返回(设置为true之后需要在注入方法中手动调用cancel方法)
      • require: 指示匹配次数最少应该有多少次(运行时少于这个次数会抛出异常)
      • allow: 指示匹配次数最多应该有多少次(设置得比require小或者小于1则会忽略,运行时多于这个次数会抛出异常)
    • @At文档
      在继续说明@Inject注解之前,有必要说明@At注解的用法。
      此注解有以下几个比较重要的属性:
      • value: 这个属性的值决定了argstargetordinalopcode能填哪些内容
        Wiki中已经讲得比较详细了: Injection Point Reference
        或者参考Mixin定位
      • slice: 选择一个在外部注解中定义的@Slice注解的id(因为比如@Inject注解中的atslice属性都是数组,可以指定多个@At和多个@Slice,所以这个时候指定哪个@At应用哪个@Slice就很有必要了)
      • shift: 决定注入位置相对于匹配到的位置向前或向后移动
      • by: 当shift属性设置为At.Shift.BY时,设置相对移动的格数(允许设置为负数,如果绝对值大于3,那么应该考虑换个匹配规则)
    • @Slice文档

      • id: 指定此注解的识别名称(给@At识别用的)
      • from: 用@At匹配范围从哪开始
      • to: 到哪结束

      Mixin推荐所有注入方法的访问级都设为private(同样适用于后面介绍的几个注解),并且如果目标方法是静态方法,那么注入方法也必须是静态方法。
      @Inject标记的方法有如下要求:

    • 返回值类型必须是void
    • 所有合法的方法名皆可,Mixin合并时会重命名这些注入方法
    • 如果有必要,注入方法可以添加目标方法的参数,且必须加在参数开头,与目标方法参数的顺序和类型必须一致

      因此,如果用@Inject注解表示上述修改方案的话,应该像这样:

      package com.example.mixins;
      
      import net.minecraft.client.Minecraft;
      import org.lwjgl.opengl.Display;
      import org.spongepowered.asm.mixin.Mixin;
      import org.spongepowered.asm.mixin.injection.At;
      import org.spongepowered.asm.mixin.injection.Inject;
      import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
      
      @Mixin(Minecraft.class)
      public abstract class MixinMinecraft {
        // 如果目标方法无返回值类型,那么在添加目标方法参数之后必须有一个参数,且必须是 CallbackInfo 类型。
        @Inject(
            method = "createDisplay",
            at = @At(
                value = "INVOKE", // 表示注入在方法被调用之前
                shift = At.Shift.AFTER, // 把注入点往后移一位,也就变成注入在方法被调用之后
                target = "Lorg/lwjgl/opengl/Display;setTitle(Ljava/lang/String;)V",
                remap = false // <-- 因为 Display.setTitle 并不存在于混淆表中,所以设置 remap 属性为 false
            )
        )
        private void inject_createDisplay(CallbackInfo ci) {
            Display.setTitle("MyCustomTitle");
            // ci.cancel() // <-- 如果调用了 CallbackInfo::cancel ,就表示执行完注入方法后目标方法直接return,不再执行原方法剩余的代码。
            // cancel() 方法能被调用的前提是在 @Inject 里设置 cancellable 属性为 true ,否则会抛出异常。
        }
      }
      

      如果@Inject注入的是带返回值类型的方法,那么跟上面介绍的就会有所差别。 假设有一个需求:在Minecraft窗体处于未激活状态时,自动锁定每秒5帧(这样可以有效降低CPU占用)。已知控制最大帧数的方法是Minecraft.getLimitFramerate。那么应该写成像下面这样:

      public int getLimitFramerate() {
        if (!Display.isActive()) {
            return 5;
        }
        return this.world == null && this.currentScreen != null ? 30 : this.gameSettings.limitFramerate;
      }
      

      那么我们该怎么用@Inject来达成我们的目的呢?

      package com.example.mixins;
      
      import net.minecraft.client.Minecraft;
      import org.lwjgl.opengl.Display;
      import org.spongepowered.asm.mixin.Mixin;
      import org.spongepowered.asm.mixin.injection.At;
      import org.spongepowered.asm.mixin.injection.Inject;
      import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
      
      @Mixin(Minecraft.class)
      public abstract class MixinMinecraft {
        // 对于目标方法有返回值的情况,在添加目标方法参数之后一个参数必须是 CallbackInfoReturnable 类型,且泛型类型必须是目标方法的返回值类型;
        // 如果未设置 cancellable 为 true 而强行执行 cancel() 或者 setReturnValue() 方法,会抛出 CancellationException 异常
        // 对于 CallbackInfoReturnable 而言,在 setReturnValue() 方法中包含了 cancel() 方法
        @Inject(
            method = "getLimitFramerate",
            at = @At("HEAD"),
            cancellable = true
        )
        private void inject_getLimitFramerate(CallbackInfoReturnable<Integer> cir) {
            if (!Display.isActive()) {
                cir.setReturnValue(5);
            }
        }
        // 在调用 cir.setReturnValue() 之前:
        // 如果 @At.value 的值是 RETURN 或者 TAIL ,那么调用 cir.getReturnValue() 时的返回值就是在目标方法中即将返回的值;对于其他 @At.value 的情况,调用它的返回值都是 null 。
      }
      

      这样我们在运行时就类似于执行以下代码(不完全准确):

      public int getLimitFramerate() {
        CallbackInfoReturnable<Integer> cir = new CallbackInfoReturnable<>("getLimitFramerate", true);
        this.inject_getLimitFramerate(cir);
        if (cir.isCancelled()) {
            return cir.getReturnValue();
        }
        return this.world == null && this.currentScreen != null ? 30 : this.gameSettings.limitFramerate;
      }
      

      如果你想注入者构造方法,那么at的值只能是@At("RETURN")

      @Inject(method = "<init>", at = @At("RETURN"))
      // 构造方法只允许注入到末尾,因为Mixin需要保证调用你的方法之前整个类被完整地初始化
      

      @Inject也允许注入方法捕获目标方法内的局部变量,这可能有些复杂。
      因为Minecraft在混淆时抹除了所有方法的局部变量表,因此Mixin获得的局部变量表都是根据方法体的字节码分析出来的,所以这样生成的局部变量表很容易被别的模组修改,所以Mixin建议慎用局部变量捕获功能。
      我们需要定义@Inject内的locals参数,类型是LocalCapture,它是一个枚举类型,有以下枚举值:

    • NO_CAPTURE: 默认值,表示不捕获局部变量
    • PRINT: 捕获局部变量,但不注入方法,仅在控制台打印对应注入点可捕获的局部变量表
    • CAPTURE_FAILSOFT: 捕获局部变量,如果捕获失败,不注入该方法,在日志中输出一个异常
    • CAPTURE_FAILHARD: 捕获局部变量,如果捕获失败,抛出一个错误,终止游戏
    • CAPTURE_FAILEXCEPTION: 捕获局部变量,如果捕获失败,生成一个带异常的方法存根,并应用注入

      Mixin建议第一次注入该方法时将locals设为LocalCapture.PRINT,以便确定具体的局部变量表。
      我们以下面这段代码为例:

      package com.example.mixins;
      
      import net.minecraft.client.Minecraft;
      import org.spongepowered.asm.mixin.Mixin;
      import org.spongepowered.asm.mixin.injection.At;
      import org.spongepowered.asm.mixin.injection.Inject;
      import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
      import org.spongepowered.asm.mixin.injection.callback.LocalCapture;
      
      @Mixin(Minecraft.class)
      public abstract class MixinMinecraft {
        @Inject(
            method = "setWindowIcon",
            at = @At(value = "INVOKE", target = "Lnet/minecraft/client/resources/DefaultResourcePack;getInputStreamAssets(Lnet/minecraft/util/ResourceLocation;)Ljava/io/InputStream;"),
            locals = LocalCapture.PRINT
        )
        private void inject_setWindowIcon(CallbackInfo ci) {}
      }
      

      在匹配到注入点后,将会在控制台输出以下信息(只会输出到控制台,并不会输出到日志中):(这个会输出两次,因为有两个匹配点)

      /***************************************************************************************************************************************************/
      /*       Target Class : net.minecraft.client.Minecraft                                                                                             */
      /*      Target Method : private void setWindowIcon()                                                                                               */
      /*  Target Max LOCALS : 6                                                                                                                          */
      /* Initial Frame Size : 1                                                                                                                          */
      /*      Callback Name : handler$inject_setWindowIcon$zza000                                                                                        */
      /*        Instruction : InjectionNode INVOKEVIRTUAL                                                                                                */
      /***************************************************************************************************************************************************/
      /*   LOCAL                  TYPE  NAME                                                                                                             */
      /*   [  0]             Minecraft  this                                                                                                             */
      /* > [  1]           Util$EnumOS  util$enumos                                        <capture>                                                     */
      /*   [  2]           InputStream  inputstream                                        <capture>                                                     */
      /*   [  3]           InputStream  inputstream1                                       <capture>                                                     */
      /*   [  4]                     -                                                                                                                   */
      /*   [  5]                     -                                                                                                                   */
      /***************************************************************************************************************************************************/
      /*                                                                                                                                                 */
      /* /**                                                                                                                                             */
      /*  * Expected callback signature                                                                                                                  */
      /*  * /                                                                                                                                            */
      /* private void handler$inject_setWindowIcon$zza000(CallbackInfo ci, Util$EnumOS util$enumos, InputStream inputstream, InputStream inputstream1) { */
      /*     // Method body                                                                                                                              */
      /* }                                                                                                                                               */
      /*                                                                                                                                                 */
      /***************************************************************************************************************************************************/
      

      注意下面输出的Expected callback signature,这个就是Mixin认为能成功捕获的注入方法的方法签名,也是注入点所在的作用域能使用的所有的局部变量,我们只需要把它认为合适的方法参数复制到我们注入方法中,并根据需求修改合适的locals的值即可:

      @Inject(
        method = "setWindowIcon",
        at = @At(value = "INVOKE", target = "Lnet/minecraft/client/resources/DefaultResourcePack;getInputStreamAssets(Lnet/minecraft/util/ResourceLocation;)Ljava/io/InputStream;"),
        locals = LocalCapture.CAPTURE_FAILSOFT
      )
      private void inject_setWindowIcon(CallbackInfo ci, Util.EnumOS util$enumos, InputStream inputstream, InputStream inputstream1) {}
      // 我们不一定要把预期参数全填上,也可以只选择前几个,比如:
      // private void inject_setWindowIcon(CallbackInfo ci, Util.EnumOS util$enumos, InputStream inputstream)
      // private void inject_setWindowIcon(CallbackInfo ci, Util.EnumOS util$enumos)
      // 上面这两个也是符合要求并能成功注入的
      

      有的时候,我们事先已经知道某些非常流行的模组会对目标方法的局部变量表进行更改,或者是匹配多个位置时每个位置的可用局部变量不同,这个时候Mixin允许对注入方法进行重载,并加上下面这个注解:

    • @Surrogate文档

      但是出于某些奇怪的原因@Surrogate方法的参数个数不能多于@Inject方法,因此我建议把参数个数最多的方法标记为@Inject,其余重载方法标记为@Surrogate,此时的@Surrpgate方法参数必须包含所有可用的局部变量,而不能仅有前几个。 假如有一个模组对setWindowIcon的注入点的作用域加了两个int类型的局部变量,如果添加了这个模组,那么局部变量类型就会变成MinecraftintintUtil$EnumOSInputStreamInputStream,那么我们自己的模组就应该这样写:

      @Inject(
        method = "setWindowIcon",
        at = @At(value = "INVOKE", target = "Lnet/minecraft/client/resources/DefaultResourcePack;getInputStreamAssets(Lnet/minecraft/util/ResourceLocation;)Ljava/io/InputStream;"),
        locals = LocalCapture.CAPTURE_FAILSOFT
      )
      private void inject_setWindowIcon(CallbackInfo ci, int i, int j, Util.EnumOS util$enumos, InputStream inputstream, InputStream inputstream1) {}
      
      @Surrogate
      private void inject_setWindowIcon(CallbackInfo ci, Util.EnumOS util$enumos, InputStream inputstream, InputStream inputstream1) {}
      
      @Surrogate
      private void inject_setWindowIcon(CallbackInfo ci, Util.EnumOS util$enumos, InputStream inputstream, InputStream inputstream1, CallbackInfo ci1) {}
      // 注意这个方法参数会多出一个 CallbackInfo ,因为这个例子下指定的 @Inject.at 会在目标方法里匹配两次,而且这两次恰好在同一作用域当中,所以后面的 CallbackInfo 正是匹配第一次时的那个。
      
      //@Surrogate private void inject_setWindowIcon(CallbackInfo ci, int i, int j, Util.EnumOS util$enumos, InputStream inputstream, InputStream inputstream1, CallbackInfo ci1) {}
      // 这个方法不会被匹配到,因为 @Surrogate 方法的参数个数不会多于 @Inject 方法的参数个数
      

      之前说到@Inject中的requireallow可以检查匹配次数,但是这是检查@Inject方法和与这个方法附带的所有@Surrogate方法的总共的匹配次数的。
      如果你想对某个的@Inject方法或者@Surrogate方法单独安排检查匹配次数,可以在对应的方法上添加下面这个注解:

    • @Group文档

      • name: 名称(报错时会在日志中显示)
      • min: 同@Inject.require
      • max: 同@Inject.allow

      例如:

      @Surrogate @Group(max = 1) // 不会报错,因为这个方法最多只能匹配到一次
      private void inject_setWindowIcon(CallbackInfo ci, Util.EnumOS util$enumos, InputStream inputstream, InputStream inputstream1) {}
      
      @Inject(
        method = "setWindowIcon",
        at = @At(value = "INVOKE", target = "Lnet/minecraft/client/resources/DefaultResourcePack;getInputStreamAssets(Lnet/minecraft/util/ResourceLocation;)Ljava/io/InputStream;"),
        locals = LocalCapture.CAPTURE_FAILSOFT,
        require = 2 // 不会报错,因为两个方法总共最少能匹配到两次
      )
      private void inject_setWindowIcon(CallbackInfo ci, Util.EnumOS util$enumos, InputStream inputstream, InputStream inputstream1, CallbackInfo ci1) {}
      

results matching ""

    No results matching ""