Minecraft Forge Coremod Comprehensive Tutorial
In this tutorial we will discuss what a coremod is and the details on developing your own coremod.
What is a coremod?
A mod modifies Minecraft in some way. Mods created with Minecraft Forge normally rely on Forge's API to modify Minecraft. This includes things like creating your own blocks, items, crafting recipes, and custom network packets. The vast majority of mods add new content to Minecraft in a way that Forge supports. But what if you want to do something that it doesn't?
If you're lucky, vanilla Minecraft itself might expose a way to achieve the behaviour you want. For example, you can intercept network packets by hooking into Minecraft's Netty pipeline. Otherwise, you will need to modify Minecraft's bytecode directly. This is what a coremod does.
Outlining a class transformer
Let's start by writing the method that will actually modify Minecraft's bytecode. Our class will look like this:
// ...
import net.minecraft.launchwrapper.IClassTransformer;
public class CustomClassTransformer implements IClassTransformer {
@Override
public byte[] transform(String name, String transformedName, byte[] basicClass) {
// ...
}
}
You'll notice that the IClassTransformer
interface is actually imported from Minecraft and not Forge. You can actually find this interface in Mojang's LegacyLauncher repository. Presumably the Mojang developers use this to aid them with local development.
Our method is called for every class that's being loaded and takes three arguments. The first argument, name
, is the raw qualified name of the current class being transformed. For example, in a deobfuscated environment (such as testing locally), this argument will look something like: net.minecraft.client.gui.FontRenderer
. However, in an obfuscated environment it will look like: avn
.
The second argument, transformedName
, is similar to name
but it will always be the deobfuscated, fully qualified name. We rely on this argument to check if we are transforming the right class.
Finally, the last argument, basicClass
, contains the raw bytecode for the current class. We will be modifying and returning this argument.
A primer on visitors
Internally, most compilers and interpreters parse and represent source code as an abstract syntax tree. Each node of a tree is an expression or statement and children of a node are subexpressions of that node. For example, take the mathematical expression: 1 + 1
. The root of the tree would be a node for the operator +
and it would have two children, both of which would be the integer 1
.
We can extend this concept further and instead of converting source code, we can go in the other direction and turn bytecode (what Java source code is ultimately compiled to) into an abstract syntax tree. The resultant structure is less granular so we won't have expressions as nodes but we can still access individual methods and fields.
A common pattern with trees is to iterate over each node. This is called a tree traversal or "walking the tree". From a software design perspective, we'd like to separate the logic for traversing the tree from the code that actually handles each node. This lets us avoid duplicating the logic for traversals and makes it easy to add new operations on the tree. This is achieved using the visitor pattern, where different operations are implemented with different visitors.
Let's examine the code for reading the class bytecode into a tree. We will be using the ASM library bundled with Forge:
ClassNode classNode = new ClassNode();
ClassReader classReader = new ClassReader(basicClass);
classReader.accept(classNode, 0);
org.objectweb.asm
and not some other package! Your IDE may import classes internal to your JDK.
Here, confusingly, ClassNode
is being used as a visitor and ClassReader
is treated as the data structure in this pattern. In fact, ClassNode
represents both a tree and a visitor. We will explore this shortly. The method classReader.accept(...)
reads the provided bytecode and invokes visit
methods on classNode
with the parsed class properties (such as methods or fields). The variable classNode
takes these properties and adds them to itself as children.
To write this tree back as a sequence of bytes, we use the ClassWriter
class:
int flags = ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES;
ClassWriter writer = new ClassWriter(flags);
classNode.accept(writer);
return writer.toByteArray();
Here, ClassNode
is treated as a structure and ClassWriter
as a visitor. Each time writer
visits a node in the tree, it converts the node into the corresponding bytecode. The flags we pass into its constructor ensure that the class metadata is correctly recalculated.
A primer on Java bytecode
Now that we can inspect and modify the methods for a class, how do we correctly change its behaviour? To do that, we first need to understand how the JVM (Java virtual machine) is designed.
Every method in Java, under the hood, consists of a sequence of instructions. These instructions tell the JVM what operations to perform: calling a method, adding two numbers; more generally, how your program should work. The "virtual" in "Java virtual machine" refers to the fact that it's quite literally emulating a processor or "machine" inside of your computer.
You may already be familiar with assembly instructions for other architectures such as Intel (x86) or MIPS. These architectures have their instructions operate on registers; effectively a limited set of variables that can only store values in binary. The JVM on the other hand is a stack machine; its instructions store and compute values on a stack.
For example, here's how you might compute 6 + 7
using Java bytecode:
bipush 6
bipush 7
iadd
By referencing the JVM instruction listings we can see that bipush
pushes an integer smaller than 256 onto the stack. After executing the first two bipush
instructions, the stack looks like this:
7 <- Top of stack
6
The instruction iadd
takes (pops off) the top two values and pushes the result of their addition:
13 <- Top of stack
You can explore how Java source code transforms into bytecode with this website: https://javap.yawk.at
Tweaking a method
Let's now change the behaviour of a method. For this example, we will change Minecraft's FontRenderer
to replace the string "foo" in any rendered text with three asterisks. First we need to ensure that we are transforming the right class:
@Override
public byte[] transform(...) {
if (!transformedName.equals("net.minecraft.client.gui.FontRenderer"))
return basicClass;
// ...
}
FontRenderer.class.getName()
is not allowed. Since the class has not been loaded yet, it will invoke your class transformer in an infinitely recursive loop.
Then, after parsing the bytecode into a tree, we will loop over all the methods and find the one we want to modify:
for (MethodNode method : classNode.methods) {
if (method.name.equals("renderString")) {
// ...
}
}
By inspecting the definition of the renderString
method in our IDE, we can see that the first argument is the string to be rendered. Let's write a static method inside our class transformer that will alter this argument:
public static String alter(String string) {
return string.replaceAll("foo", "***");
}
This method must be marked as public
so that it can be called from within FontRenderer
. Next we will generate the payload for invoking this method. Let's first import the constants for each instruction's opcode:
import static org.objectweb.asm.Opcodes.*;
Our method for generating the payload will look like this:
private static InsnList payload() {
InsnList payload = new InsnList();
// ...
return payload;
}
To load the string as an argument to our alter
method, we use the ALOAD
instruction. This instruction takes an integer which represents the index of the variable we want to load. As the method renderString
is not static, the first variable will be this
(the FontRenderer
instance). Arguments to the method come next and so the index of the string we want to modify is 1
:
payload.add(new VarInsnNode(ALOAD, 1));
Next we will retrieve the method we want to insert:
Class<CustomClassTransformer> target = CustomClassTransformer.class;
Method other = target.getDeclaredMethod("alter", String.class);
...and add the instruction for invoking the method:
payload.add(new MethodInsnNode(INVOKESTATIC,
Type.getInternalName(target), other.getName(),
Type.getMethodDescriptor(other), false));
The method getMethodDescriptor
returns an encoded version of a method's descriptor (effectively its signature and return type). This allows the JVM to differentiate between methods with the same name but with different parameter types (that is, overloaded methods).
Finally, we will add the instruction for replacing the argument with our returned string:
payload.add(new VarInsnNode(ASTORE, 1));
Now we just need to insert the payload at the start of the renderString
method:
if (method.name.equals(...)) {
method.instructions.insert(payload());
}
Registering our class transformer
Now that we have our class transformer, we can register it with Forge. We need to create a class implementing IFMLLoadingPlugin
:
// ...
import net.minecraftforge.fml.relauncher.IFMLLoadingPlugin;
public class CustomPlugin implements IFMLLoadingPlugin {
// ...
}
For this tutorial, we will only implementing the getASMTransformerClass
method. All the other required methods can return either nothing or null
. This method returns an array of fully qualified names of our class transformers:
@Override
public String[] getASMTransformerClass() {
return new String[]{CustomClassTransformer.class.getName()};
}
We will also add two annotations on our class:
@IFMLLoadingPlugin.MCVersion(<version>)
@IFMLLoadingPlugin.TransformerExclusions(<group>)
public class CustomPlugin ...
The MCVersion
annotation tells Forge what version of Minecraft this coremod was made for. This is important as coremods break easily across different Minecraft versions. Replace <version>
with a version string (such as: "1.8.9"
).
The TransformerExclusions
annotation tells Forge what packages should be excluded from being transformed by your class transformers. Replace <group>
with your mod's package path (by default, this will be: "com.example.examplemod"
). This ensures transformers will not attempt to recursively transform themselves.
Finally, we need to modify our artifact's manifest so that Forge knows that our mod is a coremod. Add this snippet to the build.gradle
file:
jar {
manifest {
attributes "FMLCorePlugin": "<group>.CustomPlugin"
}
}
As with the TransformerExclusions
annotation, replace <group>
with your mod's package path. You can now test your coremod locally.
Making the mod appear
By default, coremods do not appear in the mods list. To make your coremod appear, you must add an attribute to your manifest:
jar {
manifest {
// ...
attributes "FMLCorePluginContainsFMLMod": "true"
}
}
Ensure that you have a class annotated with Mod
and a mcmod.info
file (these should be present by default). This attribute tells Forge to load your mod class as well so you are free to do anything a normal mod can (such as registering event handlers or adding new items). For more information, see my 1.8 quick start guide.
Handling obfuscation
While class names are automatically deobfuscated for us, method names are not. This means our coremod will not work on obfuscated clients. To fix this, we will compare against both deobfuscated and obfuscated method names.
To find obfuscated method names we will use the mappings from MCP (Mod Coder Pack). Download the pack specific to your target Minecraft version and extract it. The decompiled sources you see in your IDE are generated from MCP's mappings and so they're compatible with each other.
Inside of your extracted folder will be a conf folder. This folder contains all the mappings for Minecraft's symbols (such as field and method names). Let's open the methods.csv file in a text editor.
Each line of the methods.csv file is in the format: unique identifier, deobfuscated name, side, description (excluding the first line which is the header). For this tutorial, we are only interested in the first two columns. Use your editor to find the line containing your target method name (in this case: renderString
):
func_180455_b,renderString,0,"Render single line string by setting GL color, current (posX,posY), and calling renderStringAtPos()"
Some methods may have the same name so make sure that the description matches with the documentation in your IDE for that method.
Our method's unique identifier is: func_180455_b
. This is not the obfuscated method name. This is a unique identifier that MCP assigns to make it easier for people to contribute to the mappings. To find the obfuscated name, open the joined.srg file (not joined.exc) in the same folder and find the line containing your method's unique identifier:
MD: avn/b (Ljava/lang/String;FFIZ)I net/minecraft/client/gui/FontRenderer/func_180455_b (Ljava/lang/String;FFIZ)I
The first part of the line, MD
, denotes that this mapping is for a method. Every method mapping is in the format:
<obfuscated class>/<obfuscated name> <descriptor> <class>/<name> <descriptor>
This means that our target method's obfuscated name is b
and it's in the class avn
. However, there are many other methods with the same name and in the same obfuscated class. This a result of the obfuscation process. For example:
avn/b ()Z net/minecraft/client/gui/FontRenderer/func_78260_a ()Z
The only difference between these two obfuscated methods is their descriptor. We compare against both method names and descriptors to ensure that we are modifying the correct method:
boolean name = method.name.equals("renderString") || method.name.equals("b");
boolean descriptor = method.desc.equals("(Ljava/lang/String;FFIZ)I");
if (name && descriptor) {
// ...
}
After adding this, your mod can now be distributed.