Skip to content

Dynamic Mixins

Matthew Olsson edited this page Sep 2, 2023 · 5 revisions

Dynamic Mixins

CTJS 3.0 replaces the old ASM injection with a dynamic Mixin system, allowing module authors to write their own Mixins!

Why Mixins?

There are a few reasons we replaced our old ASM system with Mixins, and they are the same reason that Mixins are generally preferred in the modding community over ASM injection:

  • They are easier to understand. You write Java code (or in this case, JavaScript code) instead of JVM bytecode.
  • They are safer. Multiple different Mixins targeting the same method have less of a chance of getting in each other's way compared to two different ASM injections targeting the same method.
    • This is especially true if one uses the chaining injectors from MixinExtras.
  • They offload much of the work to the Mixin system

This wiki page will serve as a tutorial for writing dynamic Mixins in a CT module. This is not a Mixin tutorial! If you are unfamiliar with Mixins, see this fabric wiki page.

Writing your first Dynamic Mixin

To get started, create a new JS file in your module. This file will hold all of your custom Mixins. After creating this file, open metadata.json and add the entry "mixinEntry": "mixins.js", assuming you called your file mixins.js. This is how our loader knows which file to load during Mixin application. This file cannot import other module files. Normal module files are allowed to class-load MC classes whenever they want, but during Mixin application, no MC classes are allowed to be class loaded. While you can use Java.type to class-load non-MC classes, it is a modified version, and will throw if you attempt to load a class from the net.minecraft or com.mojang.blaze3d package.

Now put the following code into your Mixins file:

// mixins.js
const chatHudMixin = new Mixin('net.minecraft.client.gui.hud.ChatHud');

export const chatHud_modifyChatOffset = chatHudMixin.modifyConstant({
    method: 'render',
    constant: new Constant({
        floatValue: 4,
        ordinal: 0,
    }),
});

The first step is creating a Mixin object that targets a specific class, which happens on line one. Once we have that Mixin object, we can call any of the following injector methods: inject, redirect, modifyArg, modifyArgs, modifyConstant, modifyExpressionValue, modifyReceiver, modifyReturnValue, modifyVariable, wrapOperation, and wrapWithCondition. These methods take an options bag which takes options that usually correspond 1-to-1 with a property on the corresponding Java annotation. We'll talk more about some key differences below.

We pass our desired method and constant to the injector, and we get back a callback object which we will use to attach our code to. We export this object so we can use it in our normal module code. It is worth pointing out that Mixin and Constant are already provided in the global scope. We also provide At and Slice which correspond to their Java equivalents. We also provide Local and some descriptor helpers, which we'll get to later.

At this point we've injected into the ChatHud class, but we don't actually do anything with it. Let's change that by importing this object in a different file:

// index.js
import { chatHud_modifyChatOffset } from './mixins.js';

chatHud_modifyChatOffset.attach(() => 50);

After we import it, we call attach and pass in our function. This will be called when our injection fires, and whatever we return will be returned by the Mixin. Note that you can call .attach again at a later point to change the Mixin's behavior. This Mixin in particular changes the offset of the ChatHud from 4 pixels to 50. If you start the game, you should see something similar to this:

image of at offset chat hud

Callback Arguments

The callback you pass to attach will receive the following arguments:

  • The receiver (this value) of the method you are injecting into. If the method is static, this argument is omitted
  • The Mixin arguments. These are the arguments that you would normally have to write in your Java Mixin, forwarded into your JS callback.
  • Any Local objects you have captured (see the next section).

Locals

All the injectors accept a locals key in their options bag. This corresponds to the @Local annotation from MixinExtras, and allows any local variables to be captured. This is also the primary mechanism for capturing parameters, as the Mixins generated by CT do not capture the parameter list of the injected method when they don't have to. There are three ways to use Local:

new Local({ type: 'F', index: 2 }); // Capture the 3rd local of type float
new Local({ type: 'F', ordinal: 1 }); // Capture the 2nd float local 
new Local({ print: true }); // Dump a list of possible locals and don't capture anything, used for debugging

Access Wideners

We don't provide any equivalent for @Accessor and @Invoker. Instead, we provide a method for directly widening access to fields and methods. To do this, call the widenField(name: string, mutable?: boolean) or widenMethod(name: string, mutable?: boolean) methods on Mixin.

Remapping

Unlike in older CT versions, in CT 3.0+ you do not have to worry about remapping anything. This applies not only to types/methods/fields for dynamic Mixins, but also to property access during runtime as well! This means that in your attach callbacks (and anywhere else you use MC classes), you do not have to use mapped property name (although you can; they will still work).

Provided API

As we've seen with Mixin and Constant, we provide an API for you to write Mixins. Here is a full TypeScript declaration file for this API:

Typings

declare const Java: {
    /**
     * Returns the Java class associated with [className]. During mixin application, does
     * not allow loading mapped class type (types in the `net.minecraft` or 
     * `com.mojang.blaze3d` packages).
     */
    type(className: string): any /* Class */;
};

declare const Opcodes: {
    [key: string]: number;
};

declare const Condition: {
    [key: string]: object;
};

