Published
- 6 min read
How I ported a Fabric mod to Forge without rewriting it.
Everyone knows that Minecraft Forge sucks.
I’m not a fan of making Forge mods. But unfortunately, the amount of Forge mods that actually add content is quite big, compared to Fabric.
People started using Synitra Connector, a mod that allows to run Fabric mods on Forge (with a biiiit of overhead) which caused numerous problems.
So I decided to find out how hard can it be to port a mod to Forge without actually porting it.
But why?
Wasting precious time on a Forge port sucks. No one wants to deal with it. While tools like Architectury do exist, it’s better to use it from the start if you’re planning to do Forge support. Since then it might be just too late, since your code will be filled with Fabric API.
But how?
To make that happen, we need to understand what’s the core difference between a Forge mod and a Fabric mod, which is:
- Minecraft mappings. Fabric uses Intermediary mappings and Forge uses SRG mappings (MCP? i dont really know and it doesn’t really matter).
- Metadata.
Fabric has a
<modid>.mod.json
and Forge usesmods.toml
andMANIFEST.MF
. - Running it at the right time. Since the modloaders run their mods in a different way, you need to hook them up in a special way.
Metadata is the easiest part. Anyone could write a script to magically turn a .mod.json
into mods.toml
. Or do it by hand. But mappings…
That’s the most interesting part!
Mappings
Intermediary mappings are easy to find. Here’s the mapping file for 1.20.1.
SRG is a bit harder. I had to go through MCP Config and find the 1.20.1 TSRG mappings (unfortunately I do not have the link on me, but I’m sure you can find it if you search for it).
One problem… the TSRG mappings I found and a decompiled Forge mod that I used as a reference seemed to show that the classnames are from Mojang mappings, which complicated the task even more.
Mojang has their own mappings for different versions since 1.14, you can find all you need here.
Another problem. Mojang mappings come separately: for a client and a server.
Mapping Thaumaturgy
To juggle the mapping files I used MappingIO library by FabricMC.
I wrote a class that would:
- Join the server and client Mojang mappings.
- Remap Intermediary methods and fields to SRG ones (if a mapping exists, otherwise don’t translate)
- Remap the leftover obfuscated mappings to SRG ones (since intermediary is incomplete, there is a chance you’ll stumble upon an obfuscated element)
- Remap Intermediary class names to Mojang ones.
I achieved all of this with this code:
package dev.drtheo.aitforger.remapper.mappings;
import dev.drtheo.aitforger.remapper.asm.desc.FieldDesc;
import dev.drtheo.aitforger.remapper.asm.desc.MethodDesc;
import dev.drtheo.aitforger.remapper.api.MappingProvider;
import net.fabricmc.mappingio.MappedElementKind;
import net.fabricmc.mappingio.MappingVisitor;
import net.fabricmc.mappingio.adapter.ForwardingMappingVisitor;
import net.fabricmc.mappingio.tree.MappingTree;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.util.List;
// official.accept(Intr2Mcp <- intermediary, mcp)
public class Intr2McpMappingAdapter extends ForwardingMappingVisitor implements MappingProvider {
private final MappingTree intermediary;
private final MappingTree mcp;
public Intr2McpMappingAdapter(MappingVisitor parent, MappingTree intermediary, MappingTree mcp) {
super(parent);
this.intermediary = intermediary;
this.mcp = mcp;
}
@Override
public void visitNamespaces(String s, List<String> list) throws IOException {
super.visitNamespaces("intermediary", List.of("srg"));
}
private MappingTree.ClassMapping interClass;
private MappingTree.ClassMapping mcpClass;
private MappingTree.FieldMapping mcpField;
private MappingTree.MethodMapping mcpMethod;
@Override
public boolean visitClass(String srcName) throws IOException {
this.interClass = this.intermediary.getClass(srcName);
this.mcpClass = this.mcp.getClass(srcName);
return super.visitClass(this.getClass(srcName));
}
@Override
public boolean visitField(String srcName, @Nullable String srcDesc) throws IOException {
this.mcpField = this.mcpClass.getField(srcName, srcDesc);
FieldDesc desc = FieldDesc.of(srcName, srcDesc)
.remap(this);
return super.visitField(desc.name(), desc.desc());
}
@Override
public boolean visitMethod(String srcName, @Nullable String srcDesc) throws IOException {
this.mcpMethod = this.mcpClass.getMethod(srcName, srcDesc);
MethodDesc desc = MethodDesc.of(srcName, srcDesc)
.remap(this);
return super.visitMethod(desc.name(), desc.desc());
}
@Override
public void visitDstName(MappedElementKind targetKind, int namespace, String name) throws IOException {
String result = name;
if (targetKind == MappedElementKind.FIELD)
result = this.mcpField.getDstName(0);
if (targetKind == MappedElementKind.METHOD)
result = this.mcpMethod.getDstName(0);
super.visitDstName(targetKind, namespace, result != null ? result : name);
}
@Override
public String getClass(String type) {
MappingTree.ClassMapping mapping = this.intermediary.getClass(type);
if (mapping == null)
return type;
String result = mapping.getDstName(0);
if (result != null)
return result;
return type;
}
@Override
public String getMethod(String ownerName, String name, String desc) {
if (this.interClass == null)
return name;
MappingTree.MethodMapping mapping = this.interClass.getMethod(name, desc);
if (mapping == null)
return name;
String result = mapping.getDstName(0);
if (result != null)
return result;
return name;
}
@Override
public String getField(String ownerName, String name, String desc) {
if (this.interClass == null)
return name;
MappingTree.FieldMapping mapping = this.interClass.getField(name, desc);
if (mapping == null)
return name;
String result = mapping.getDstName(0);
if (result != null)
return result;
return name;
}
}
Which I then fed into official mappings like this:
VisitableMappingTree result = new MemoryMappingTree();
MappingTree merged = merge(client, server);
VisitableMappingTree mcp = read(srg, MappingFormat.TSRG_2_FILE);
VisitableMappingTree intermediary = read(tiny, MappingFormat.TINY_FILE);
merged.accept(new Intr2McpMappingAdapter(result, intermediary, mcp));
Obviously, you also would need to merge the Mojang client and server mappings, which I did like this:
public static MappingTree merge(Path clientPath, Path serverPath) throws IOException {
long start = System.currentTimeMillis();
VisitableMappingTree merged = new MemoryMappingTree();
MappingReader.read(clientPath, MappingFormat.PROGUARD_FILE, merged);
MappingReader.read(serverPath, MappingFormat.PROGUARD_FILE, merged);
VisitableMappingTree result = new MemoryMappingTree();
merged.accept(new MappingSourceNsSwitch(result, "target"));
LOGGER.debug("Finished merging vanilla mappings in %dms", System.currentTimeMillis() - start);
return result;
}
To apply the mappings I’ve used tiny-remapper, also made by the folks at FabricMC. And voila! You have a jar that if you run the entrypoint at a proper time, will work on Forge natively, even though it’s a Fabric mod.
Hooking it up
You can start with a wrapper mod. It will have the additional mixins and code to hook up the remapped jar properly.
In this example, I’ll be showing you how to hook up a Fabric mod that has an entrypoint to client and common side code.
We’ll start with a method that just runs the entrypoints.
public static void load() {
JukeAttributes.inject();
new MyMod().onInitialize();
if (FMLEnvironment.dist == Dist.CLIENT)
new MyModClient().onInitializeClient();
JukeAttributes.release();
finishedLoading = true;
}
We use FMLEnvironment.dist
to get the current execution side. We also mark that we finished loading the mod to avoid the initialization happening twice.
One interesting thing is JukeAttributes
(credits to Synitra).
It initializes fake attributes with real registry names of real attributes that will get registered later.
Now where do we execute that load method?
-
Client has to be initialized after some initialization in the
MinecraftClient
class, and I’ve found that mixing inRenderSystem#initBackendSystem
works like a charm. -
Server (obviously) doesn’t execute any of the client render code, so we have to mixin into
net.minecraft.server.Main#main
.
Now if both your remapped jar and the wrapper jar get loaded (are on classpath) then it will work!
To achieve that you can use jar-in-jar from Forge Gradle or write a task that builds the container jar first and then copies the remapped jar to the generated resources of the full jar.
If your mod uses Fabric API then just use Forgified Fabric API, or rewrite it yourself! Remember, you can always re-implement some Fabric stuff since you’re controlling the remapping process. I wrote an additional mapping file that would remap all the Fabric Loader API calls to a semi-assed implementation of it in my full mod jar.
The mapping looked something like this:
v1 original remapped
CLASS net/fabricmc/api/ClientModInitializer dev/drtheo/<mod>/remapped/net/fabricmc/api/ClientModInitializer
CLASS net/fabricmc/api/ModInitializer dev/drtheo/<mod>/remapped/net/fabricmc/api/ModInitializer
CLASS net/fabricmc/loader/api/FabricLoader dev/drtheo/<mod>/remapped/net/fabricmc/loader/api/FabricLoader
CLASS net/fabricmc/api/EnvType dev/drtheo/<mod>/remapped/net/fabricmc/api/EnvType
CLASS net/fabricmc/api/Environment dev/drtheo/<mod>/remapped/net/fabricmc/api/Environment
CLASS net/fabricmc/fabric/api/screenhandler/v1/ScreenHandlerRegistry dev/drtheo/<mod>/remapped/net/fabricmc/fabric/api/screenhandler/v1/ScreenHandlerRegistry
CLASS net/fabricmc/fabric/api/screenhandler/v1/ScreenHandlerRegistry$SimpleClientHandlerFactory dev/drtheo/<mod>/remapped/net/fabricmc/fabric/api/screenhandler/v1/ScreenHandlerRegistry$SimpleClientHandlerFactory
CLASS net/fabricmc/loader/api/ModContainer dev/drtheo/<mod>/remapped/net/fabricmc/loader/api/ModContainer
CLASS net/fabricmc/fabric/api/command/v2/ArgumentTypeRegistry dev/drtheo/<mod>/remapped/net/fabricmc/fabric/api/command/v2/ArgumentTypeRegistry
Then I just re-implemented those classes and voila! It works!
Outro
I hope this guide helps someone to understand the internals of the modloaders and how they work. Maybe someone will even attempt to do the same I did for this post.