Example: a multi-loader mod

Every file of a mod that builds four jars from one codebase, for Fabric, Quilt, Legacy Fabric, Forge and NeoForge from Minecraft 1.7.10 to 26.3.

Last updated

On this page

Complete example

A complete Gradle project for a mod whose only feature is a settings menu, built as four jars that together cover every loader and version the library supports. It follows Build one mod for every loader, which explains the reasoning; this page is the copyable result.

It compiles without downloading Minecraft or Forge: the entry classes only touch each loader's @Mod annotation, which comes from small compile-only stubs.

Files#

mymod/
├── settings.gradle.kts
├── build.gradle.kts
└── src/
    ├── main/java/com/example/mymod/          shared: compiled against the API only
    │   ├── MyMod.java
    │   ├── MyConfig.java
    │   └── MyMenu.java
    ├── main/resources/assets/mymod/lang/en_us.json
    ├── test/java/com/example/mymod/MyMenuTest.java
    ├── fabric/                                Fabric, Quilt, Legacy Fabric
    │   ├── java/com/example/mymod/loader/FabricEntry.java
    │   └── resources/fabric.mod.json
    ├── modlauncher/                           Forge 1.13.2+, NeoForge
    │   ├── java/com/example/mymod/loader/ForgeEntry.java
    │   ├── java/com/example/mymod/loader/NeoForgeEntry.java
    │   └── resources/META-INF/mods.toml, META-INF/neoforge.mods.toml, pack.mcmeta
    ├── forgeLegacy/                           Forge 1.8 – 1.12.2
    │   ├── java/com/example/mymod/loader/ForgeLegacyEntry.java
    │   └── resources/mcmod.info, version.properties
    ├── forgeCpw/                              Forge 1.7.10
    │   ├── java/com/example/mymod/loader/ForgeCpwEntry.java
    │   └── resources/mcmod.info, version.properties
    ├── stubsModlauncher/java/…                compile-only @Mod stubs, never in a jar
    ├── stubsForgeLegacy/java/…
    └── stubsForgeCpw/java/…

./gradlew build runs the tests and writes build/libs/mymod-1.0.0-fabric.jar, -modlauncher.jar, -forge-1.8-1.12.2.jar and -forge-1.7.10.jar.

Build scripts#

settings.gradle.kts
rootProject.name = "mymod"
build.gradle.kts
plugins {
    java
}

group = "com.example"
version = "1.0.0"

repositories {
    mavenCentral()
    maven("https://maven.xichil.dev")
    maven("https://maven.fabricmc.net/")
}

val mvuiApi = "dev.xichil.mvui:xis-mvui-api:1.0.0"

// Java 8 bytecode runs on every Minecraft version from 1.7.10 to 26.3.
tasks.withType<JavaCompile>().configureEach {
    options.release = 8
}

val main: SourceSet = sourceSets["main"]