////////////////////////
// Descriptor helpers //
////////////////////////

declare const void_: 'V';
declare const boolean: 'Z';
declare const char: 'C';
declare const byte: 'B';
declare const short: 'S';
declare const int: 'I';
declare const float: 'F';
declare const long: 'J';
declare const double: 'D';

/**
 * A helper for creating field and method descriptors.
 */
declare function desc(options: {
    owner?: string;
    name?: string;
    /**
     * Used for field descriptors to specify the field type. Cannot be used with `args` or `ret`.
     */
    type?: string;
    args?: string[];
    ret?: string;
}): string;

/**
 * Helper for creating class descriptors. Instead of writing
 * "La/b/c;", this allows you to write "J`a.b.c`"
 */
declare function J(strings: string[], exprs: string[]): string;

////////////
// Mixins //
////////////

/**
 * The entry point to a mixin. Note that CT mixins for the most part do not support
 * multi-target injections, hence why target is not an array.
 */
declare const Mixin: IMixin;

/**
 * See org.spongepowered.asm.mixin.Mixin for more information on each parameter.
 * The string type is treated as `{ target: <string value> }`.
 */
interface IMixin {
    new (options: string | {
        target: string;
        priority?: number;
        remap?: boolean;
    }): IMixin;

    /**
     * See org.spongepowered.asm.mixin.injection.Inject for more information on each parameter. 
     */
    inject(options: {
        method: string;
        id?: string;
        slices?: Slice | Slice[];
        at?: At | At[];
        cancellable?: boolean;
        locals?: Local | Local[];
        remap?: boolean;
        require?: number;
        expect?: number;
        allow?: number;
        constraints?: string;
    }): IMixinCallback;

    /**
     * See org.spongepowered.asm.mixin.injection.Redirect for more information on each parameter. 
     */
    redirect(options: {
        method: string;
        slice?: Slice;
        at?: At;
        args?: string[];
        returnType: string;
        locals?: Local | Local[];
        require?: number;
        expect?: number;
        allow?: number;
        constraints?: string;
    }): IMixinCallbackReturnable;

    /**
     * See org.spongepowered.asm.mixin.injection.ModifyArg for more information on each parameter. 
     */
    modifyArg(options: {
        method: string;
        slice?: Slice;
        at?: At;
        index?: number;
        /**
         * Will cause all parameters of the targeted method to be captured and passed to the callback
         * instead of just the parameter given by `index`.
         */
        captureAllParams?: boolean;
        locals?: Local | Local[];
        remap?: boolean;
        expect?: number;
        allow?: number;
        constraints?: string;
    }): IMixinCallbackReturnable;

    /**
     * See org.spongepowered.asm.mixin.injection.ModifyArgs for more information on each parameter. 
     * Note that because the argument types will be abstracted away at runtime behind the single
     * `Args` object, special care must be taken to provide the correct types. For example, all numbers in
     * Rhino are `double`s, so if you need to set a float argument, you would need to do something
     * along the lines of `args.set(0, new java.lang.Float(1))`.
     */
    modifyArgs(options: {
        method: string;
        slice?: Slice;
        at: At;
        locals?: Local | Local[];
        require?: number;
        remap?: boolean;
        expect?: number;
        allow?: number;
        constraints?: string;
    }): IMixinCallbackReturnable;

    /**
     * See org.spongepowered.asm.mixin.injection.ModifyConstant for more information on each parameter. 
     */
    modifyConstant(options: {
        method: string;
        slice?: Slice | Slice[];
        constant: Constant;
        locals?: Local | Local[];
        require?: number;
        remap?: boolean;
        expect?: number;
        allow?: number;
        constraints?: string;
    }): IMixinCallbackReturnable;

    /**
     * See com.llamalad7.mixinextras.injector.ModifyExpressionValue for more information on each parameter. 
     */
    modifyExpressionValue(options: {
        method: string;
        at: At;
        slices?: Slice | Slice[];
        locals?: Local | Local[];
        remap?: boolean;
        require?: number;
        expect?: number;
        allow?: number;
    }): IMixinCallbackReturnable;

    /**
     * See com.llamalad7.mixinextras.injector.ModifyReceiver for more information on each parameter. 
     */
    modifyReceiver(options: {
        method: string;
        at: At;
        slices?: Slice | Slice[];
        locals?: Local | Local[];
        remap?: boolean;
        require?: number;
        expect?: number;
        allow?: number;
    }): IMixinCallbackReturnable;

    /**
     * See com.llamalad7.mixinextras.injector.ModifyReturnValue for more information on each parameter. 
     */
    modifyReturnValue(options: {
        method: string;
        at: At;
        slice?: Slice | Slice[];
        locals?: Local | Local[];
        remap?: boolean;
        require?: number;
        expect?: number;
        allow?: number;
    }): IMixinCallbackReturnable;

    /**
     * See org.spongepowered.asm.mixin.injection.ModifyVariable for more information on each parameter. 
     */
    modifyVariable(options: {
        method: string;
        at: At;
        slice?: Slice;
        /**
         * See [Local]
         */
        print?: boolean;
        /**
         * See [Local]
         */
        ordinal?: number;
        /**
         * See [Local]
         */
        index?: number;
        /**
         * See [Local]
         */
        type?: string;
        locals?: Local | Local[];
        remap?: boolean;
        require?: number;
        expect?: number;
        allow?: number;
        constraints?: string;
    }): IMixinCallbackReturnable