dependencies {
    compileOnly(mvuiApi)                       // never bundled: the library's jar provides it
    testImplementation(mvuiApi)
    testImplementation(testFixtures(mvuiApi))  // FakeHost
    testImplementation(platform("org.junit:junit-bom:5.13.4"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.test {
    useJUnitPlatform()
}

// Compile-only stubs of the Forge annotations: on the entry classes' class path, never in a jar.
val stubsModlauncher: SourceSet = sourceSets.create("stubsModlauncher")
val stubsForgeLegacy: SourceSet = sourceSets.create("stubsForgeLegacy")
val stubsForgeCpw: SourceSet = sourceSets.create("stubsForgeCpw")

/** One jar per loader family: its source set, the jar's classifier, and what its entry class compiles against. */
data class Family(val sourceSet: String, val classifier: String, val loaderApi: Any)

val families = listOf(
    Family("fabric", "fabric", "net.fabricmc:fabric-loader:0.19.5"),
    Family("modlauncher", "modlauncher", stubsModlauncher.output),
    Family("forgeLegacy", "forge-1.8-1.12.2", stubsForgeLegacy.output),
    Family("forgeCpw", "forge-1.7.10", stubsForgeCpw.output),
)

families.forEach { family ->
    val set = sourceSets.create(family.sourceSet) {
        compileClasspath += main.output
    }
    dependencies {
        add(set.compileOnlyConfigurationName, mvuiApi)
        add(set.compileOnlyConfigurationName, family.loaderApi)
    }
    tasks.named<ProcessResources>(set.processResourcesTaskName) {
        val props = mapOf("version" to project.version.toString())
        inputs.properties(props)
        filesMatching(listOf("fabric.mod.json", "META-INF/*.toml", "mcmod.info", "version.properties")) {
            expand(props)
        }
    }
    val familyJar = tasks.register<Jar>("${family.sourceSet}Jar") {
        group = "build"
        description = "The mod's jar for ${family.classifier}."
        archiveClassifier = family.classifier
        from(main.output, set.output)      // shared classes and lang files + this family's entry and metadata
    }
    tasks.assemble {
        dependsOn(familyJar)
    }
}

// The shared classes alone load on no loader.
tasks.jar {
    enabled = false
}

Shared code#

These three files and the test are all the mod's logic. They import only dev.xichil.mvui and the JDK.

src/main/java/com/example/mymod/MyMod.java
package com.example.mymod;

import dev.xichil.mvui.MultiversionUi;
import dev.xichil.mvui.Text;

import java.nio.file.Path;
import java.nio.file.Paths;

/** Each loader's entry class calls init() once, on the client. */
public final class MyMod {
    public static final String MOD_ID = "mymod";

    private MyMod() {
    }

    public static void init() {
        // The game's working directory is the game directory on every loader.
        Path file = Paths.get("config", MOD_ID + ".properties");
        MultiversionUi.register(MOD_ID, Text.translatable("mymod.name", "My Mod"),
                new MyMenu(MyConfig.load(file), file));
    }
}
src/main/java/com/example/mymod/MyConfig.java
package com.example.mymod;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;

public final class MyConfig {
    public boolean sounds = true;
    public int volume = 80;

    public static MyConfig load(Path file) {
        MyConfig c = new MyConfig();
        if (!Files.exists(file)) {
            return c;
        }
        Properties p = new Properties();
        try (InputStream in = Files.newInputStream(file)) {
            p.load(in);
            c.sounds = Boolean.parseBoolean(p.getProperty("sounds", "true"));
            c.volume = Integer.parseInt(p.getProperty("volume", "80").trim());
        } catch (IOException | NumberFormatException e) {
            return new MyConfig();   // unreadable: start from the defaults
        }
        return c;
    }

    public void save(Path file) throws IOException {
        Properties p = new Properties();
        p.setProperty("sounds", String.valueOf(sounds));
        p.setProperty("volume", String.valueOf(volume));
        Files.createDirectories(file.toAbsolutePath().getParent());
        try (OutputStream out = Files.newOutputStream(file)) {
            p.store(out, "My Mod settings");
        }
    }
}
src/main/java/com/example/mymod/MyMenu.java
package com.example.mymod;

import dev.xichil.mvui.Button;
import dev.xichil.mvui.MenuScreen;
import dev.xichil.mvui.Screen;
import dev.xichil.mvui.ScreenFactory;
import dev.xichil.mvui.Slider;
import dev.xichil.mvui.StatusLine;
import dev.xichil.mvui.Text;
import dev.xichil.mvui.Toggle;
import dev.xichil.mvui.UiHost;
import dev.xichil.mvui.UiTexts;

import java.nio.file.Path;

public final class MyMenu implements ScreenFactory {
    private final MyConfig config;
    private final Path file;

    public MyMenu(MyConfig config, Path file) {
        this.config = config;
        this.file = file;
    }

    @Override
    public Text buttonLabel() {
        return Text.translatable("mymod.menu", "My Mod...");
    }

    @Override
    public Screen create(UiHost host) {
        StatusLine status = StatusLine.builder("status").build();
        return MenuScreen.builder("main")
                .title(Text.translatable("mymod.title", "My Mod"))
                .add(Toggle.builder("sounds")
                        .label(Text.translatable("mymod.sounds", "Sounds"))
                        .value(() -> config.sounds)
                        .onChange(v -> config.sounds = v)
                        .build())
                .add(Slider.builder("volume")
                        .label(Text.translatable("mymod.volume", "Volume"))
                        .range(0, 100).step(5)
                        .value(() -> config.volume)
                        .onChange(v -> config.volume = (int) v)
                        .enabled(() -> config.sounds)
                        .build())
                .add(status)
                .footer(Button.builder("done")
                        .label(UiTexts.DONE)
                        .onPress(() -> host.runAsync(() -> {
                            config.save(file);
                            return null;
                        }, (ignored, error) -> {
                            if (error != null) {
                                status.show(StatusLine.Kind.ERROR,
                                        Text.translatable("mymod.save.failed", "Couldn't save the settings"));
                            } else {
                                host.back();
                            }
                        }))
                        .build())
                .build();
    }
}
src/main/resources/assets/mymod/lang/en_us.json
{
  "mymod.name": "My Mod",
  "mymod.menu": "My Mod...",
  "mymod.title": "My Mod",
  "mymod.sounds": "Sounds",
  "mymod.volume": "Volume",
  "mymod.save.failed": "Couldn't save the settings"
}
src/test/java/com/example/mymod/MyMenuTest.java
package com.example.mymod;

import dev.xichil.mvui.fake.FakeHost;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import java.nio.file.Path;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

class MyMenuTest {
    @TempDir
    Path dir;

    @Test
    void soundsOffGreysOutVolumeAndDoneSaves() {
        Path file = dir.resolve("mymod.properties");
        MyConfig config = new MyConfig();
        FakeHost host = new FakeHost();
        host.open(new MyMenu(config, file).create(host));

        host.click("sounds");
        assertTrue(host.render().contains("[slider volume] Volume: 80 (disabled)"));

        host.click("done");
        assertEquals(0, host.depth());
        assertFalse(MyConfig.load(file).sounds);
    }
}

Fabric, Quilt and Legacy Fabric#

src/fabric/java/com/example/mymod/loader/FabricEntry.java
package com.example.mymod.loader;

import com.example.mymod.MyMod;
import net.fabricmc.api.ClientModInitializer;

public final class FabricEntry implements ClientModInitializer {
    @Override
    public void onInitializeClient() {
        MyMod.init();
    }
}
src/fabric/resources/fabric.mod.json
{
  "schemaVersion": 1,
  "id": "mymod",
  "version": "${version}",
  "name": "My Mod",
  "description": "A settings menu on every loader and version.",
  "license": "All Rights Reserved",
  "environment": "client",
  "entrypoints": {
    "client": ["com.example.mymod.loader.FabricEntry"]
  },
  "depends": {
    "fabricloader": ">=0.12.0",
    "xis_mvui": ">=1.0.0"
  }
}

Forge 1.13.2+ and NeoForge#

src/modlauncher/java/com/example/mymod/loader/ForgeEntry.java
package com.example.mymod.loader;

import com.example.mymod.MyMod;
import net.minecraftforge.fml.common.Mod;

/** Forge 1.13.2+ and NeoForge 1.20.1. */
@Mod(MyMod.MOD_ID)
public final class ForgeEntry {
    public ForgeEntry() {
        MyMod.init();
    }
}
src/modlauncher/java/com/example/mymod/loader/NeoForgeEntry.java
package com.example.mymod.loader;

import com.example.mymod.MyMod;
import net.neoforged.fml.common.Mod;

/** NeoForge 1.20.2+. */
@Mod(MyMod.MOD_ID)
public final class NeoForgeEntry {
    public NeoForgeEntry() {
        MyMod.init();
    }
}
src/modlauncher/resources/META-INF/mods.toml
# Read by Forge 1.13.2+ and NeoForge 1.20.1 - 1.20.4.
modLoader = "javafml"
loaderVersion = "[1,)"
license = "All Rights Reserved"

[[mods]]
modId = "mymod"
version = "${version}"
displayName = "My Mod"
description = '''A settings menu on every loader and version.'''
displayTest = "IGNORE_ALL_VERSION"

[[dependencies.mymod]]
modId = "xis_mvui"
mandatory = true
versionRange = "[1.0.0,)"
ordering = "NONE"
side = "CLIENT"
src/modlauncher/resources/META-INF/neoforge.mods.toml
# Read by NeoForge 1.20.5+.
modLoader = "javafml"
loaderVersion = "[1,)"
license = "All Rights Reserved"

[[mods]]
modId = "mymod"
version = "${version}"
displayName = "My Mod"
description = '''A settings menu on every loader and version.'''
displayTest = "IGNORE_ALL_VERSION"

[[dependencies.mymod]]
modId = "xis_mvui"
type = "required"
versionRange = "[1.0.0,)"
ordering = "NONE"
side = "CLIENT"
src/modlauncher/resources/pack.mcmeta
{
  "pack": {
    "description": "My Mod resources",
    "pack_format": 6
  }
}

loaderVersion = "[1,)" matches both Forge's and NeoForge's loader versions. displayTest = "IGNORE_ALL_VERSION" means a server doesn't need the mod, or a matching version of it, for the client to join.

Forge 1.8 – 1.12.2#

src/forgeLegacy/java/com/example/mymod/loader/ForgeLegacyEntry.java
package com.example.mymod.loader;

import com.example.mymod.MyMod;
import dev.xichil.mvui.MultiversionUi;
import net.minecraftforge.fml.common.Mod;

/** Name and version come from mcmod.info and version.properties. */
@Mod(modid = MyMod.MOD_ID, useMetadata = true, acceptableRemoteVersions = "*",
        dependencies = "required-after:" + MultiversionUi.MOD_ID,
        guiFactory = MultiversionUi.LEGACY_FORGE_GUI_FACTORY)
public final class ForgeLegacyEntry {
    public ForgeLegacyEntry() {
        MyMod.init();
    }
}
src/forgeLegacy/resources/mcmod.info
[
  {
    "modid": "mymod",
    "name": "My Mod",
    "description": "A settings menu on every loader and version.",
    "version": "${version}",
    "requiredMods": ["xis_mvui"],
    "dependencies": ["xis_mvui"]
  }
]
src/forgeLegacy/resources/version.properties
mymod.version=${version}

Forge 1.7.10#

The same as Forge 1.8 – 1.12.2, with the cpw.mods.fml annotation and the 1.7.10 factory. mcmod.info and version.properties are identical; put copies in src/forgeCpw/resources/.

src/forgeCpw/java/com/example/mymod/loader/ForgeCpwEntry.java
package com.example.mymod.loader;

import com.example.mymod.MyMod;
import cpw.mods.fml.common.Mod;
import dev.xichil.mvui.MultiversionUi;

@Mod(modid = MyMod.MOD_ID, useMetadata = true, acceptableRemoteVersions = "*",
        dependencies = "required-after:" + MultiversionUi.MOD_ID,
        guiFactory = MultiversionUi.CPW_FORGE_GUI_FACTORY)
public final class ForgeCpwEntry {
    public ForgeCpwEntry() {
        MyMod.init();
    }
}

Stubs#

Each stub declares only what the entry classes use. The real annotations are there at runtime.

src/stubsModlauncher/java/net/minecraftforge/fml/common/Mod.java
package net.minecraftforge.fml.common;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/** Compile-only stub. FML finds mods by scanning class files for this annotation. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Mod {
    String value();
}
src/stubsModlauncher/java/net/neoforged/fml/common/Mod.java
package net.neoforged.fml.common;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/** Compile-only stub of NeoForge's mod annotation. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Mod {
    String value();
}
src/stubsForgeLegacy/java/net/minecraftforge/fml/common/Mod.java
package net.minecraftforge.fml.common;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/** Compile-only stub of Forge 1.8 - 1.12.2's mod annotation: only the members the entry class uses. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Mod {
    String modid();
    boolean useMetadata() default false;
    String acceptableRemoteVersions() default "";
    String dependencies() default "";
    String guiFactory() default "";
}

src/stubsForgeCpw/java/cpw/mods/fml/common/Mod.java is the same as the Forge 1.8 – 1.12.2 stub, with package cpw.mods.fml.common;.

How it works#