    /**
     * See com.llamalad7.mixinextras.injector.WrapOperation for more information on each parameter. 
     */
    wrapOperation(options: {
        method: string;
        at?: At;
        constant?: Constant;
        slice?: Slice | Slice[];
        locals?: Local | Local[];
        remap?: boolean;
        require?: number;
        expect?: number;
        allow?: number;
    }): IMixinCallbackReturnable;

    /**
     * See com.llamalad7.mixinextras.injector.WrapWithCondition for more information on each parameter. 
     */
    wrapWithCondition(options: {
        method: string;
        at: At;
        slice?: Slice | Slice[];
        locals?: Local | Local[];
        remap?: boolean;
        require?: number;
        expect?: number;
        allow?: number;
    }): IMixinCallbackReturnable;
    
    /**
     * Makes the given field public. If [isMutable] is true, the field will have its `final` modifier removed.
     */
    widenField(name: string, isMutable?: boolean): void;
    
    /**
     * Makes the given method public. If [isMutable] is true, the method will have its `final` modifier removed.
     */
    widenMethod(name: string, isMutable?: boolean): void;
};

/**
 * Object returned from mixin injectors that allows registering (and re-registering)
 * the callback which will be called when the mixin injector is called. `args` will
 * always start with the receiver object (the `this` value of the class that the
 * mixin is applied to) unless the mixin is targeting a static context.
 */
interface IMixinCallback {
    attach(callback: (...args: any[]) => void): void;
    release(): void;
};

/**
 * Similar to [IMixinCallback], but requires the callback to return a value. The exact
 * type of that value depends on the mixin.
 */
interface IMixinCallbackReturnable {
    attach(callback: (...args: any[]) => any): void;
    release(): void;
};

/**
 * See org.spongepowered.asm.mixin.injection.At for more information on each
 * option. Note that the string type is treated as `{ value: <string value> }`.
 */
interface At {
    new (options: string | {
        value: string;
        id?: string;
        slice?: string;
        by?: number;
        args?: string[];
        target?: string;
        ordinal?: number;
        opcode?: number;
        remap?: boolean;
    }): At;  
};

/**
 * See org.spongepowered.asm.mixin.injection.Slice for more information on each option.
 */
interface Slice {
    new (options: {
        id?: string;
        from?: At;
        to?: At;
    }): Slice;
};

type LocalOptions = {
    /**
     * Will cause the local variable table (LVT) of this method to be printed. Causes
     * this injection to not apply.
     *
     * TODO: Is the entire injection aborted? Or just this local attaching
     */
    print?: boolean;

    /**
     * The name of a parameter to capture. This cannot be used with non-parameter
     * locals, hence the name. Cannot be used with `index`, `ordinal`, or `type`.
     */
    parameterName?: string;

    /**
     * The index of the variable in the LVT. Cannot be used with `ordinal`. If used,
     * `type` must also be included.
     */
    index?: number;

    /**
     * The ordinal of the variable of the given type in the method. Cannot be used
     * with `index`. If used, `type` must also be included.
     */
    ordinal?: number;

    /**
     * The types of variable being captured. Pairs with either `index` or `ordinal`.
     * Note that you do not need to specify mutable type wrappers manually; instead,
     * prefer setting the `mutable` flag below to `true`. 
     */
    type?: string;

    /**
     * Whether the local should be captured mutably. This will cause the local variable
     * type to be wrapped in the appropriate 
     * [mutable wrapper](https://github.com/LlamaLad7/MixinExtras/tree/master/src/main/java/com/llamalad7/mixinextras/sugar/ref).
     */
    mutable?: boolean;
};

interface Local {
    new (options: LocalOptions): Local;
};

/**
 * See org.spongepowered.asm.mixin.injection.Constant for more information on each option.
 */
interface Constant {
    new (options: {
        nullValue?: boolean;
        intValue?: number;
        floatValue?: number;
        longValue?: number;
        doubleValue?: number;
        stringValue?:  string;
        classValue?: string;
        ordinal?: number;
        slice?: string;
        expandZeroConditions?: object;
        log?: boolean;
    }): IMixinCallbackReturnable;
};

Differences from Java Mixins

Most of the differences in the dynamic Mixin API come from the fact that we have to infer all the types we use to generate the Mixin class, which means sometimes we need some help from the user. This is a list of API differences:

  • We usually don't support multiple injection points. Mixin takes a single class name, and most method and at attributes are not arrays.
  • We do not provide Desc or any fields of that type.
  • We do not support any non-injection annotations (except @Local) like @Shadow, @Invoker, @Coerce, etc. If you need this functionality, let us know and we'll see if we can provide an API for it.
  • ModifyArg has a captureAllParams option, which will cause the generated Mixin to accept all the method call's parameter instead of just the one specified by index.
  • ModifyVariable has a type option which must be used if ordinal or index is specified, similarly to Local