From 21ac9452f42b7a23145e61d1fae3fa56b184cbac Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 8 Jul 2025 11:54:16 +0200 Subject: [PATCH 01/37] build: Update to Gradle 8.8 --- gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index db9a6b8..0d18421 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From ef66cdb6a8a3936d6792729bf9f7b645bb773b53 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 7 Jul 2025 15:32:02 +0200 Subject: [PATCH 02/37] tests/fabric: Fix typos --- integrationTest/fabric/build.gradle | 2 +- integrationTest/fabric/src/exampleMod/resources/fabric.mod.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/integrationTest/fabric/build.gradle b/integrationTest/fabric/build.gradle index fe3f155..5cc03f9 100644 --- a/integrationTest/fabric/build.gradle +++ b/integrationTest/fabric/build.gradle @@ -84,7 +84,7 @@ tasks.register("exampleModJar", Jar) { from(sourceSets.exampleMod.output) dependsOn(configurations.exampleModRuntimeClasspath) from({ configurations.exampleModRuntimeClasspath.files }) { - rename { "META-INF/jars/loader-launchwrapper-1.0.0.jar" } + rename { "META-INF/jars/loader-fabric-1.0.0.jar" } } } diff --git a/integrationTest/fabric/src/exampleMod/resources/fabric.mod.json b/integrationTest/fabric/src/exampleMod/resources/fabric.mod.json index 6e45385..a3a982c 100644 --- a/integrationTest/fabric/src/exampleMod/resources/fabric.mod.json +++ b/integrationTest/fabric/src/exampleMod/resources/fabric.mod.json @@ -12,7 +12,7 @@ "depends": {}, "jars": [ { - "file": "META-INF/jars/loader-launchwrapper-1.0.0.jar" + "file": "META-INF/jars/loader-fabric-1.0.0.jar" } ] } \ No newline at end of file From 3834fcc9aeda7c6e513a010b64477d1156f7a996 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 7 Jul 2025 17:52:04 +0200 Subject: [PATCH 03/37] tests/lw: Fix logging This plugin database file exists in multiple input jars, and if we simply pick one of them then we'll be missing most of the plugins (e.g. the one that loads xml configs), breaking logging. If we exclude the file, log4j will fallback to a different path with does discover enough plugins for it to function correctly. --- integrationTest/launchwrapper/build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/integrationTest/launchwrapper/build.gradle b/integrationTest/launchwrapper/build.gradle index f7f69be..42e9b8e 100644 --- a/integrationTest/launchwrapper/build.gradle +++ b/integrationTest/launchwrapper/build.gradle @@ -306,6 +306,7 @@ tasks.register("forge10808Jar", Jar) { dependsOn(configurations.forge10808Runtime) from({ configurations.forge10808Runtime.collect { zipTree(it) } }) { exclude 'META-INF/*.RSA', 'META-INF/*.SF', 'META-INF/*.DSA' + exclude 'META-INF/org/apache/logging/log4j/core/config/plugins/Log4j2Plugins.dat' exclude 'net/minecraftforge/fml/common/launcher/TerminalTweaker.class' } } @@ -317,6 +318,7 @@ tasks.register("forge11202Jar", Jar) { dependsOn(configurations.forge11202Runtime) from({ configurations.forge11202Runtime.collect { zipTree(it) } }) { exclude 'META-INF/*.RSA', 'META-INF/*.SF', 'META-INF/*.DSA' + exclude 'META-INF/org/apache/logging/log4j/core/config/plugins/Log4j2Plugins.dat' exclude 'net/minecraftforge/fml/common/launcher/TerminalTweaker.class' } } From 3fcef5456039e82caf2e019cd366f4fe6e5d9021 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 7 Jul 2025 15:50:42 +0200 Subject: [PATCH 04/37] tests/lw: Move tweaker-specific tests into dedicated file --- .../essential/loader/stage1/Stage1Tests.java | 27 -------------- .../loader/stage1/Stage1TweakerTests.java | 35 +++++++++++++++++++ 2 files changed, 35 insertions(+), 27 deletions(-) create mode 100644 integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1TweakerTests.java diff --git a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1Tests.java b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1Tests.java index 8fadd2f..a4153a6 100644 --- a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1Tests.java +++ b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1Tests.java @@ -6,7 +6,6 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; -import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -55,32 +54,6 @@ public void testUnsupportedVersion(Installation installation, boolean secondLaun assertEquals(secondLaunch, isolatedLaunch.isEssentialLoaded(), "Essential loaded"); } - @Test - public void testMultipleCustomTweakerMods(Installation installation) throws Exception { - installation.addExampleMod(); - installation.addExample2Mod(); - - IsolatedLaunch isolatedLaunch = installation.launchFML(); - - installation.assertModLaunched(isolatedLaunch); - installation.assertMod2Launched(isolatedLaunch); - assertTrue(isolatedLaunch.isEssentialLoaded(), "Essential loaded"); - } - - @Test - public void testMultipleEssentialTweakerMods(Installation installation) throws Exception { - installation.addExampleMod("essential-tweaker"); - installation.addExample2Mod("essential-tweaker"); - - IsolatedLaunch isolatedLaunch = installation.launchFML(); - - assertTrue(isolatedLaunch.getModLoadState("coreMod"), "Example CoreMod ran"); - assertTrue(isolatedLaunch.getModLoadState("mod"), "Example Mod ran"); - assertTrue(isolatedLaunch.getMod2LoadState("coreMod"), "Example2 CoreMod ran"); - assertTrue(isolatedLaunch.getMod2LoadState("mod"), "Example2 Mod ran"); - assertTrue(isolatedLaunch.isEssentialLoaded(), "Essential loaded"); - } - @Test public void testBranchSpecifiedInConfigFile(Installation installation) throws Exception { installation.addExampleMod(); diff --git a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1TweakerTests.java b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1TweakerTests.java new file mode 100644 index 0000000..7327517 --- /dev/null +++ b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1TweakerTests.java @@ -0,0 +1,35 @@ +package gg.essential.loader.stage1; + +import gg.essential.loader.fixtures.Installation; +import gg.essential.loader.fixtures.IsolatedLaunch; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class Stage1TweakerTests { + @Test + public void testMultipleCustomTweakerMods(Installation installation) throws Exception { + installation.addExampleMod(); + installation.addExample2Mod(); + + IsolatedLaunch isolatedLaunch = installation.launchFML(); + + installation.assertModLaunched(isolatedLaunch); + installation.assertMod2Launched(isolatedLaunch); + assertTrue(isolatedLaunch.isEssentialLoaded(), "Essential loaded"); + } + + @Test + public void testMultipleEssentialTweakerMods(Installation installation) throws Exception { + installation.addExampleMod("essential-tweaker"); + installation.addExample2Mod("essential-tweaker"); + + IsolatedLaunch isolatedLaunch = installation.launchFML(); + + assertTrue(isolatedLaunch.getModLoadState("coreMod"), "Example CoreMod ran"); + assertTrue(isolatedLaunch.getModLoadState("mod"), "Example Mod ran"); + assertTrue(isolatedLaunch.getMod2LoadState("coreMod"), "Example2 CoreMod ran"); + assertTrue(isolatedLaunch.getMod2LoadState("mod"), "Example2 Mod ran"); + assertTrue(isolatedLaunch.isEssentialLoaded(), "Essential loaded"); + } +} From b91a450375cb10fcbd4dcc9df0d6057a98001602 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 7 Jul 2025 15:57:56 +0200 Subject: [PATCH 05/37] tests/lw: Move props utils to dedicated class --- .../java/gg/essential/loader/util/Props.java | 34 +++++++++++++++++++ .../loader/stage1/Stage1BundledTests.java | 27 ++------------- .../loader/stage2/Stage2BundledTests.java | 6 ++-- .../essential/loader/stage2/Stage2Tests.java | 4 +-- 4 files changed, 42 insertions(+), 29 deletions(-) create mode 100644 integrationTest/common/src/main/java/gg/essential/loader/util/Props.java diff --git a/integrationTest/common/src/main/java/gg/essential/loader/util/Props.java b/integrationTest/common/src/main/java/gg/essential/loader/util/Props.java new file mode 100644 index 0000000..efc43b7 --- /dev/null +++ b/integrationTest/common/src/main/java/gg/essential/loader/util/Props.java @@ -0,0 +1,34 @@ +package gg.essential.loader.util; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +public class Props { + public static Properties readProps(Path path) throws IOException { + try (InputStream in = Files.newInputStream(path)) { + Properties props = new Properties(); + props.load(in); + return props; + } + } + + public static void writeProps(Path path, Properties props) throws IOException { + Files.createDirectories(path.getParent()); + try (Writer out = Files.newBufferedWriter(path)) { + props.store(out, null); + } + } + + public static Properties props(String...args) { + Properties props = new Properties(); + for (String arg : args) { + String[] split = arg.split("=", 2); + props.put(split[0], split[1]); + } + return props; + } +} diff --git a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1BundledTests.java b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1BundledTests.java index 2aac39b..88503e9 100644 --- a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1BundledTests.java +++ b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1BundledTests.java @@ -13,6 +13,9 @@ import java.util.Properties; import static gg.essential.loader.fixtures.BaseInstallation.withBranch; +import static gg.essential.loader.util.Props.props; +import static gg.essential.loader.util.Props.readProps; +import static gg.essential.loader.util.Props.writeProps; import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -202,28 +205,4 @@ public void testBundledVersionDowngrade(Installation installation) throws Except assertEquals("1", launch.getProperty("essential.stage2.version")); assertEquals(props("pendingUpdateVersion=2"), readProps(installation.stage1ConfigFile)); } - - public static Properties readProps(Path path) throws IOException { - try (InputStream in = Files.newInputStream(path)) { - Properties props = new Properties(); - props.load(in); - return props; - } - } - - public static void writeProps(Path path, Properties props) throws IOException { - Files.createDirectories(path.getParent()); - try (Writer out = Files.newBufferedWriter(path)) { - props.store(out, null); - } - } - - public static Properties props(String...args) { - Properties props = new Properties(); - for (String arg : args) { - String[] split = arg.split("=", 2); - props.put(split[0], split[1]); - } - return props; - } } diff --git a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2BundledTests.java b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2BundledTests.java index 5ded0db..35a5dfe 100644 --- a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2BundledTests.java +++ b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2BundledTests.java @@ -13,9 +13,9 @@ import java.util.Properties; import static gg.essential.loader.fixtures.BaseInstallation.withBranch; -import static gg.essential.loader.stage1.Stage1BundledTests.props; -import static gg.essential.loader.stage1.Stage1BundledTests.readProps; -import static gg.essential.loader.stage1.Stage1BundledTests.writeProps; +import static gg.essential.loader.util.Props.props; +import static gg.essential.loader.util.Props.readProps; +import static gg.essential.loader.util.Props.writeProps; import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; diff --git a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2Tests.java b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2Tests.java index d198aca..47ca8eb 100644 --- a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2Tests.java +++ b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2Tests.java @@ -9,8 +9,8 @@ import java.nio.file.Path; import static gg.essential.loader.fixtures.BaseInstallation.withBranch; -import static gg.essential.loader.stage1.Stage1BundledTests.props; -import static gg.essential.loader.stage1.Stage1BundledTests.writeProps; +import static gg.essential.loader.util.Props.props; +import static gg.essential.loader.util.Props.writeProps; import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; import static org.apache.commons.codec.digest.DigestUtils.md5Hex; import static org.junit.jupiter.api.Assertions.assertEquals; From c5ddf9bb7264c8edb5b07e666149d0fd60f48e61 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 7 Jul 2025 15:30:56 +0200 Subject: [PATCH 06/37] tests/lw: Move common stage1 tests to fabric project Because we'll be dropping most of stage1 from our LaunchWrapper version. --- .../loader/fixtures/IsolatedLaunch.java | 10 +++- .../essential-loader-stage2.properties | 0 .../essential-loader.properties | 0 integrationTest/fabric/build.gradle | 58 +++++++++++++++++++ .../loader/stage2/EssentialLoader.java | 19 ++++++ .../loader/fixtures/Installation.java | 4 ++ .../loader/stage1/Stage1BundledTests.java | 48 +++++++-------- .../loader/stage1/Stage1FailureTests.java | 20 +++---- .../essential/loader/stage1/Stage1Tests.java | 18 +++--- .../java/sun/gg/essential/LoadState.java | 3 + integrationTest/launchwrapper/build.gradle | 4 +- 11 files changed, 136 insertions(+), 48 deletions(-) rename integrationTest/{launchwrapper => }/essential-loader-stage2.properties (100%) rename integrationTest/{launchwrapper => }/essential-loader.properties (100%) create mode 100644 integrationTest/fabric/src/dummyStage2/java/gg/essential/loader/stage2/EssentialLoader.java rename integrationTest/{launchwrapper => fabric}/src/main/java/gg/essential/loader/stage1/Stage1BundledTests.java (86%) rename integrationTest/{launchwrapper => fabric}/src/main/java/gg/essential/loader/stage1/Stage1FailureTests.java (89%) rename integrationTest/{launchwrapper => fabric}/src/main/java/gg/essential/loader/stage1/Stage1Tests.java (71%) diff --git a/integrationTest/common/src/main/java/gg/essential/loader/fixtures/IsolatedLaunch.java b/integrationTest/common/src/main/java/gg/essential/loader/fixtures/IsolatedLaunch.java index 4c22139..cc86ecc 100644 --- a/integrationTest/common/src/main/java/gg/essential/loader/fixtures/IsolatedLaunch.java +++ b/integrationTest/common/src/main/java/gg/essential/loader/fixtures/IsolatedLaunch.java @@ -149,11 +149,15 @@ public boolean getMod2LoadState(String field) throws Exception { .getBoolean(null); } + public boolean getEssentialLoadState(String field) throws Exception { + return getClass("sun.gg.essential.LoadState") + .getDeclaredField(field) + .getBoolean(null); + } + public boolean isEssentialLoaded() throws Exception { try { - return getClass("sun.gg.essential.LoadState") - .getDeclaredField("mod") - .getBoolean(null); + return getEssentialLoadState("mod"); } catch (ClassNotFoundException ignored) { return false; } diff --git a/integrationTest/launchwrapper/essential-loader-stage2.properties b/integrationTest/essential-loader-stage2.properties similarity index 100% rename from integrationTest/launchwrapper/essential-loader-stage2.properties rename to integrationTest/essential-loader-stage2.properties diff --git a/integrationTest/launchwrapper/essential-loader.properties b/integrationTest/essential-loader.properties similarity index 100% rename from integrationTest/launchwrapper/essential-loader.properties rename to integrationTest/essential-loader.properties diff --git a/integrationTest/fabric/build.gradle b/integrationTest/fabric/build.gradle index 5cc03f9..0127078 100644 --- a/integrationTest/fabric/build.gradle +++ b/integrationTest/fabric/build.gradle @@ -8,6 +8,7 @@ sourceSets { exampleMod essential minecraft + dummyStage2 jijV1 jijV2 @@ -66,6 +67,8 @@ dependencies { essentialCompileOnly("net.fabricmc:fabric-loader:0.11.6") essentialCompileOnly("net.fabricmc:sponge-mixin:0.9.4+mixin.0.8.2") essentialCompileOnly(sourceSets.minecraft.output) + + dummyStage2CompileOnly(sourceSets.minecraft.output) } def integrationTest = tasks.register("integrationTest", Test) { @@ -88,6 +91,11 @@ tasks.register("exampleModJar", Jar) { } } +tasks.register("dummyStage2Jar", Jar) { + archiveBaseName.set("dummyStage2") + from(sourceSets.dummyStage2.output) +} + tasks.register("essentialJar", Jar) { archiveBaseName.set("essential") from(sourceSets.essential.output) @@ -140,6 +148,48 @@ tasks.register("essentialJijijJar", Jar) { } } +(1..5).each { i -> + def stage2Task = tasks.register("stage2V${i}Jar", Jar) { + archiveBaseName.set(name) + from(evaluationDependsOn(':stage2:fabric').tasks.jar.archiveFile.map { zipTree(it) }) + // Dummy attribute so they all have different hashes + manifest { + attributes "Implementation-Version": "$i" + } + } + def stage3Task = tasks.register("stage3V${i}Jar", Jar) { + archiveBaseName.set(name) + from(tasks.essentialJar.archiveFile.map { zipTree(it) }) + manifest { + // Dummy attribute so they all have different hashes + attributes "Implementation-Version": "$i" + // For stage3 version 4+, we add an explicit requirement on stage2 version 4 + if (i >= 4) { + attributes "Requires-Essential-Stage2-Version": "4" + } + } + } + tasks.register("exampleBundledModJar$i", Jar) { + archiveBaseName.set(name) + from(tasks.exampleModJar.archiveFile.map { zipTree(it) }) + + def stage2Jar = stage2Task.get().archiveFile + def stage3Jar = stage3Task.get().archiveFile + from(stage2Jar) { + rename { "bundled-stage2-${i}.jar" } + } + from(stage3Jar) { + rename { "bundled-essential-${i}.jar" } + } + from(file("../essential-loader-stage2.properties")) { + expand(["pinnedFileMd5": { stage2Jar.get().asFile.bytes.md5() }, version: i]) + } + from(file("../essential-loader.properties")) { + expand(["pinnedFileMd5": { stage3Jar.get().asFile.bytes.md5() }, "version": i]) + } + } +} + tasks.register("setupDownloadsApi", Sync) { def downloadsApi = new File(project.buildDir, "downloadsApi") into(downloadsApi) @@ -184,4 +234,12 @@ tasks.register("setupDownloadsApi", Sync) { mod(tasks.essentialJijV2Jar.archiveFile, "essential:essential", "jij2") mod(tasks.essentialJijV3Jar.archiveFile, "essential:essential", "jij3") mod(tasks.essentialJijijJar.archiveFile, "essential:essential", "jijij") + + (1..5).each { i -> + mod(tasks.named("exampleBundledModJar$i").map { it.archiveFile }, "example:mod", "bundled-$i") + mod(tasks.named("stage2V${i}Jar").map { it.archiveFile }, "essential:loader-stage2", "$i") + mod(tasks.named("stage3V${i}Jar").map { it.archiveFile }, "essential:essential", "$i") + } + + mod(tasks.dummyStage2Jar.archiveFile, "essential:loader-stage2", "dummy") } diff --git a/integrationTest/fabric/src/dummyStage2/java/gg/essential/loader/stage2/EssentialLoader.java b/integrationTest/fabric/src/dummyStage2/java/gg/essential/loader/stage2/EssentialLoader.java new file mode 100644 index 0000000..3fde009 --- /dev/null +++ b/integrationTest/fabric/src/dummyStage2/java/gg/essential/loader/stage2/EssentialLoader.java @@ -0,0 +1,19 @@ +package gg.essential.loader.stage2; + +import sun.gg.essential.LoadState; + +import java.nio.file.Path; + +@SuppressWarnings("unused") +public class EssentialLoader { + public EssentialLoader(Path gameDir, String gameVersion) { + } + + public void load() { + LoadState.dummyStage2Loaded = true; + } + + public void initialize() { + LoadState.dummyStage2Initialized = true; + } +} diff --git a/integrationTest/fabric/src/main/java/gg/essential/loader/fixtures/Installation.java b/integrationTest/fabric/src/main/java/gg/essential/loader/fixtures/Installation.java index d6629e8..6985469 100644 --- a/integrationTest/fabric/src/main/java/gg/essential/loader/fixtures/Installation.java +++ b/integrationTest/fabric/src/main/java/gg/essential/loader/fixtures/Installation.java @@ -24,6 +24,10 @@ public class Installation extends BaseInstallation { } } + public final Path stage1Folder = essentialDir.resolve("loader").resolve("stage1").resolve("fabric"); + public final Path stage1ConfigFile = stage1Folder.resolve("stage2.fabric_1.14.4.properties"); + public final Path stage2ConfigFile = essentialDir.resolve("essential-loader.properties"); + public Installation() throws IOException { // Current fabric-loader breaks on LegacyLauncher if there is a JiJ mod and MC needs to be decompiled, so we // launch once without any mods to get the decompiled MC and can the proceed as usual. diff --git a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1BundledTests.java b/integrationTest/fabric/src/main/java/gg/essential/loader/stage1/Stage1BundledTests.java similarity index 86% rename from integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1BundledTests.java rename to integrationTest/fabric/src/main/java/gg/essential/loader/stage1/Stage1BundledTests.java index 88503e9..e3c78da 100644 --- a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1BundledTests.java +++ b/integrationTest/fabric/src/main/java/gg/essential/loader/stage1/Stage1BundledTests.java @@ -26,7 +26,7 @@ public class Stage1BundledTests { public void testAutoUpdateWithBundledVersion(Installation installation) throws Exception { installation.addExampleMod("bundled-1"); - installation.launchFML(); + installation.launchFabric(); // Enable auto-update via config file (otherwise we'll default to update with-prompt when there's a pinned jar) Files.write(installation.stage1ConfigFile, "autoUpdate=true".getBytes(StandardCharsets.UTF_8)); @@ -34,12 +34,12 @@ public void testAutoUpdateWithBundledVersion(Installation installation) throws E Files.delete(installation.stage2Meta); Files.copy(installation.stage2DummyMeta, installation.stage2Meta); - IsolatedLaunch isolatedLaunch = installation.launchFML(); + IsolatedLaunch isolatedLaunch = installation.launchFabric(); installation.assertModLaunched(isolatedLaunch); assertFalse(isolatedLaunch.isEssentialLoaded(), "Essential loaded"); - assertTrue(isolatedLaunch.getClass("gg.essential.loader.stage2.EssentialLoader").getDeclaredField("loaded").getBoolean(null)); - assertTrue(isolatedLaunch.getClass("gg.essential.loader.stage2.EssentialLoader").getDeclaredField("initialized").getBoolean(null)); + assertTrue(isolatedLaunch.getEssentialLoadState("dummyStage2Loaded")); + assertTrue(isolatedLaunch.getEssentialLoadState("dummyStage2Initialized")); } @Test @@ -48,13 +48,13 @@ public void testPromptUpdateAccepted(Installation installation) throws Exception Files.copy(withBranch(installation.stage2Meta, "2"), installation.stage2Meta, REPLACE_EXISTING); - IsolatedLaunch firstLaunch = installation.launchFML(); + IsolatedLaunch firstLaunch = installation.launchFabric(); assertEquals("1", firstLaunch.getProperty("essential.stage2.version")); assertEquals(props("pendingUpdateVersion=2"), readProps(installation.stage1ConfigFile)); writeProps(installation.stage1ConfigFile, props("pendingUpdateVersion=2", "pendingUpdateResolution=true")); - IsolatedLaunch secondLaunch = installation.launchFML(); + IsolatedLaunch secondLaunch = installation.launchFabric(); assertEquals("2", secondLaunch.getProperty("essential.stage2.version")); assertEquals(props("overridePinnedVersion=2"), readProps(installation.stage1ConfigFile)); } @@ -65,13 +65,13 @@ public void testPromptUpdateRejected(Installation installation) throws Exception Files.copy(withBranch(installation.stage2Meta, "2"), installation.stage2Meta, REPLACE_EXISTING); - IsolatedLaunch firstLaunch = installation.launchFML(); + IsolatedLaunch firstLaunch = installation.launchFabric(); assertEquals("1", firstLaunch.getProperty("essential.stage2.version")); assertEquals(props("pendingUpdateVersion=2"), readProps(installation.stage1ConfigFile)); writeProps(installation.stage1ConfigFile, props("pendingUpdateVersion=2", "pendingUpdateResolution=false")); - IsolatedLaunch secondLaunch = installation.launchFML(); + IsolatedLaunch secondLaunch = installation.launchFabric(); assertEquals("1", secondLaunch.getProperty("essential.stage2.version")); installation.assertModLaunched(secondLaunch); @@ -85,11 +85,11 @@ public void testPromptUpdateAcceptedFallback(Installation installation) throws E Files.copy(withBranch(installation.stage2Meta, "2"), installation.stage2Meta, REPLACE_EXISTING); - IsolatedLaunch firstLaunch = installation.launchFML(); + IsolatedLaunch firstLaunch = installation.launchFabric(); assertEquals("1", firstLaunch.getProperty("essential.stage2.version")); assertEquals(props("pendingUpdateVersion=2"), readProps(installation.stage1ConfigFile)); - IsolatedLaunch secondLaunch = installation.newLaunchFML(); + IsolatedLaunch secondLaunch = installation.newLaunchFabric(); secondLaunch.setProperty("essential.stage1.fallback-prompt-auto-answer", "true"); secondLaunch.launch(); @@ -103,11 +103,11 @@ public void testPromptUpdateRejectedFallback(Installation installation) throws E Files.copy(withBranch(installation.stage2Meta, "2"), installation.stage2Meta, REPLACE_EXISTING); - IsolatedLaunch firstLaunch = installation.launchFML(); + IsolatedLaunch firstLaunch = installation.launchFabric(); assertEquals("1", firstLaunch.getProperty("essential.stage2.version")); assertEquals(props("pendingUpdateVersion=2"), readProps(installation.stage1ConfigFile)); - IsolatedLaunch secondLaunch = installation.newLaunchFML(); + IsolatedLaunch secondLaunch = installation.newLaunchFabric(); secondLaunch.setProperty("essential.stage1.fallback-prompt-auto-answer", "false"); secondLaunch.launch(); @@ -121,13 +121,13 @@ public void testBundledVersionUpgradeWithoutOverride(Installation installation) // Skip online update, we want to test local bundle update writeProps(installation.stage1ConfigFile, props("pendingUpdateVersion=stable", "pendingUpdateResolution=false")); - IsolatedLaunch firstLaunch = installation.launchFML(); + IsolatedLaunch firstLaunch = installation.launchFabric(); assertEquals("1", firstLaunch.getProperty("essential.stage2.version")); assertEquals("1", firstLaunch.getProperty("essential.stage2.version")); installation.addExampleMod("bundled-2"); - IsolatedLaunch secondLaunch = installation.launchFML(); + IsolatedLaunch secondLaunch = installation.launchFabric(); assertEquals("2", secondLaunch.getProperty("essential.stage2.version")); assertEquals(props("pendingUpdateVersion=stable", "pendingUpdateResolution=false"), readProps(installation.stage1ConfigFile)); } @@ -139,22 +139,22 @@ public void testBundledVersionUpgradeToNewerVersion(Installation installation) t // First install at version 1 Files.copy(withBranch(installation.stage2Meta, "1"), installation.stage2Meta, REPLACE_EXISTING); installation.addExampleMod("bundled-1"); - launch = installation.launchFML(); + launch = installation.launchFabric(); assertEquals("1", launch.getProperty("essential.stage2.version")); // Then click-to-update to version 2 Files.copy(withBranch(installation.stage2Meta, "2"), installation.stage2Meta, REPLACE_EXISTING); - launch = installation.launchFML(); + launch = installation.launchFabric(); assertEquals("1", launch.getProperty("essential.stage2.version")); assertEquals(props("pendingUpdateVersion=2"), readProps(installation.stage1ConfigFile)); writeProps(installation.stage1ConfigFile, props("pendingUpdateVersion=2", "pendingUpdateResolution=true")); - launch = installation.launchFML(); + launch = installation.launchFabric(); assertEquals("2", launch.getProperty("essential.stage2.version")); assertEquals(props("overridePinnedVersion=2"), readProps(installation.stage1ConfigFile)); // Finally upgrade the bundled mod to version 3 installation.addExampleMod("bundled-3"); - launch = installation.launchFML(); + launch = installation.launchFabric(); // it should use that version assertEquals("3", launch.getProperty("essential.stage2.version")); // and un-pin the existing one @@ -167,22 +167,22 @@ public void testBundledVersionUpgradeToOlderVersion(Installation installation) t // First install at version 1 installation.addExampleMod("bundled-1"); - launch = installation.launchFML(); + launch = installation.launchFabric(); assertEquals("1", launch.getProperty("essential.stage2.version")); // Then click-to-update to version 3 Files.copy(withBranch(installation.stage2Meta, "3"), installation.stage2Meta, REPLACE_EXISTING); - launch = installation.launchFML(); + launch = installation.launchFabric(); assertEquals("1", launch.getProperty("essential.stage2.version")); assertEquals(props("pendingUpdateVersion=3"), readProps(installation.stage1ConfigFile)); writeProps(installation.stage1ConfigFile, props("pendingUpdateVersion=3", "pendingUpdateResolution=true")); - launch = installation.launchFML(); + launch = installation.launchFabric(); assertEquals("3", launch.getProperty("essential.stage2.version")); assertEquals(props("overridePinnedVersion=3"), readProps(installation.stage1ConfigFile)); // Finally upgrade the bundled mod to version 2 installation.addExampleMod("bundled-2"); - launch = installation.launchFML(); + launch = installation.launchFabric(); // it should stay at version 3 however assertEquals("3", launch.getProperty("essential.stage2.version")); assertEquals(props("overridePinnedVersion=3"), readProps(installation.stage1ConfigFile)); @@ -195,12 +195,12 @@ public void testBundledVersionDowngrade(Installation installation) throws Except // First install at version 2 installation.addExampleMod("bundled-2"); Files.copy(withBranch(installation.stage2Meta, "2"), installation.stage2Meta, REPLACE_EXISTING); - launch = installation.launchFML(); + launch = installation.launchFabric(); assertEquals("2", launch.getProperty("essential.stage2.version")); // Then downgrade the bundled mod to version 1 installation.addExampleMod("bundled-1"); - launch = installation.launchFML(); + launch = installation.launchFabric(); // we should follow the downgrade, despite our local version being more up-to-date assertEquals("1", launch.getProperty("essential.stage2.version")); assertEquals(props("pendingUpdateVersion=2"), readProps(installation.stage1ConfigFile)); diff --git a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1FailureTests.java b/integrationTest/fabric/src/main/java/gg/essential/loader/stage1/Stage1FailureTests.java similarity index 89% rename from integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1FailureTests.java rename to integrationTest/fabric/src/main/java/gg/essential/loader/stage1/Stage1FailureTests.java index 94bd3f9..6515d1c 100644 --- a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1FailureTests.java +++ b/integrationTest/fabric/src/main/java/gg/essential/loader/stage1/Stage1FailureTests.java @@ -28,12 +28,12 @@ public void testJsonSyntaxInvalid(Installation installation, boolean secondLaunc installation.addExampleMod(); if (secondLaunch) { - installation.launchFML(); + installation.launchFabric(); } Files.write(installation.stage2Meta, "{ oh no }".getBytes(StandardCharsets.UTF_8)); - IsolatedLaunch isolatedLaunch = installation.launchFML(); + IsolatedLaunch isolatedLaunch = installation.launchFabric(); installation.assertModLaunched(isolatedLaunch); assertEquals(secondLaunch, isolatedLaunch.isEssentialLoaded(), "Essential loaded"); @@ -53,12 +53,12 @@ public void testJsonContentInvalid(Installation installation, boolean secondLaun installation.addExampleMod(); if (secondLaunch) { - installation.launchFML(); + installation.launchFabric(); } Files.write(installation.stage2Meta, "{ \"url\": 42 }".getBytes(StandardCharsets.UTF_8)); - IsolatedLaunch isolatedLaunch = installation.launchFML(); + IsolatedLaunch isolatedLaunch = installation.launchFabric(); installation.assertModLaunched(isolatedLaunch); assertEquals(secondLaunch, isolatedLaunch.isEssentialLoaded(), "Essential loaded"); @@ -78,12 +78,12 @@ public void testServerError(Installation installation, boolean secondLaunch) thr installation.addExampleMod(); if (secondLaunch) { - installation.launchFML(); + installation.launchFabric(); } Files.delete(installation.stage2Meta); - IsolatedLaunch isolatedLaunch = installation.launchFML(); + IsolatedLaunch isolatedLaunch = installation.launchFabric(); installation.assertModLaunched(isolatedLaunch); assertEquals(secondLaunch, isolatedLaunch.isEssentialLoaded(), "Essential loaded"); @@ -103,7 +103,7 @@ public void testDownloadChecksumMismatch(Installation installation, boolean seco installation.addExampleMod(); if (secondLaunch) { - installation.launchFML(); + installation.launchFabric(); } Gson gson = new Gson(); @@ -111,7 +111,7 @@ public void testDownloadChecksumMismatch(Installation installation, boolean seco meta.addProperty("checksum", "00000000000000000000000000000000"); Files.write(installation.stage2Meta, gson.toJson(meta).getBytes(StandardCharsets.UTF_8)); - IsolatedLaunch isolatedLaunch = installation.launchFML(); + IsolatedLaunch isolatedLaunch = installation.launchFabric(); installation.assertModLaunched(isolatedLaunch); assertEquals(secondLaunch, isolatedLaunch.isEssentialLoaded(), "Essential loaded"); @@ -131,7 +131,7 @@ public void testDownloadServerError(Installation installation, boolean secondLau installation.addExampleMod(); if (secondLaunch) { - installation.launchFML(); + installation.launchFabric(); } Gson gson = new Gson(); @@ -140,7 +140,7 @@ public void testDownloadServerError(Installation installation, boolean secondLau meta.addProperty("checksum", "00000000000000000000000000000000"); // to get it to update on second launch Files.write(installation.stage2Meta, gson.toJson(meta).getBytes(StandardCharsets.UTF_8)); - IsolatedLaunch isolatedLaunch = installation.launchFML(); + IsolatedLaunch isolatedLaunch = installation.launchFabric(); installation.assertModLaunched(isolatedLaunch); assertEquals(secondLaunch, isolatedLaunch.isEssentialLoaded(), "Essential loaded"); diff --git a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1Tests.java b/integrationTest/fabric/src/main/java/gg/essential/loader/stage1/Stage1Tests.java similarity index 71% rename from integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1Tests.java rename to integrationTest/fabric/src/main/java/gg/essential/loader/stage1/Stage1Tests.java index a4153a6..1d373e8 100644 --- a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1Tests.java +++ b/integrationTest/fabric/src/main/java/gg/essential/loader/stage1/Stage1Tests.java @@ -16,17 +16,17 @@ public class Stage1Tests { public void testUpdate(Installation installation) throws Exception { installation.addExampleMod(); - installation.launchFML(); + installation.launchFabric(); Files.delete(installation.stage2Meta); Files.copy(installation.stage2DummyMeta, installation.stage2Meta); - IsolatedLaunch isolatedLaunch = installation.launchFML(); + IsolatedLaunch isolatedLaunch = installation.launchFabric(); installation.assertModLaunched(isolatedLaunch); assertFalse(isolatedLaunch.isEssentialLoaded(), "Essential loaded"); - assertTrue(isolatedLaunch.getClass("gg.essential.loader.stage2.EssentialLoader").getDeclaredField("loaded").getBoolean(null)); - assertTrue(isolatedLaunch.getClass("gg.essential.loader.stage2.EssentialLoader").getDeclaredField("initialized").getBoolean(null)); + assertTrue(isolatedLaunch.getEssentialLoadState("dummyStage2Loaded")); + assertTrue(isolatedLaunch.getEssentialLoadState("dummyStage2Initialized")); } @Test @@ -43,12 +43,12 @@ public void testUnsupportedVersion(Installation installation, boolean secondLaun installation.addExampleMod(); if (secondLaunch) { - installation.launchFML(); + installation.launchFabric(); } Files.write(installation.stage2Meta, new byte[0]); - IsolatedLaunch isolatedLaunch = installation.launchFML(); + IsolatedLaunch isolatedLaunch = installation.launchFabric(); installation.assertModLaunched(isolatedLaunch); assertEquals(secondLaunch, isolatedLaunch.isEssentialLoaded(), "Essential loaded"); @@ -61,11 +61,11 @@ public void testBranchSpecifiedInConfigFile(Installation installation) throws Ex Files.createDirectories(installation.stage1ConfigFile.getParent()); Files.write(installation.stage1ConfigFile, "branch=dummy".getBytes(StandardCharsets.UTF_8)); - IsolatedLaunch isolatedLaunch = installation.launchFML(); + IsolatedLaunch isolatedLaunch = installation.launchFabric(); installation.assertModLaunched(isolatedLaunch); assertFalse(isolatedLaunch.isEssentialLoaded(), "Essential loaded"); - assertTrue(isolatedLaunch.getClass("gg.essential.loader.stage2.EssentialLoader").getDeclaredField("loaded").getBoolean(null)); - assertTrue(isolatedLaunch.getClass("gg.essential.loader.stage2.EssentialLoader").getDeclaredField("initialized").getBoolean(null)); + assertTrue(isolatedLaunch.getEssentialLoadState("dummyStage2Loaded")); + assertTrue(isolatedLaunch.getEssentialLoadState("dummyStage2Initialized")); } } diff --git a/integrationTest/fabric/src/minecraft/java/sun/gg/essential/LoadState.java b/integrationTest/fabric/src/minecraft/java/sun/gg/essential/LoadState.java index 245b13b..a556003 100644 --- a/integrationTest/fabric/src/minecraft/java/sun/gg/essential/LoadState.java +++ b/integrationTest/fabric/src/minecraft/java/sun/gg/essential/LoadState.java @@ -6,4 +6,7 @@ public class LoadState { public static boolean tweaker = false; public static boolean mod = false; + + public static boolean dummyStage2Loaded = false; + public static boolean dummyStage2Initialized = false; } diff --git a/integrationTest/launchwrapper/build.gradle b/integrationTest/launchwrapper/build.gradle index 42e9b8e..d3b0726 100644 --- a/integrationTest/launchwrapper/build.gradle +++ b/integrationTest/launchwrapper/build.gradle @@ -231,10 +231,10 @@ tasks.register("exampleRelocatedModJar", com.github.jengelman.gradle.plugins.sha from(stage3Jar) { rename { "bundled-essential-${i}.jar" } } - from(file("essential-loader-stage2.properties")) { + from(file("../essential-loader-stage2.properties")) { expand(["pinnedFileMd5": { stage2Jar.get().asFile.bytes.md5() }, version: i]) } - from(file("essential-loader.properties")) { + from(file("../essential-loader.properties")) { expand(["pinnedFileMd5": { stage3Jar.get().asFile.bytes.md5() }, "version": i]) } } From ecead0591df37495e8068af5676239843f763d6e Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 7 Jul 2025 13:54:00 +0200 Subject: [PATCH 07/37] tests/lw: Move `sun.gg.essential.LoadState` into `minecraft` source set So it's always the original IsolatedLaunch classpath, even when EssentialLoader only adds Essential inside a RelaunchClassLoader. --- integrationTest/launchwrapper/build.gradle | 2 ++ .../java/sun/gg/essential/LoadState.java | 0 .../minecraft11202/java/sun/gg/essential/LoadState.java | 7 +++++++ 3 files changed, 9 insertions(+) rename integrationTest/launchwrapper/src/{essential => minecraft10808}/java/sun/gg/essential/LoadState.java (100%) create mode 100644 integrationTest/launchwrapper/src/minecraft11202/java/sun/gg/essential/LoadState.java diff --git a/integrationTest/launchwrapper/build.gradle b/integrationTest/launchwrapper/build.gradle index d3b0726..69176c6 100644 --- a/integrationTest/launchwrapper/build.gradle +++ b/integrationTest/launchwrapper/build.gradle @@ -107,10 +107,12 @@ dependencies { essentialCompileOnly(launchwrapper) essentialCompileOnly(forge) + essentialCompileOnly(sourceSets.minecraft10808.output) dummyStage1CompileOnly(launchwrapper) dummyStage2CompileOnly(launchwrapper) dummyStage3CompileOnly(launchwrapper) + dummyStage3CompileOnly(sourceSets.minecraft10808.output) } def integrationTest = tasks.register("integrationTest", Test) { diff --git a/integrationTest/launchwrapper/src/essential/java/sun/gg/essential/LoadState.java b/integrationTest/launchwrapper/src/minecraft10808/java/sun/gg/essential/LoadState.java similarity index 100% rename from integrationTest/launchwrapper/src/essential/java/sun/gg/essential/LoadState.java rename to integrationTest/launchwrapper/src/minecraft10808/java/sun/gg/essential/LoadState.java diff --git a/integrationTest/launchwrapper/src/minecraft11202/java/sun/gg/essential/LoadState.java b/integrationTest/launchwrapper/src/minecraft11202/java/sun/gg/essential/LoadState.java new file mode 100644 index 0000000..311b1b9 --- /dev/null +++ b/integrationTest/launchwrapper/src/minecraft11202/java/sun/gg/essential/LoadState.java @@ -0,0 +1,7 @@ +package sun.gg.essential; + +// Being in the sun package excludes this class from the launch class loader, so it can be safely accessed from everywhere +public class LoadState { + public static boolean tweaker = false; + public static boolean mod = false; +} From abacc34c399e33335855023fbb2652d2b3003200 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 7 Jul 2025 13:16:23 +0200 Subject: [PATCH 08/37] tests/lw: Move `dummyInitialized` to `LoadState` So it can be accessed even when EssentialLoader relaunches. --- .../java/gg/essential/api/tweaker/EssentialTweaker.java | 6 +++--- .../gg/essential/loader/stage2/Stage2BundledTests.java | 2 +- .../main/java/gg/essential/loader/stage2/Stage2Tests.java | 8 ++++---- .../minecraft10808/java/sun/gg/essential/LoadState.java | 1 + .../minecraft11202/java/sun/gg/essential/LoadState.java | 1 + 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/integrationTest/launchwrapper/src/dummyStage3/java/gg/essential/api/tweaker/EssentialTweaker.java b/integrationTest/launchwrapper/src/dummyStage3/java/gg/essential/api/tweaker/EssentialTweaker.java index 344ee19..8ddfcc1 100644 --- a/integrationTest/launchwrapper/src/dummyStage3/java/gg/essential/api/tweaker/EssentialTweaker.java +++ b/integrationTest/launchwrapper/src/dummyStage3/java/gg/essential/api/tweaker/EssentialTweaker.java @@ -1,12 +1,12 @@ package gg.essential.api.tweaker; +import sun.gg.essential.LoadState; + import java.io.File; @SuppressWarnings("unused") public class EssentialTweaker { - public static boolean dummyInitialized; - public static void initialize(File gameDir) { - dummyInitialized = true; + LoadState.dummyTweaker = true; } } diff --git a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2BundledTests.java b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2BundledTests.java index 35a5dfe..e5ef4a7 100644 --- a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2BundledTests.java +++ b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2BundledTests.java @@ -36,7 +36,7 @@ public void testAutoUpdateWithBundledVersion(Installation installation) throws E IsolatedLaunch isolatedLaunch = installation.launchFML(); installation.assertModLaunched(isolatedLaunch); - assertTrue(isolatedLaunch.getClass("gg.essential.api.tweaker.EssentialTweaker").getDeclaredField("dummyInitialized").getBoolean(null)); + assertTrue(isolatedLaunch.getEssentialLoadState("dummyTweaker")); } @Test diff --git a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2Tests.java b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2Tests.java index 47ca8eb..6338b06 100644 --- a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2Tests.java +++ b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2Tests.java @@ -48,9 +48,9 @@ public void testUpdate(Installation installation, boolean viaDiff) throws Except IsolatedLaunch isolatedLaunch = installation.launchFML(); installation.assertModLaunched(isolatedLaunch); - assertTrue(isolatedLaunch.getClass("gg.essential.api.tweaker.EssentialTweaker").getDeclaredField("dummyInitialized").getBoolean(null)); + assertTrue(isolatedLaunch.getClass("sun.gg.essential.LoadState").getDeclaredField("dummyTweaker").getBoolean(null)); - String expectedHash = "694929e3f553445861cf55ba7f9b3be7"; + String expectedHash = "bff7d3997b96b24f618ea906371bae01"; assertEquals(expectedHash, md5Hex(Files.readAllBytes(installation.stage3DummyJarFile))); assertEquals(expectedHash, md5Hex(Files.readAllBytes(installation.essentialDir.resolve("Essential (forge_1.8.8).jar")))); } @@ -102,7 +102,7 @@ public void testBranchSpecifiedInConfigFile(Installation installation) throws Ex IsolatedLaunch isolatedLaunch = installation.launchFML(); installation.assertModLaunched(isolatedLaunch); - assertTrue(isolatedLaunch.getClass("gg.essential.api.tweaker.EssentialTweaker").getDeclaredField("dummyInitialized").getBoolean(null)); + assertTrue(isolatedLaunch.getClass("sun.gg.essential.LoadState").getDeclaredField("dummyTweaker").getBoolean(null)); } @Test @@ -120,7 +120,7 @@ public void testUpdateRequiringNewerStage2(Installation installation) throws Exc IsolatedLaunch secondLaunch = installation.launchFML(); assertEquals("2", secondLaunch.getProperty("essential.stage2.version")); assertNull(secondLaunch.getProperty("essential.version")); - assertThrows(ClassNotFoundException.class, () -> secondLaunch.getClass("gg.essential.api.tweaker.EssentialTweaker")); + assertThrows(ClassNotFoundException.class, () -> secondLaunch.getClass("sun.gg.essential.LoadState")); // Make available a newer stage2 version // We make available version 5 even though stage3 only requires version 4; we expect it to upgrade straight to 5 diff --git a/integrationTest/launchwrapper/src/minecraft10808/java/sun/gg/essential/LoadState.java b/integrationTest/launchwrapper/src/minecraft10808/java/sun/gg/essential/LoadState.java index 311b1b9..01957db 100644 --- a/integrationTest/launchwrapper/src/minecraft10808/java/sun/gg/essential/LoadState.java +++ b/integrationTest/launchwrapper/src/minecraft10808/java/sun/gg/essential/LoadState.java @@ -2,6 +2,7 @@ // Being in the sun package excludes this class from the launch class loader, so it can be safely accessed from everywhere public class LoadState { + public static boolean dummyTweaker = false; public static boolean tweaker = false; public static boolean mod = false; } diff --git a/integrationTest/launchwrapper/src/minecraft11202/java/sun/gg/essential/LoadState.java b/integrationTest/launchwrapper/src/minecraft11202/java/sun/gg/essential/LoadState.java index 311b1b9..01957db 100644 --- a/integrationTest/launchwrapper/src/minecraft11202/java/sun/gg/essential/LoadState.java +++ b/integrationTest/launchwrapper/src/minecraft11202/java/sun/gg/essential/LoadState.java @@ -2,6 +2,7 @@ // Being in the sun package excludes this class from the launch class loader, so it can be safely accessed from everywhere public class LoadState { + public static boolean dummyTweaker = false; public static boolean tweaker = false; public static boolean mod = false; } From e884862865309ef123fc76969bb14dfa6629bf30 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 7 Jul 2025 16:22:10 +0200 Subject: [PATCH 09/37] tests/lw: Make `getBlackboard` work with relaunching --- .../loader/fixtures/IsolatedLaunch.java | 16 +++++++++++++--- .../loader/stage2/relaunch/Relaunch.java | 2 ++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/integrationTest/common/src/main/java/gg/essential/loader/fixtures/IsolatedLaunch.java b/integrationTest/common/src/main/java/gg/essential/loader/fixtures/IsolatedLaunch.java index cc86ecc..9bf8a4a 100644 --- a/integrationTest/common/src/main/java/gg/essential/loader/fixtures/IsolatedLaunch.java +++ b/integrationTest/common/src/main/java/gg/essential/loader/fixtures/IsolatedLaunch.java @@ -171,9 +171,19 @@ public String getModVersion(String modId) throws Exception { @SuppressWarnings("unchecked") public Map getBlackboard() throws Exception { - return (Map) getClass(LAUNCH_CLASS_NAME) - .getDeclaredField("blackboard") - .get(null); + ClassLoader classLoader = loader; + while (true) { + Map blackboard = (Map) Class.forName(LAUNCH_CLASS_NAME, false, classLoader) + .getDeclaredField("blackboard") + .get(null); + + classLoader = (ClassLoader) blackboard.get("gg.essential.loader.stage2.relaunchClassLoader"); + if (classLoader != null) { + continue; + } + + return blackboard; + } } public Class getClass(String name) throws ClassNotFoundException { diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/Relaunch.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/Relaunch.java index f677bbc..7b33f4b 100644 --- a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/Relaunch.java +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/Relaunch.java @@ -115,6 +115,8 @@ public static void relaunch(URL essentialUrl) { } RelaunchClassLoader relaunchClassLoader = new RelaunchClassLoader(urls.toArray(new URL[0]), systemClassLoader); + // Make it available for introspection in our tests + Launch.blackboard.put("gg.essential.loader.stage2.relaunchClassLoader", relaunchClassLoader); List args = new ArrayList<>(LaunchArgs.guessLaunchArgs()); String main = args.remove(0); From c3d312fe72ff70c0635ba62ca6f84b1b017c52b6 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Wed, 7 May 2025 10:21:40 +0200 Subject: [PATCH 10/37] stage2/lw: Always relaunch We effectively always relaunch on 1.8.9 anyway, and relaunch on 1.12.2 frequently due to other mods, so we may as well just re-launch unconditionally and clean up all the code we use to determine whether we need to relaunch and all the old code we use to still get our classes loaded over others when we don't relaunch. --- docs/platforms.md | 4 +- .../mod/tweaker/ExampleModTweaker.java | 6 - .../loader/stage1/Stage1DevEnvTests.java | 26 -- .../loader/stage2/RelaunchTests.java | 70 ------ .../loader/stage2/EssentialLoader.java | 229 +----------------- .../loader/stage2/relaunch/Relaunch.java | 1 - .../loader/stage2/utils/Versions.java | 97 -------- 7 files changed, 5 insertions(+), 428 deletions(-) delete mode 100644 stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/utils/Versions.java diff --git a/docs/platforms.md b/docs/platforms.md index f3599a7..d45e2c7 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -61,8 +61,8 @@ and re-launch Minecraft in there, this time with more recent versions of certain the inner LaunchWrapper that would otherwise not be possible to make. Relaunching is always required on 1.8.9 because the default ASM library is too old for what Mixin 0.8 needs. -On 1.12.2 we can get away without relaunching as long as there is no other mod that pulls in an older version of one of -our libs (Kotlin being a frequent example). +On 1.12.2 we could get away without relaunching as long as there is no other mod that pulls in an older version of one +of our libs (Kotlin being a frequent example), but for simplicity we'll simply always relaunch there as well. #### Others There are numerous other hacks used on this platform. For now, refer to the code. diff --git a/integrationTest/launchwrapper/src/exampleMod/java/com/example/mod/tweaker/ExampleModTweaker.java b/integrationTest/launchwrapper/src/exampleMod/java/com/example/mod/tweaker/ExampleModTweaker.java index ceda60f..59a0d92 100644 --- a/integrationTest/launchwrapper/src/exampleMod/java/com/example/mod/tweaker/ExampleModTweaker.java +++ b/integrationTest/launchwrapper/src/exampleMod/java/com/example/mod/tweaker/ExampleModTweaker.java @@ -12,12 +12,6 @@ import java.util.Objects; public class ExampleModTweaker extends EssentialSetupTweaker { - static { - if (Boolean.parseBoolean(System.getProperty("examplemod.exclude_kotlin_from_transformers", "false"))) { - Launch.classLoader.addTransformerExclusion("kotlin.something."); - } - } - @Override public void acceptOptions(List args, File gameDir, File assetsDir, String profile) { super.acceptOptions(args, gameDir, assetsDir, profile); diff --git a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1DevEnvTests.java b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1DevEnvTests.java index c29f527..65f30d8 100644 --- a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1DevEnvTests.java +++ b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1DevEnvTests.java @@ -42,30 +42,4 @@ public void testEssentialTweakerModsInDev(Installation installation) throws Exce assertTrue(isolatedLaunch.getMod2LoadState("mod"), "Example2 Mod ran"); assertTrue(isolatedLaunch.isEssentialLoaded(), "Essential loaded"); } - - @Test - public void testInDevWithRelaunch(Installation installation) throws Exception { - IsolatedLaunch isolatedLaunch = newDevLaunch(installation); - isolatedLaunch.setProperty("essential.branch", "asm-52"); - isolatedLaunch.launch(); - - assertTrue(isolatedLaunch.getModLoadState("coreMod"), "Example CoreMod ran"); - assertTrue(isolatedLaunch.getModLoadState("mod"), "Example Mod ran"); - assertTrue(isolatedLaunch.isEssentialLoaded(), "Essential loaded"); - } - - @Test - public void testEssentialTweakerModsInDevWithRelaunch(Installation installation) throws Exception { - installation.addExample2Mod("essential-tweaker"); - - IsolatedLaunch isolatedLaunch = newDevLaunch(installation); - isolatedLaunch.setProperty("essential.branch", "asm-52"); - isolatedLaunch.launch(); - - assertTrue(isolatedLaunch.getModLoadState("coreMod"), "Example CoreMod ran"); - assertTrue(isolatedLaunch.getModLoadState("mod"), "Example Mod ran"); - assertTrue(isolatedLaunch.getMod2LoadState("coreMod"), "Example2 CoreMod ran"); - assertTrue(isolatedLaunch.getMod2LoadState("mod"), "Example2 Mod ran"); - assertTrue(isolatedLaunch.isEssentialLoaded(), "Essential loaded"); - } } diff --git a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchTests.java b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchTests.java index 01f739e..c4317a7 100644 --- a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchTests.java +++ b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchTests.java @@ -28,67 +28,6 @@ public class RelaunchTests { private static final String DEV_LAUNCH_INJECTOR_MAIN = "net.fabricmc.devlaunchinjector.Main"; private static final String FML_TWEAKER = "net.minecraftforge.fml.common.launcher.FMLTweaker"; - @Test - public void testOldKotlinOnClasspath(Installation installation) throws Exception { - installation.addExampleMod(); - installation.addOldKotlinMod(); - - IsolatedLaunch isolatedLaunch = installation.newLaunchFML(); - isolatedLaunch.setProperty("essential.loader.relaunch", "false"); // do not want to re-launch for this one - isolatedLaunch.launch(); - - installation.assertModLaunched(isolatedLaunch); - assertTrue(isolatedLaunch.isEssentialLoaded(), "Essential loaded"); - } - - @Test - public void testRelaunchDueToOldKotlin(Installation installation) throws Exception { - installation.addExampleMod(); - installation.addOldKotlinMod(); - - IsolatedLaunch isolatedLaunch = installation.launchFML(); - - installation.assertModLaunched(isolatedLaunch); - assertTrue(isolatedLaunch.isEssentialLoaded(), "Essential loaded"); - } - - @Test - public void testRelaunchDueToOldMixin(Installation installation) throws Exception { - installation.addExampleMod("stable-with-mixin-07"); - - IsolatedLaunch isolatedLaunch = installation.newLaunchFML(); - isolatedLaunch.setProperty("essential.branch", "mixin-08"); - isolatedLaunch.launch(); - - installation.assertModLaunched(isolatedLaunch); - assertTrue(isolatedLaunch.isEssentialLoaded(), "Essential loaded"); - } - - @Test - public void testRelaunchDueToKotlinBeingExcluded(Installation installation) throws Exception { - installation.addExampleMod(); - - IsolatedLaunch isolatedLaunch = installation.newLaunchFML(); - isolatedLaunch.setProperty("examplemod.exclude_kotlin_from_transformers", "true"); - isolatedLaunch.launch(); - - installation.assertModLaunched(isolatedLaunch); - assertTrue(isolatedLaunch.isEssentialLoaded(), "Essential loaded"); - } - - @Test - public void testRelaunchDueToOldAsm(Installation installation) throws Exception { - installation.addExampleMod(); - - IsolatedLaunch isolatedLaunch = installation.newLaunchFML(); - isolatedLaunch.setProperty("essential.branch", "asm-52"); - isolatedLaunch.setProperty("examplemod.require_asm52", "true"); - isolatedLaunch.launch(); - - installation.assertModLaunched(isolatedLaunch); - assertTrue(isolatedLaunch.isEssentialLoaded(), "Essential loaded"); - } - @Test public void testRelaunchOfInitPhaseMixinWithMixin07(Installation installation) throws Exception { testRelaunchOfInitPhaseMixin(installation, "07"); @@ -108,7 +47,6 @@ public void testRelaunchOfInitPhaseMixin(Installation installation, String outer // the appenders if one with the same name already exists, thereby breaking the inner mixin if we do not fix it. IsolatedLaunch isolatedLaunch = installation.newLaunchFML11202(); isolatedLaunch.setProperty("essential.branch", "mixin-08"); - isolatedLaunch.setProperty("essential.loader.relaunch.force", "late"); isolatedLaunch.launch(); assertTrue(isolatedLaunch.getModLoadState("mixinInitPhase"), "Example INIT-phase mixin applied"); @@ -172,8 +110,6 @@ private void configureDevLaunch(IsolatedLaunch launch, Installation installation @Test public void testRelaunchArgs_UnknownMain(Installation installation) throws Exception { - installation.addOldKotlinMod(); // to trigger the relaunch - IsolatedLaunch isolatedLaunch = newDevLaunch(installation, "some.unknown.launcher.Main"); isolatedLaunch.launch(); @@ -192,8 +128,6 @@ public void testRelaunchArgs_UnknownMain(Installation installation) throws Excep @Test public void testRelaunchArgs_LaunchWrapper(Installation installation) throws Exception { - installation.addOldKotlinMod(); // to trigger the relaunch - IsolatedLaunch isolatedLaunch = newDevLaunch(installation, LAUNCH_WRAPPER_MAIN, "--tweakClass", FML_TWEAKER); isolatedLaunch.launch(); @@ -209,8 +143,6 @@ public void testRelaunchArgs_LaunchWrapper(Installation installation) throws Exc @Test public void testRelaunchArgs_GradleStart(Installation installation) throws Exception { - installation.addOldKotlinMod(); // to trigger the relaunch - IsolatedLaunch isolatedLaunch = installation.newLaunchFML(); isolatedLaunch.addArg("--uuid", "UUID"); configureDevLaunch(isolatedLaunch, installation, "GradleStart"); @@ -230,8 +162,6 @@ public void testRelaunchArgs_GradleStart(Installation installation) throws Excep @Test public void testRelaunchArgs_DevLaunchInjector(Installation installation) throws Exception { - installation.addOldKotlinMod(); // to trigger the relaunch - String dliConfig = "clientArgs\n\t--tweakClass\n\t" + FML_TWEAKER; Path dliConfigPath = installation.gameDir.resolve("dli-config.toml"); diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java index ea84a63..26ef823 100644 --- a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java @@ -3,41 +3,31 @@ import gg.essential.loader.stage2.data.ModJarMetadata; import gg.essential.loader.stage2.relaunch.Relaunch; import gg.essential.loader.stage2.util.Delete; -import gg.essential.loader.stage2.utils.Versions; import net.minecraft.launchwrapper.ITweaker; import net.minecraft.launchwrapper.Launch; -import net.minecraft.launchwrapper.LaunchClassLoader; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.lang.reflect.Field; import java.lang.reflect.Method; import java.net.MalformedURLException; import java.net.URL; import java.net.URLClassLoader; import java.nio.file.FileSystem; import java.nio.file.FileSystems; -import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; -import java.nio.file.attribute.BasicFileAttributes; -import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import java.util.jar.Attributes; import java.util.jar.Manifest; -import java.util.stream.Stream; import static gg.essential.loader.stage2.Utils.findMostRecentFile; import static gg.essential.loader.stage2.Utils.findNextMostRecentFile; @@ -113,7 +103,9 @@ protected void loadPlatform() { ourMixinUrl = ourEssentialUrl; } - preloadEssential(ourEssentialPath, ourEssentialUrl); + if (Relaunch.checkEnabled()) { + Relaunch.relaunch(ourMixinUrl); + } try { injectMixinTweaker(); @@ -206,173 +198,6 @@ protected void addToClasspath(Path path) { ourMixinUrl = url; } - private void preloadEssential(Path path, URL url) { - if (System.getProperty(Relaunch.FORCE_PROPERTY, "").equals("early")) { - if (Relaunch.checkEnabled()) { - Relaunch.relaunch(ourMixinUrl); - } - } - - String outdatedAsm = isAsmOutdated(url); - if (outdatedAsm != null) { - LOGGER.warn("Found an old version of ASM ({}). This may cause issues.", outdatedAsm); - if (Relaunch.checkEnabled()) { - Relaunch.relaunch(url); - } - } - - // Pre-load the resource cache of the launch class loader with the class files of some of our libraries. - // Doing so will allow us to load our version, even if there is an older version already on the classpath - // before our jar. This will of course only work if they have not already been loaded but in that case - // there's really not much we can do about it anyway. - try { - Field classLoaderExceptionsField = LaunchClassLoader.class.getDeclaredField("classLoaderExceptions"); - classLoaderExceptionsField.setAccessible(true); - @SuppressWarnings("unchecked") - Set classLoaderExceptions = (Set) classLoaderExceptionsField.get(Launch.classLoader); - - Field transformerExceptionsField = LaunchClassLoader.class.getDeclaredField("transformerExceptions"); - transformerExceptionsField.setAccessible(true); - @SuppressWarnings("unchecked") - Set transformerExceptions = (Set) transformerExceptionsField.get(Launch.classLoader); - - // Some mods (BetterFoliage) will exclude kotlin from transformations, thereby voiding our pre-loading. - boolean kotlinExcluded = Stream.concat(classLoaderExceptions.stream(), transformerExceptions.stream()) - .anyMatch(prefix -> prefix.startsWith("kotlin")); - if (kotlinExcluded && !Relaunch.HAPPENED) { - LOGGER.warn("Found Kotlin to be excluded from LaunchClassLoader transformations. This may cause issues."); - LOGGER.debug("classLoaderExceptions:"); - for (String classLoaderException : classLoaderExceptions) { - LOGGER.debug(" - {}", classLoaderException); - } - LOGGER.debug("transformerExceptions:"); - for (String transformerException : transformerExceptions) { - LOGGER.debug(" - {}", transformerException); - } - if (Relaunch.checkEnabled()) { - throw new RelaunchRequest(); - } - } - - // Some mods include signatures for all the classes in their jar, including Mixin. As a result, if any other - // mod ships a Mixin version different from theirs (we likely do), it'll explode because of mis-matching - // signatures. - String signedMixinMod = findSignedMixin(); - if (signedMixinMod != null && !Relaunch.HAPPENED) { - // To work around that, we'll re-launch. That works because our relaunch class loader does not implement - // signature loading. - LOGGER.warn("Found {}. This mod includes signatures for its bundled Mixin and will explode if " + - "a different Mixin version (even a more recent one) is loaded.", signedMixinMod); - if (Relaunch.ENABLED) { - LOGGER.warn("Trying to work around the issue by re-launching which will ignore signatures."); - } else { - LOGGER.warn("Cannot apply workaround because re-launching is disabled."); - } - if (Relaunch.checkEnabled()) { - throw new RelaunchRequest(); - } - } - - Field resourceCacheField = LaunchClassLoader.class.getDeclaredField("resourceCache"); - resourceCacheField.setAccessible(true); - @SuppressWarnings("unchecked") - Map resourceCache = (Map) resourceCacheField.get(Launch.classLoader); - - Field negativeResourceCacheField = LaunchClassLoader.class.getDeclaredField("negativeResourceCache"); - negativeResourceCacheField.setAccessible(true); - @SuppressWarnings("unchecked") - Set negativeResourceCache = (Set) negativeResourceCacheField.get(Launch.classLoader); - - try (FileSystem fileSystem = FileSystems.newFileSystem(path, null)) { - Path[] libs = { - fileSystem.getPath("kotlin"), - fileSystem.getPath("kotlinx", "coroutines"), - fileSystem.getPath("gg", "essential", "universal"), - fileSystem.getPath("gg", "essential", "elementa"), - fileSystem.getPath("gg", "essential", "vigilance"), - fileSystem.getPath("codes", "som", "anthony", "koffee"), - fileSystem.getPath("org", "kodein"), - }; - for (Path libPath : libs) { - preloadLibrary(path, libPath, resourceCache, negativeResourceCache); - } - - // Mixin is primarily a tweaker lib, so the chances of it having already been loaded by this point - // are not nearly as small as non-tweaker libs. So, to reduce the chance of instability caused by - // incompatible implementation classes, we only force our version if it is not already initialized. - if (Launch.blackboard.get("mixin.initialised") == null) { - preloadLibrary(path, fileSystem.getPath("org", "spongepowered"), resourceCache, negativeResourceCache); - } - } - - if (Launch.classLoader.getClassBytes("pl.asie.foamfix.coremod.FoamFixCore") != null) { - // FoamFix will by default replace the resource cache map with a weak one, thereby negating our hack. - // To work around that, we preempt its replacement and put in a map which will throw an exception when - // iterated. - LOGGER.info("Detected FoamFix, locking LaunchClassLoader.resourceCache"); - resourceCacheField.set(Launch.classLoader, new ConcurrentHashMap(resourceCache) { - // FoamFix will call this before overwriting the resourceCache field - @Override - public Set> entrySet() { - throw new RuntimeException("Suppressing FoamFix LaunchWrapper weak resource cache.") { - // It'll then catch the exception and print it, which we can make less noisy. - @Override - public void printStackTrace() { - LOGGER.info(this.getMessage()); - } - }; - } - }); - } - } catch (RelaunchRequest relaunchRequest) { - Relaunch.relaunch(url); - } catch (Exception e) { - LOGGER.error("Failed to pre-load dependencies: ", e); - } - } - - private void preloadLibrary(Path jarPath, Path libPath, Map resourceCache, Set negativeResourceCache) throws IOException { - if (Files.notExists(libPath)) { - LOGGER.debug("Not pre-loading {} because it does not exist.", libPath); - return; - } - - LOGGER.debug("Pre-loading {} from {}..", libPath, jarPath); - long start = System.nanoTime(); - - Files.walkFileTree(libPath, new SimpleFileVisitor() { - private static final String SUFFIX = ".class"; - private boolean warned; - - @Override - public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) throws IOException { - if (path.getFileName().toString().endsWith(SUFFIX)) { - String file = path.toString().substring(1); - String name = file.substring(0, file.length() - SUFFIX.length()).replace('/', '.'); - byte[] bytes = Files.readAllBytes(path); - byte[] oldBytes = resourceCache.put(name, bytes); - if (oldBytes != null && !Arrays.equals(oldBytes, bytes) && !warned) { - warned = true; - LOGGER.warn("Found potentially conflicting version of {} already loaded. This may cause issues.", libPath); - LOGGER.warn("First conflicting class: {}", name); - try { - LOGGER.warn("Likely source: {}", Launch.classLoader.findResource(file)); - } catch (Throwable t) { - LOGGER.warn("Unable to determine likely source:", t); - } - if (Relaunch.checkEnabled()) { - throw new RelaunchRequest(); - } - } - negativeResourceCache.remove(name); - } - return FileVisitResult.CONTINUE; - } - }); - - LOGGER.debug("Done after {}ns.", System.nanoTime() - start); - } - // Production requires usage of the MixinTweaker. Simply calling MixinBootstrap.init() will not always work, even // if it appears to work most of the time. // This code is a intentional duplicate of the one in stage1. The one over there is in case the third-party mod @@ -407,20 +232,6 @@ private static void injectMixinTweaker() throws ClassNotFoundException, IllegalA protected void doInitialize() { detectStage0Tweaker(); - String outdatedMixin = isMixinOutdated(); - if (outdatedMixin != null) { - LOGGER.warn("Found an old version of Mixin ({}). This may cause issues.", outdatedMixin); - if (Relaunch.checkEnabled()) { - Relaunch.relaunch(ourMixinUrl); - } - } - - if (System.getProperty(Relaunch.FORCE_PROPERTY, "").equals("late")) { - if (Relaunch.checkEnabled()) { - Relaunch.relaunch(ourMixinUrl); - } - } - super.doInitialize(); } @@ -436,38 +247,4 @@ private void detectStage0Tweaker() { } } } - - private String isMixinOutdated() { - String loadedVersion = String.valueOf(Launch.blackboard.get("mixin.initialised")); - String bundledVersion = Versions.getMixinVersion(ourMixinUrl); - LOGGER.debug("Found Mixin {} loaded, we bundle {}", loadedVersion, bundledVersion); - if (Versions.compare("mixin", loadedVersion, bundledVersion) < 0) { - return loadedVersion; - } else { - return null; - } - } - - private String isAsmOutdated(URL ourUrl) { - String loadedVersion = org.objectweb.asm.ClassWriter.class.getPackage().getImplementationVersion(); - String bundledVersion = Versions.getAsmVersion(ourUrl); - LOGGER.debug("Found ASM {} loaded, we bundle {}", loadedVersion, bundledVersion); - if (Versions.compare("ASM", loadedVersion, bundledVersion) < 0) { - return loadedVersion; - } else { - return null; - } - } - - private String findSignedMixin() throws IOException { - if (hasClass("net.darkhax.surge.Surge")) return "Surge"; - if (hasClass("me.jellysquid.mods.phosphor.core.PhosphorFMLLoadingPlugin")) return "Phosphor"; - return null; - } - - private static boolean hasClass(String name) throws IOException { - return Launch.classLoader.getClassBytes(name) != null; - } - - private static class RelaunchRequest extends RuntimeException {} } diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/Relaunch.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/Relaunch.java index 7b33f4b..f2faffe 100644 --- a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/Relaunch.java +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/Relaunch.java @@ -30,7 +30,6 @@ public class Relaunch { private static final String HAPPENED_PROPERTY = "essential.loader.relaunched"; private static final String ENABLED_PROPERTY = "essential.loader.relaunch"; - public static final String FORCE_PROPERTY = "essential.loader.relaunch.force"; /** Whether we are currently inside a re-launch due to classpath complications. */ public static final boolean HAPPENED = Boolean.parseBoolean(System.getProperty(HAPPENED_PROPERTY, "false")); diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/utils/Versions.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/utils/Versions.java deleted file mode 100644 index 2393fdd..0000000 --- a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/utils/Versions.java +++ /dev/null @@ -1,97 +0,0 @@ -package gg.essential.loader.stage2.utils; - -import org.objectweb.asm.ClassReader; -import org.objectweb.asm.Opcodes; -import org.objectweb.asm.tree.ClassNode; -import org.objectweb.asm.tree.FieldNode; - -import java.io.IOException; -import java.io.InputStream; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.file.FileSystem; -import java.nio.file.FileSystems; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Collections; - -import static gg.essential.loader.stage2.EssentialLoader.LOGGER; -import static gg.essential.loader.stage2.EssentialLoaderBase.asJar; - -public class Versions { - public static int compare(String what, String left, String right) { - return compare(parseVersion(what, left), parseVersion(what, right)); - } - - public static int compare(int[] left, int[] right) { - if (left == null || right == null) { - return 0; - } - for (int i = 0; i < Math.max(left.length, right.length); i++) { - int l = i < left.length ? left[i] : 0; - int r = i < right.length ? right[i] : 0; - if (l < r) { - return -1; - } else if (l > r) { - return 1; - } - } - return 0; - } - - public static int[] parseVersion(String what, String version) { - if (version == null) { - return null; - } - String[] parts = version.split("[.-]"); - int[] numbers = new int[parts.length]; - for (int i = 0; i < parts.length; i++) { - try { - numbers[i] = Integer.parseInt(parts[i]); - } catch (NumberFormatException e) { - LOGGER.warn("Failed to parse {} version \"{}\".", what, version); - LOGGER.debug(e); - return null; - } - } - return numbers; - } - - public static String getMixinVersion(URL jarUrl) { - try (FileSystem fileSystem = FileSystems.newFileSystem(asJar(jarUrl.toURI()), Collections.emptyMap())) { - Path bootstrapPath = fileSystem.getPath("org", "spongepowered", "asm", "launch", "MixinBootstrap.class"); - try (InputStream inputStream = Files.newInputStream(bootstrapPath)) { - ClassReader reader = new ClassReader(inputStream); - ClassNode classNode = new ClassNode(Opcodes.ASM5); - reader.accept(classNode, 0); - for (FieldNode field : classNode.fields) { - if (field.name.equals("VERSION")) { - return String.valueOf(field.value); - } - } - LOGGER.warn("Failed to determine version of bundled mixin: no VERSION field in MixinBootstrap"); - } - } catch (URISyntaxException | IOException e) { - LOGGER.warn("Failed to determine version of bundled mixin:", e); - } - return null; - } - - public static String getAsmVersion(URL jarUrl) { - try (FileSystem fileSystem = FileSystems.newFileSystem(asJar(jarUrl.toURI()), Collections.emptyMap())) { - // There is no nice way to get the version while we explode the ASM jar directly into our jar. - // So we take an educated guess based on stuff we care about. - Path asmPath = fileSystem.getPath("org", "objectweb", "asm"); - if (Files.exists(asmPath.resolve("commons").resolve("ClassRemapper.class"))) { - return "5.2"; // default with 1.12.2, sufficient for Mixin 0.8 - } else if (Files.exists(asmPath.resolve("Opcodes.class"))) { - return "5.0.3"; // default with 1.8.9, not sufficient for Mixin 0.8 - } else { - return null; - } - } catch (URISyntaxException | IOException e) { - LOGGER.warn("Failed to determine version of bundled asm:", e); - } - return null; - } -} From 43524ee927507e7f6a7a4909a4de03e6d7fa4dfe Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 7 Jul 2025 14:08:36 +0200 Subject: [PATCH 11/37] tests/lw: Drop requirement for Essential to be excluded from LaunchClassLoader This has been fixed in Essential in ea9aa48c5e9058e4525f23200b68e90411b0dda4 (dated 2021). --- .../java/gg/essential/api/tweaker/EssentialTweaker.java | 8 -------- 1 file changed, 8 deletions(-) diff --git a/integrationTest/launchwrapper/src/essential/java/gg/essential/api/tweaker/EssentialTweaker.java b/integrationTest/launchwrapper/src/essential/java/gg/essential/api/tweaker/EssentialTweaker.java index 5c93dc0..4ed0cfd 100644 --- a/integrationTest/launchwrapper/src/essential/java/gg/essential/api/tweaker/EssentialTweaker.java +++ b/integrationTest/launchwrapper/src/essential/java/gg/essential/api/tweaker/EssentialTweaker.java @@ -1,7 +1,6 @@ package gg.essential.api.tweaker; import sun.gg.essential.LoadState; -import net.minecraft.launchwrapper.Launch; import java.io.File; @@ -9,12 +8,5 @@ public class EssentialTweaker { public static void initialize(File gameDir) { LoadState.tweaker = true; - - ClassLoader expectedLoader = Launch.classLoader.getClass().getClassLoader(); - ClassLoader actualLoader = EssentialTweaker.class.getClassLoader(); - if (expectedLoader != actualLoader) { - throw new RuntimeException("Essential should be excluded from the Launch class loader." + - "Expected: " + expectedLoader + ", but was " + actualLoader); - } } } From a1e4e264d2d774f6733033cae85f2444df187ecb Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Wed, 7 May 2025 11:29:18 +0200 Subject: [PATCH 12/37] stage2/lw: Fix outdated comment Ended up deciding against going the mixin route because we had no need for it, so this comment doesn't actually apply. --- .../loader/stage2/relaunch/RelaunchClassLoader.java | 2 +- ...yRelaunchTransformer.java => RelaunchTransformer.java} | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) rename stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/{LegacyRelaunchTransformer.java => RelaunchTransformer.java} (82%) diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/RelaunchClassLoader.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/RelaunchClassLoader.java index d659a84..55de86d 100644 --- a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/RelaunchClassLoader.java +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/RelaunchClassLoader.java @@ -19,7 +19,7 @@ class RelaunchClassLoader extends IsolatedClassLoader { public RelaunchClassLoader(URL[] urls, ClassLoader parent) { super(urls, parent); - this.transformer = new LegacyRelaunchTransformer(); + this.transformer = new RelaunchTransformer(); } @Override diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/LegacyRelaunchTransformer.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/RelaunchTransformer.java similarity index 82% rename from stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/LegacyRelaunchTransformer.java rename to stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/RelaunchTransformer.java index 529820f..4419e6f 100644 --- a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/LegacyRelaunchTransformer.java +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/RelaunchTransformer.java @@ -14,13 +14,7 @@ import static gg.essential.loader.stage2.relaunch.Relaunch.FML_TWEAKER; -/** - * For when we do not have access to Mixin 0.8 (old Essential version on 1.8.9). - * - * If we do have Mixin 0.8, we spin up an isolated instance of that instead and then a mixin takes care of this: - * {@link gg.essential.loader.stage2.relaunch.mixins.forge.Mixin_SkipFMLSecurityManager} - */ -public class LegacyRelaunchTransformer implements BiFunction { +public class RelaunchTransformer implements BiFunction { @Override public byte[] apply(String name, byte[] bytes) { // It installs a SecurityManager which locks itself down by rejecting any future managers and forge From 3554d8f48da911ca5c16cb004ce9a45314b854e4 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Wed, 7 May 2025 11:43:38 +0200 Subject: [PATCH 13/37] stage2/lw: Handle embedded stage1 via transformer instead of modifying the jar --- integrationTest/launchwrapper/build.gradle | 11 +++ .../loader/stage2/RelaunchTests.java | 11 +++ .../loader/stage2/EssentialLoader.java | 98 ------------------- .../stage2/relaunch/RelaunchTransformer.java | 29 ++++++ 4 files changed, 51 insertions(+), 98 deletions(-) diff --git a/integrationTest/launchwrapper/build.gradle b/integrationTest/launchwrapper/build.gradle index 69176c6..52513b3 100644 --- a/integrationTest/launchwrapper/build.gradle +++ b/integrationTest/launchwrapper/build.gradle @@ -271,6 +271,16 @@ tasks.register("essentialWithAsm52Jar", Jar) { from({ configurations.asm52.collect { zipTree(it) } }) } +tasks.register("essentialWithDummyStage1", Jar) { + archiveBaseName.set("essential-with-dummy-stage1") + from(sourceSets.essential.output) + includeMixin(it, configurations.mixin07) + + from(tasks.named("dummyStage1Jar").map { it.outputs }) { + rename { "gg/essential/loader/stage0/stage1.jar" } + } +} + tasks.register("dummyStage1Jar", Jar) { archiveBaseName.set("dummyStage1") from(sourceSets.dummyStage1.output) @@ -443,6 +453,7 @@ tasks.register("setupDownloadsApi", Sync) { mod(tasks.essentialJar.archiveFile, "essential:essential", "stable") mod(tasks.essentialWithMixin08Jar.archiveFile, "essential:essential", "mixin-08") mod(tasks.essentialWithAsm52Jar.archiveFile, "essential:essential", "asm-52") + mod(tasks.essentialWithDummyStage1.archiveFile, "essential:essential", "with-dummy-stage1") mod(provider { configurations.mixin07.singleFile }, "essential:mixin", "07") mod(evaluationDependsOn(':stage0:launchwrapper').tasks.jar.archiveFile, "essential:loader-stage0", "stable") mod(tasks.dummyStage1Jar.archiveFile, "essential:loader-stage1", "dummy") diff --git a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchTests.java b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchTests.java index c4317a7..3e874ff 100644 --- a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchTests.java +++ b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchTests.java @@ -256,4 +256,15 @@ private Map> parseArguments(String[] args) { } return result; } + + @Test + public void testRelaunchWithNewerStage1Available(Installation installation) throws Exception { + installation.addExampleMod(); + + IsolatedLaunch isolatedLaunch = installation.newLaunchFML(); + isolatedLaunch.setProperty("essential.branch", "with-dummy-stage1"); + isolatedLaunch.launch(); + + assertTrue(isolatedLaunch.isEssentialLoaded(), "Essential loaded"); + } } diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java index 26ef823..3eb61e1 100644 --- a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java @@ -1,39 +1,22 @@ package gg.essential.loader.stage2; -import gg.essential.loader.stage2.data.ModJarMetadata; import gg.essential.loader.stage2.relaunch.Relaunch; -import gg.essential.loader.stage2.util.Delete; import net.minecraft.launchwrapper.ITweaker; import net.minecraft.launchwrapper.Launch; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; import java.lang.reflect.Method; import java.net.MalformedURLException; import java.net.URL; import java.net.URLClassLoader; -import java.nio.file.FileSystem; -import java.nio.file.FileSystems; -import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; -import java.nio.file.StandardOpenOption; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; -import java.util.jar.Attributes; -import java.util.jar.Manifest; - -import static gg.essential.loader.stage2.Utils.findMostRecentFile; -import static gg.essential.loader.stage2.Utils.findNextMostRecentFile; public class EssentialLoader extends EssentialLoaderBase { - public static final Logger LOGGER = LogManager.getLogger(EssentialLoader.class); private static final String MIXIN_TWEAKER = "org.spongepowered.asm.launch.MixinTweaker"; private static final String STAGE1_TWEAKER = "gg.essential.loader.stage1.EssentialSetupTweaker"; private static final String STAGE0_TWEAKERS_KEY = "essential.loader.stage2.stage0tweakers"; @@ -47,39 +30,6 @@ public EssentialLoader(Path gameDir, String gameVersion) { super(gameDir, gameVersion); } - private void deleteEmbeddedStage0(Path downloadedFile) throws IOException { - // We need to strip the stage1 loader bundled in mods (to allow them to be dropped directly in the mods - // folder) because it might be more recent than the version currently on the classpath and as such may prompt - // an update of stage1 inside a relaunch (failing hard on Windows because the stage1 jar is currently loaded). - // FIXME do we really have to do this next part? would be much nicer if we could treat Essential just like any - // other third-party mod here; and we can't just strip the Tweaker for third-party mods because those may - // actually need it. - // We also need to strip the corresponding manifest entry because otherwise stage1 might try to load us as a - // regular Essential-using mod, which won't actually work (Essential will function but it won't appear as a - // mod in the Mods menu, etc.). - try (FileSystem fileSystem = FileSystems.newFileSystem(downloadedFile, (ClassLoader) null)) { - Path stage0Path = fileSystem.getPath("gg", "essential", "loader", "stage0"); - if (Files.exists(stage0Path)) { - Delete.recursively(stage0Path); - } - - Path manifestPath = fileSystem.getPath("META-INF", "MANIFEST.MF"); - if (Files.exists(manifestPath)) { - Manifest manifest = new Manifest(); - try (InputStream in = Files.newInputStream(manifestPath)) { - manifest.read(in); - } - manifest.getMainAttributes().remove(new Attributes.Name("TweakClass")); - // Specify OpenOptions here to bypass a bug in older openjdk versions (like the one the vanilla launcher uses - // by default... *grumbles*). - // See: https://github.com/openjdk/jdk8u/commit/bc2f17678c9607becb67f453c8b692c96d0e8bba#diff-2635ee58b104a22280e52e4140e2086f1a145bd9766c02a329a4ed25b01a972e - try (OutputStream out = Files.newOutputStream(manifestPath, StandardOpenOption.TRUNCATE_EXISTING)) { - manifest.write(out); - } - } - } - } - @Override protected void loadPlatform() { if (ourEssentialPath == null || ourEssentialUrl == null || ourMixinUrl == null) { @@ -126,54 +76,6 @@ protected ClassLoader getModClassLoader() { return Launch.classLoader.getClass().getClassLoader(); } - @Override - protected void addToClasspath(Mod mod, ModJarMetadata jarMeta, Path mainJar, List innerJars) { - if (mod.isEssential()) { - // If we were to load the downloaded Essential jar directly, we will run into issues if the game goes on to - // relaunch. See [deleteEmbeddedStage0] for details. - // To prevent that, we'll create a copy of the downloaded jar, delete the embedded stage0 from that, and - // then add that jar to the classpath instead. - // We don't just modify the original directly because that would mess up its checksum. - try { - String fileBaseName = mod.fileBaseName + ".processed"; - Path processedMainJar = findMostRecentFile(mod.dataDir, fileBaseName, FILE_EXTENSION).getKey(); - - ModJarMetadata processedMeta = ModJarMetadata.EMPTY; - if (Files.exists(processedMainJar)) { - try { - processedMeta = ModJarMetadata.readFromJarFile(processedMainJar); - } catch (IOException e) { - LOGGER.warn("Failed to read existing processed jar metadata", e); - } - } - - if (!processedMeta.equals(jarMeta)) { - Path tmpFile = Files.createTempFile(processedMainJar.getParent(), "processing", ".jar"); - Files.copy(mainJar, tmpFile, StandardCopyOption.REPLACE_EXISTING); - deleteEmbeddedStage0(tmpFile); - jarMeta.writeToJarFile(tmpFile); - - try { - Files.deleteIfExists(processedMainJar); - } catch (IOException e) { - LOGGER.warn("Failed to delete old processed file, will try again later.", e); - } - - // If we succeeded in deleting that file, we might now be able to write to a lower-numbered one - // and if not, we need to write to the next higher one. - processedMainJar = findNextMostRecentFile(mod.dataDir, fileBaseName, FILE_EXTENSION); - - Files.move(tmpFile, processedMainJar); - } - - mainJar = processedMainJar; - } catch (IOException e) { - LOGGER.warn("Failed to post-process downloaded Essential jar:", e); - } - } - super.addToClasspath(mod, jarMeta, mainJar, innerJars); - } - @Override protected void addToClasspath(Path path) { URL url; diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/RelaunchTransformer.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/RelaunchTransformer.java index 4419e6f..f0c380c 100644 --- a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/RelaunchTransformer.java +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/RelaunchTransformer.java @@ -9,6 +9,8 @@ import org.objectweb.asm.tree.InsnNode; import org.objectweb.asm.tree.MethodInsnNode; import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.TypeInsnNode; +import org.objectweb.asm.tree.VarInsnNode; import java.util.function.BiFunction; @@ -37,6 +39,33 @@ public byte[] apply(String name, byte[] bytes) { return classWriter.toByteArray(); } + // Suppress the stage1 update mechanism in any stage0 classes because it'll fail on Windows where the file + // is already locked by the JVM. + if (name.endsWith(".EssentialSetupTweaker")) { + ClassNode classNode = new ClassNode(Opcodes.ASM5); + new ClassReader(bytes).accept(classNode, 0); + for (MethodNode method : classNode.methods) { + if ("loadStage1".equals(method.name)) { + // Older stage0 versions have a static method, in newer ones it's a member method. + boolean isStatic = (method.access & Opcodes.ACC_STATIC) != 0; + InsnList instructions = method.instructions; + instructions.clear(); + instructions.add(new TypeInsnNode(Opcodes.NEW, "gg/essential/loader/stage1/EssentialSetupTweaker")); + instructions.add(new InsnNode(Opcodes.DUP)); + instructions.add(new VarInsnNode(Opcodes.ALOAD, isStatic ? 0 : 1)); + instructions.add(new MethodInsnNode(Opcodes.INVOKESPECIAL, "gg/essential/loader/stage1/EssentialSetupTweaker", "", "(Lnet/minecraft/launchwrapper/ITweaker;)V", false)); + instructions.add(new InsnNode(Opcodes.ARETURN)); + method.maxLocals = isStatic ? 1 : 2; + method.maxStack = 3; + method.localVariables.clear(); + method.tryCatchBlocks.clear(); + } + } + ClassWriter classWriter = new ClassWriter(Opcodes.ASM5); + classNode.accept(classWriter); + return classWriter.toByteArray(); + } + return bytes; } } From 8c6a6eb892ab215d5db225b5da72ba967dbd4fb6 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 13 May 2025 07:32:37 +0200 Subject: [PATCH 14/37] stage2/lw: Move stage0 tracking to separate class --- .../loader/stage2/EssentialLoader.java | 22 ++------------- .../loader/stage2/util/Stage0Tracker.java | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 20 deletions(-) create mode 100644 stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/util/Stage0Tracker.java diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java index 3eb61e1..0d85254 100644 --- a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java @@ -1,6 +1,7 @@ package gg.essential.loader.stage2; import gg.essential.loader.stage2.relaunch.Relaunch; +import gg.essential.loader.stage2.util.Stage0Tracker; import net.minecraft.launchwrapper.ITweaker; import net.minecraft.launchwrapper.Launch; @@ -11,16 +12,10 @@ import java.net.URLClassLoader; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.Collections; -import java.util.HashSet; import java.util.List; -import java.util.Set; public class EssentialLoader extends EssentialLoaderBase { private static final String MIXIN_TWEAKER = "org.spongepowered.asm.launch.MixinTweaker"; - private static final String STAGE1_TWEAKER = "gg.essential.loader.stage1.EssentialSetupTweaker"; - private static final String STAGE0_TWEAKERS_KEY = "essential.loader.stage2.stage0tweakers"; - private static final Set STAGE0_TWEAKERS = new HashSet<>(); private Path ourEssentialPath; private URL ourEssentialUrl; @@ -132,21 +127,8 @@ private static void injectMixinTweaker() throws ClassNotFoundException, IllegalA @Override protected void doInitialize() { - detectStage0Tweaker(); + Stage0Tracker.registerStage0Tweaker(); super.doInitialize(); } - - private void detectStage0Tweaker() { - Launch.blackboard.computeIfAbsent(STAGE0_TWEAKERS_KEY, k -> Collections.unmodifiableSet(STAGE0_TWEAKERS)); - - StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); - for (int i = 0; i < stackTrace.length - 1; i++) { - StackTraceElement element = stackTrace[i]; - if (element.getClassName().equals(STAGE1_TWEAKER) && element.getMethodName().equals("injectIntoClassLoader")) { - STAGE0_TWEAKERS.add(stackTrace[i + 1].getClassName()); - break; - } - } - } } diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/util/Stage0Tracker.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/util/Stage0Tracker.java new file mode 100644 index 0000000..ad9ae81 --- /dev/null +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/util/Stage0Tracker.java @@ -0,0 +1,27 @@ +package gg.essential.loader.stage2.util; + +import net.minecraft.launchwrapper.Launch; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +public class Stage0Tracker { + + private static final String STAGE1_TWEAKER = "gg.essential.loader.stage1.EssentialSetupTweaker"; + private static final String STAGE0_TWEAKERS_KEY = "essential.loader.stage2.stage0tweakers"; + private static final Set STAGE0_TWEAKERS = new HashSet<>(); + + public static void registerStage0Tweaker() { + Launch.blackboard.computeIfAbsent(STAGE0_TWEAKERS_KEY, k -> Collections.unmodifiableSet(STAGE0_TWEAKERS)); + + StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); + for (int i = 0; i < stackTrace.length - 1; i++) { + StackTraceElement element = stackTrace[i]; + if (element.getClassName().equals(STAGE1_TWEAKER) && element.getMethodName().equals("injectIntoClassLoader")) { + STAGE0_TWEAKERS.add(stackTrace[i + 1].getClassName()); + break; + } + } + } +} From 457562233505430a5a57332e556f09688ecbc15f Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 13 May 2025 09:26:59 +0200 Subject: [PATCH 15/37] stage2/lw: Move mixin tweaker injecting to separate class --- .../loader/stage2/EssentialLoader.java | 41 +--------------- .../stage2/util/MixinTweakerInjector.java | 48 +++++++++++++++++++ 2 files changed, 50 insertions(+), 39 deletions(-) create mode 100644 stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/util/MixinTweakerInjector.java diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java index 0d85254..cba0c92 100644 --- a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java @@ -1,21 +1,18 @@ package gg.essential.loader.stage2; import gg.essential.loader.stage2.relaunch.Relaunch; +import gg.essential.loader.stage2.util.MixinTweakerInjector; import gg.essential.loader.stage2.util.Stage0Tracker; -import net.minecraft.launchwrapper.ITweaker; import net.minecraft.launchwrapper.Launch; -import java.io.IOException; import java.lang.reflect.Method; import java.net.MalformedURLException; import java.net.URL; import java.net.URLClassLoader; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.List; public class EssentialLoader extends EssentialLoaderBase { - private static final String MIXIN_TWEAKER = "org.spongepowered.asm.launch.MixinTweaker"; private Path ourEssentialPath; private URL ourEssentialUrl; @@ -52,11 +49,7 @@ protected void loadPlatform() { Relaunch.relaunch(ourMixinUrl); } - try { - injectMixinTweaker(); - } catch (ClassNotFoundException | IllegalAccessException | InstantiationException | IOException e) { - throw new RuntimeException(e); - } + MixinTweakerInjector.injectMixinTweaker(); } @Override @@ -95,36 +88,6 @@ protected void addToClasspath(Path path) { ourMixinUrl = url; } - // Production requires usage of the MixinTweaker. Simply calling MixinBootstrap.init() will not always work, even - // if it appears to work most of the time. - // This code is a intentional duplicate of the one in stage1. The one over there is in case the third-party mod - // relies on Mixin and runs even when stage2 cannot be loaded, this one is for Essential and we do not want to mix - // the two (e.g. we might change how this one works in the future but we cannot easily change the one in stage1). - private static void injectMixinTweaker() throws ClassNotFoundException, IllegalAccessException, InstantiationException, IOException { - @SuppressWarnings("unchecked") - List tweakClasses = (List) Launch.blackboard.get("TweakClasses"); - - // If the MixinTweaker is already queued (because of another mod), then there's nothing we need to to - if (tweakClasses.contains(MIXIN_TWEAKER)) { - return; - } - - // If it is already booted, we're also good to go - if (Launch.blackboard.get("mixin.initialised") != null) { - return; - } - - System.out.println("Injecting MixinTweaker from EssentialLoader"); - - // Otherwise, we need to take things into our own hands because the normal way to chainload a tweaker - // (by adding it to the TweakClasses list during injectIntoClassLoader) is too late for Mixin. - // Instead we instantiate the MixinTweaker on our own and add it to the current Tweaks list immediately. - Launch.classLoader.addClassLoaderExclusion(MIXIN_TWEAKER.substring(0, MIXIN_TWEAKER.lastIndexOf('.'))); - @SuppressWarnings("unchecked") - List tweaks = (List) Launch.blackboard.get("Tweaks"); - tweaks.add((ITweaker) Class.forName(MIXIN_TWEAKER, true, Launch.classLoader).newInstance()); - } - @Override protected void doInitialize() { Stage0Tracker.registerStage0Tweaker(); diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/util/MixinTweakerInjector.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/util/MixinTweakerInjector.java new file mode 100644 index 0000000..9032a3d --- /dev/null +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/util/MixinTweakerInjector.java @@ -0,0 +1,48 @@ +package gg.essential.loader.stage2.util; + +import net.minecraft.launchwrapper.ITweaker; +import net.minecraft.launchwrapper.Launch; + +import java.util.List; + +// Production requires usage of the MixinTweaker. Simply calling MixinBootstrap.init() will not always work, even +// if it appears to work most of the time. +// This code is a intentional duplicate of the one in stage1. The one over there is in case the third-party mod +// relies on Mixin and runs even when stage2 cannot be loaded, this one is for Essential and we do not want to mix +// the two (e.g. we might change how this one works in the future but we cannot easily change the one in stage1). +public class MixinTweakerInjector { + private static final String MIXIN_TWEAKER = "org.spongepowered.asm.launch.MixinTweaker"; + + public static void injectMixinTweaker() { + try { + doInjectMixinTweaker(); + } catch (ClassNotFoundException | IllegalAccessException | InstantiationException e) { + throw new RuntimeException(e); + } + } + + private static void doInjectMixinTweaker() throws ClassNotFoundException, IllegalAccessException, InstantiationException { + @SuppressWarnings("unchecked") + List tweakClasses = (List) Launch.blackboard.get("TweakClasses"); + + // If the MixinTweaker is already queued (because of another mod), then there's nothing we need to to + if (tweakClasses.contains(MIXIN_TWEAKER)) { + return; + } + + // If it is already booted, we're also good to go + if (Launch.blackboard.get("mixin.initialised") != null) { + return; + } + + System.out.println("Injecting MixinTweaker from EssentialLoader"); + + // Otherwise, we need to take things into our own hands because the normal way to chainload a tweaker + // (by adding it to the TweakClasses list during injectIntoClassLoader) is too late for Mixin. + // Instead we instantiate the MixinTweaker on our own and add it to the current Tweaks list immediately. + Launch.classLoader.addClassLoaderExclusion(MIXIN_TWEAKER.substring(0, MIXIN_TWEAKER.lastIndexOf('.'))); + @SuppressWarnings("unchecked") + List tweaks = (List) Launch.blackboard.get("Tweaks"); + tweaks.add((ITweaker) Class.forName(MIXIN_TWEAKER, true, Launch.classLoader).newInstance()); + } +} From 09e3b77ad0f8044226b2ee798e83408166c09abb Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 7 Jul 2025 16:51:10 +0200 Subject: [PATCH 16/37] stage2/lw: Copy additional code for MixinTweakerInjector from stage1 The call will be added in a later commit in which all the stage1 mod setup code is moved to stage2. --- .../loader/stage2/EssentialLoader.java | 2 +- .../stage2/util/MixinTweakerInjector.java | 21 ++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java index cba0c92..a2a3fef 100644 --- a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java @@ -49,7 +49,7 @@ protected void loadPlatform() { Relaunch.relaunch(ourMixinUrl); } - MixinTweakerInjector.injectMixinTweaker(); + MixinTweakerInjector.injectMixinTweaker(true); } @Override diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/util/MixinTweakerInjector.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/util/MixinTweakerInjector.java index 9032a3d..c5a53fe 100644 --- a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/util/MixinTweakerInjector.java +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/util/MixinTweakerInjector.java @@ -13,20 +13,27 @@ public class MixinTweakerInjector { private static final String MIXIN_TWEAKER = "org.spongepowered.asm.launch.MixinTweaker"; - public static void injectMixinTweaker() { + public static void injectMixinTweaker(boolean canWait) { try { - doInjectMixinTweaker(); + doInjectMixinTweaker(canWait); } catch (ClassNotFoundException | IllegalAccessException | InstantiationException e) { throw new RuntimeException(e); } } - private static void doInjectMixinTweaker() throws ClassNotFoundException, IllegalAccessException, InstantiationException { + private static void doInjectMixinTweaker(boolean canWait) throws ClassNotFoundException, IllegalAccessException, InstantiationException { @SuppressWarnings("unchecked") List tweakClasses = (List) Launch.blackboard.get("TweakClasses"); // If the MixinTweaker is already queued (because of another mod), then there's nothing we need to to if (tweakClasses.contains(MIXIN_TWEAKER)) { + if (!canWait) { + // Except we do need to initialize the MixinTweaker immediately so we can add containers + // for our mods. + // This is idempotent, so we can call it without adding to the tweaks list (and we must not add to + // it because the queued tweaker will already get added and there is nothing we can do about that). + newMixinTweaker(); + } return; } @@ -40,9 +47,13 @@ private static void doInjectMixinTweaker() throws ClassNotFoundException, Illega // Otherwise, we need to take things into our own hands because the normal way to chainload a tweaker // (by adding it to the TweakClasses list during injectIntoClassLoader) is too late for Mixin. // Instead we instantiate the MixinTweaker on our own and add it to the current Tweaks list immediately. - Launch.classLoader.addClassLoaderExclusion(MIXIN_TWEAKER.substring(0, MIXIN_TWEAKER.lastIndexOf('.'))); @SuppressWarnings("unchecked") List tweaks = (List) Launch.blackboard.get("Tweaks"); - tweaks.add((ITweaker) Class.forName(MIXIN_TWEAKER, true, Launch.classLoader).newInstance()); + tweaks.add(newMixinTweaker()); + } + + private static ITweaker newMixinTweaker() throws ClassNotFoundException, InstantiationException, IllegalAccessException { + Launch.classLoader.addClassLoaderExclusion(MIXIN_TWEAKER.substring(0, MIXIN_TWEAKER.lastIndexOf('.'))); + return (ITweaker) Class.forName(MIXIN_TWEAKER, true, Launch.classLoader).newInstance(); } } From 84df2795f2a76449df4e073def316ef7136309d7 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 8 Jul 2025 06:19:32 +0200 Subject: [PATCH 17/37] stage2/lw: Fix mixin appender cleanup with different class loader We'll want to load stage2 in a dedicated ClassLoader soon, but `getLogger` will return a different Logger instance depending on the class loader of the calling class, so we need to explicitly supply the correct class loader. --- .../gg/essential/loader/stage2/relaunch/Relaunch.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/Relaunch.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/Relaunch.java index f2faffe..b226807 100644 --- a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/Relaunch.java +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/Relaunch.java @@ -5,6 +5,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.core.Appender; +import org.apache.logging.log4j.spi.LoggerContext; import java.io.File; import java.lang.reflect.Field; @@ -151,8 +152,12 @@ private static void cleanupForRelaunch() { // into the INIT phase, skipping all mixins registered for that phase. // See MixinPlatformAgentFMLLegacy.MixinAppender // To fix that, we remove the outer mixin's appender before relaunching. - private static void cleanupMixinAppender() { - org.apache.logging.log4j.Logger fmlLogger = LogManager.getLogger("FML"); + private static void cleanupMixinAppender() throws ReflectiveOperationException { + // Note: Need to get a logger context by class loader here, otherwise this won't work if the Relaunch class is + // loaded in a different ClassLoader (even if it's a child) than the above mixin class + LoggerContext context = LogManager.getContext(Launch.class.getClassLoader(), false); + // Using reflection to call this method because its exact return type differs between beta9 and release log4j2 + Logger fmlLogger = (Logger) LoggerContext.class.getMethod("getLogger", String.class).invoke(context, "FML"); if (fmlLogger instanceof org.apache.logging.log4j.core.Logger) { org.apache.logging.log4j.core.Logger fmlLoggerImpl = (org.apache.logging.log4j.core.Logger) fmlLogger; Appender mixinAppender = fmlLoggerImpl.getAppenders().get("MixinLogWatcherAppender"); From e03ab9eac3b48009cd3bd33a60a36f406a248864 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Fri, 4 Jul 2025 11:18:45 +0200 Subject: [PATCH 18/37] stage2/lw: Abstract away from always installing Essential (Note that all changes talked about in this commit only apply to EssentialLoader for LaunchWrapper. Not to EssentialLoader for ModLauncher or Fabric, those continue operating as they used to.) This commit rewrites most of Essential Loader for LaunchWrapper to no longer always implicitly install Essential and instead provide a more general dependency loading/negotiating mechanism (similar to fabric-loader's Jar-in-Jar mechanism) useable by third-party mods via a `essential.mod.json` metadata file. To this end, this commit practically removes stage1 on LaunchWrapper because stage1 necessarily interacts with Essential infra to download/update stage2 and with the Essential mod to ask the user for permission to do so. Instead stage2 now has a self-update mechanism where if it finds a more recent stage2 in any of the discovered jars (e.g. the downloaded Essential jar), it will re-launch into that newer stage2. Additionally, this also removes the concept of a pinned stage2 and instead always includes the stage2 jar inside the stage1 jar. This also allows using the existing stage1 update mechansim (dropping a file in a specific location) to permanently update stage1 and therefore stage2 to a new version on next boot (e.g. if we want to update the Downloading gui). To support Essential's auto-updating feature (as well as similar third-party ones), in addition to plain inner jars, the `essenital.mod.json` metadata file also supports loading a specific class from the jar to generate additional dependency specifications. This class can then e.g. download a newer version of the mod and emit a dependency specification which points at it. --- .github/workflows/publish-stage2.yml | 2 +- docs/container-mods.md | 2 + docs/stages.md | 16 +- integrationTest/launchwrapper/build.gradle | 50 +- .../loader/stage1/Stage1DevEnvTests.java | 1 + .../loader/stage2/RelaunchTests.java | 5 - .../Stage2MixinTests.java} | 4 +- .../essential/loader/stage2/Stage2Tests.java | 16 +- settings.gradle.kts | 1 + stage0/launchwrapper/build.gradle | 7 + stage1/build.gradle | 4 +- stage1/launchwrapper/build.gradle | 6 + .../loader/stage1/DelayedStage0Tweaker.java | 3 + .../loader/stage1/EssentialLoader.java | 42 -- .../loader/stage1/EssentialSetupTweaker.java | 245 +-------- .../loader/stage2/EssentialLoaderBase.java | 2 +- stage2/launchwrapper-legacy/build.gradle | 13 + .../loader/stage2/EssentialLoader.java | 42 ++ stage2/launchwrapper/build.gradle | 15 + .../loader/stage1/DelayedStage0Tweaker.java | 116 ++++ .../loader/stage1/EssentialSetupTweaker.java | 53 ++ .../loader/stage2/EssentialLoader.java | 100 +--- .../loader/stage2/EssentialModUpdater.java | 81 +++ .../loader/stage2/EssentialSetupTweaker.java | 47 ++ .../gg/essential/loader/stage2/Loader.java | 519 ++++++++++++++++++ .../essential/loader/stage2/RelaunchInfo.java | 21 + .../loader/stage2/RelaunchedLoader.java | 178 ++++++ .../loader/stage2/relaunch/Relaunch.java | 61 +- 28 files changed, 1195 insertions(+), 457 deletions(-) rename integrationTest/launchwrapper/src/main/java/gg/essential/loader/{stage1/Stage1MixinTests.java => stage2/Stage2MixinTests.java} (98%) delete mode 100644 stage1/launchwrapper/src/main/java/gg/essential/loader/stage1/EssentialLoader.java create mode 100644 stage2/launchwrapper-legacy/build.gradle create mode 100644 stage2/launchwrapper-legacy/src/main/java/gg/essential/loader/stage2/EssentialLoader.java create mode 100644 stage2/launchwrapper/src/main/java/gg/essential/loader/stage1/DelayedStage0Tweaker.java create mode 100644 stage2/launchwrapper/src/main/java/gg/essential/loader/stage1/EssentialSetupTweaker.java create mode 100644 stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialModUpdater.java create mode 100644 stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialSetupTweaker.java create mode 100644 stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/Loader.java create mode 100644 stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchInfo.java create mode 100644 stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchedLoader.java diff --git a/.github/workflows/publish-stage2.yml b/.github/workflows/publish-stage2.yml index 20795b3..2ae5c87 100644 --- a/.github/workflows/publish-stage2.yml +++ b/.github/workflows/publish-stage2.yml @@ -20,7 +20,7 @@ jobs: 17 - name: Build - run: ./gradlew :stage2:{launchwrapper,fabric,modlauncher{8,9}}:build --stacktrace + run: ./gradlew :stage2:{launchwrapper-legacy,fabric,modlauncher{8,9}}:build --stacktrace - name: Upload Artifacts uses: actions/upload-artifact@v4 diff --git a/docs/container-mods.md b/docs/container-mods.md index 12aef19..8e2edc7 100644 --- a/docs/container-mods.md +++ b/docs/container-mods.md @@ -109,6 +109,8 @@ overridePinnedVersion=1.2.0.12 ## Pinning stage2 Stage2 can be pinned as well. +(But only on Fabric and ModLauncher. On LaunchWrapper the stage2 jar is already included in the stage1 jar and cannot +be updated independently.) Instead of an `essential-loader.properties` file at the root of the container mod, the file must be placed at `gg/essential/loader/stage1/stage2.properties`. diff --git a/docs/stages.md b/docs/stages.md index 4e283e2..1f157eb 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -74,18 +74,10 @@ download fails (assuming the host mod has no hard dependency on Essential). ### LaunchWrapper -On LaunchWrapper, it additionally: -- instructs Forge to scan the host mod jar for regular mods (ordinarily Forge would not check for mods in a jar that - already contained a Tweaker). -- if the jar declares a CoreMod, loads it (Forge will not load CoreMods from jars that already contained a Tweaker) -- chain load the Mixin Tweaker if the host mod declares a `MixinConfigs` attribute and the Mixin Tweaker is not yet - loaded or scheduled (you can only have one Tweaker declared, you can't have Mixin and Essential Tweaker, so we load - the Mixin one for you if you need it) -- load the host mod's declared mixin configs (Mixin only automatically loads these for mods declaring the Mixin Tweaker) - -Stage1 on LaunchWrapper currently does not need to ensure that only a single instance of stage2 is loaded because stage0 -actually puts it in the Launch class loader rather than its own isolated class loader for legacy reasons. -So all stage0 instances are talking to the same stage1 class and a simple static field is enough to ensure uniqueness. +On LaunchWrapper, it is even simpler: The stage2 jar file is embedded in the stage1 jar file, so stage1 simply extracts +that (no update checks, downloads, or anything), and then jumps to it like stage0 did. + +Stage1 used to do a lot more on LaunchWrapper, but all this functionality has since been moved to stage2. ### Fabric diff --git a/integrationTest/launchwrapper/build.gradle b/integrationTest/launchwrapper/build.gradle index 52513b3..c9c42e6 100644 --- a/integrationTest/launchwrapper/build.gradle +++ b/integrationTest/launchwrapper/build.gradle @@ -133,11 +133,15 @@ def includeMixin(AbstractArchiveTask task, Configuration mixin) { } } -def configureExampleModJar = { String tweaker, Configuration mixin = null -> return { AbstractArchiveTask task -> +def configureExampleModJar = { String tweaker, Configuration mixin = null, excludes = null -> return { AbstractArchiveTask task -> archiveBaseName.set(task.name) from(sourceSets.exampleMod.output) dependsOn(configurations.exampleModRuntimeClasspath) - from({ configurations.exampleModRuntimeClasspath.collect { zipTree(it) } }) + from({ configurations.exampleModRuntimeClasspath.collect { zipTree(it) } }) { + if (excludes != null) { + excludes() + } + } manifest { attributes "FMLCorePlugin": "com.example.mod.ExampleCoreMod", @@ -203,39 +207,49 @@ tasks.register("exampleRelocatedModJar", com.github.jengelman.gradle.plugins.sha def stage2Task = tasks.register("stage2V${i}Jar", Jar) { archiveBaseName.set(name) from(evaluationDependsOn(':stage2:launchwrapper').tasks.jar.archiveFile.map { zipTree(it) }) - // Dummy attribute so they all have different hashes manifest { + attributes "Name": "gg/essential/loader/stage2/" attributes "Implementation-Version": "$i" } } + def stage1Task = tasks.register("stage1V${i}Jar", Jar) { + archiveBaseName.set(name) + from(evaluationDependsOn(':stage1:launchwrapper').tasks.jar.archiveFile.map { zipTree(it) }) { + exclude("gg/essential/loader/stage1/stage2.jar") + } + from(stage2Task.flatMap { it.archiveFile }) { + into("gg/essential/loader/stage1") + rename { "stage2.jar" } + } + manifest { + attributes("Name": "gg/essential/loader/stage1/") + attributes "Implementation-Version": "${i}" + } + } def stage3Task = tasks.register("stage3V${i}Jar", Jar) { archiveBaseName.set(name) from(tasks.essentialJar.archiveFile.map { zipTree(it) }) - manifest { - // Dummy attribute so they all have different hashes - attributes "Implementation-Version": "$i" - // For stage3 version 4+, we add an explicit requirement on stage2 version 4 - if (i >= 4) { - attributes "Requires-Essential-Stage2-Version": "4" - } + from(stage1Task.flatMap { it.archiveFile }) { + into("gg/essential/loader/stage0") + rename { "stage1.jar" } } } tasks.register("exampleBundledModJar$i", Jar) { - def configure = configureExampleModJar("com.example.mod.tweaker.ExampleModTweaker") + def configure = configureExampleModJar("com.example.mod.tweaker.ExampleModTweaker", null, { + exclude("gg/essential/loader/stage0/stage1.jar") + }) configure.delegate = delegate configure(it) - def stage2Jar = stage2Task.get().archiveFile - def stage3Jar = stage3Task.get().archiveFile - from(stage2Jar) { - rename { "bundled-stage2-${i}.jar" } + from(stage1Task.flatMap { it.archiveFile }) { + into("gg/essential/loader/stage0") + rename { "stage1.jar" } } + + def stage3Jar = stage3Task.get().archiveFile from(stage3Jar) { rename { "bundled-essential-${i}.jar" } } - from(file("../essential-loader-stage2.properties")) { - expand(["pinnedFileMd5": { stage2Jar.get().asFile.bytes.md5() }, version: i]) - } from(file("../essential-loader.properties")) { expand(["pinnedFileMd5": { stage3Jar.get().asFile.bytes.md5() }, "version": i]) } diff --git a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1DevEnvTests.java b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1DevEnvTests.java index 65f30d8..5668cb7 100644 --- a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1DevEnvTests.java +++ b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1DevEnvTests.java @@ -16,6 +16,7 @@ private IsolatedLaunch newDevLaunch(Installation installation) throws Exception isolatedLaunch.addToClasspath(Paths.get("build", "classes", "java", "exampleMod").toUri().toURL()); isolatedLaunch.addArg("--tweakClass", "gg.essential.loader.stage0.EssentialSetupTweaker"); isolatedLaunch.setProperty("fml.coreMods.load", "com.example.mod.ExampleCoreMod"); + isolatedLaunch.setProperty("essential.loader.installEssentialMod", "true"); return isolatedLaunch; } diff --git a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchTests.java b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchTests.java index 3e874ff..c847b2b 100644 --- a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchTests.java +++ b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchTests.java @@ -53,7 +53,6 @@ public void testRelaunchOfInitPhaseMixin(Installation installation, String outer assertTrue(isolatedLaunch.getModLoadState("mixin"), "Example mixin plugin ran"); assertTrue(isolatedLaunch.getModLoadState("coreMod"), "Example CoreMod ran"); assertTrue(isolatedLaunch.getModLoadState("mod"), "Example Mod ran"); - assertTrue(isolatedLaunch.isEssentialLoaded(), "Essential loaded"); } private IsolatedLaunch newDevLaunch(Installation installation, String...javaArgs) throws IOException { @@ -113,7 +112,6 @@ public void testRelaunchArgs_UnknownMain(Installation installation) throws Excep IsolatedLaunch isolatedLaunch = newDevLaunch(installation, "some.unknown.launcher.Main"); isolatedLaunch.launch(); - assertTrue(isolatedLaunch.isEssentialLoaded(), "Essential loaded"); assertTrue(isolatedLaunch.getModLoadState("coreMod"), "Example CoreMod ran"); assertTrue(isolatedLaunch.getModLoadState("mod"), "Example Mod ran"); assertTrue(isolatedLaunch.getModLoadState("relaunched"), "Re-launched"); @@ -132,7 +130,6 @@ public void testRelaunchArgs_LaunchWrapper(Installation installation) throws Exc isolatedLaunch.launch(); installation.assertModLaunched(isolatedLaunch); - assertTrue(isolatedLaunch.isEssentialLoaded(), "Essential loaded"); assertTrue(isolatedLaunch.getModLoadState("relaunched"), "Re-launched"); // For LaunchWrapper we should be able to fully recover everything @@ -149,7 +146,6 @@ public void testRelaunchArgs_GradleStart(Installation installation) throws Excep isolatedLaunch.launch(); installation.assertModLaunched(isolatedLaunch); - assertTrue(isolatedLaunch.isEssentialLoaded(), "Essential loaded"); assertTrue(isolatedLaunch.getModLoadState("relaunched"), "Re-launched"); // For GradleStart we should be able to effectively recover everything but not cleanly @@ -175,7 +171,6 @@ public void testRelaunchArgs_DevLaunchInjector(Installation installation) throws isolatedLaunch.launch(); installation.assertModLaunched(isolatedLaunch); - assertTrue(isolatedLaunch.isEssentialLoaded(), "Essential loaded"); assertTrue(isolatedLaunch.getModLoadState("relaunched"), "Re-launched"); // For DLI we should be able to fully recover everything diff --git a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1MixinTests.java b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2MixinTests.java similarity index 98% rename from integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1MixinTests.java rename to integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2MixinTests.java index 093d7b4..1cbe6c0 100644 --- a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage1/Stage1MixinTests.java +++ b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2MixinTests.java @@ -1,4 +1,4 @@ -package gg.essential.loader.stage1; +package gg.essential.loader.stage2; import gg.essential.loader.fixtures.Installation; import gg.essential.loader.fixtures.IsolatedLaunch; @@ -8,7 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -public class Stage1MixinTests { +public class Stage2MixinTests { @Test public void testMultipleCustomTweakerModsWithMixin07(Installation installation) throws Exception { testMultipleCustomTweakerModsWithMixin(installation, "07"); diff --git a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2Tests.java b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2Tests.java index 6338b06..f65624c 100644 --- a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2Tests.java +++ b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2Tests.java @@ -14,8 +14,6 @@ import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; import static org.apache.commons.codec.digest.DigestUtils.md5Hex; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; public class Stage2Tests { @@ -118,17 +116,7 @@ public void testUpdateRequiringNewerStage2(Installation installation) throws Exc Files.copy(withBranch(installation.stage3Meta, "4"), installation.stage3Meta, REPLACE_EXISTING); writeProps(installation.stage2ConfigFile, props("pendingUpdateVersion=4", "pendingUpdateResolution=true")); IsolatedLaunch secondLaunch = installation.launchFML(); - assertEquals("2", secondLaunch.getProperty("essential.stage2.version")); - assertNull(secondLaunch.getProperty("essential.version")); - assertThrows(ClassNotFoundException.class, () -> secondLaunch.getClass("sun.gg.essential.LoadState")); - - // Make available a newer stage2 version - // We make available version 5 even though stage3 only requires version 4; we expect it to upgrade straight to 5 - Files.copy(withBranch(installation.stage2Meta, "5"), installation.stage2Meta, REPLACE_EXISTING); - - // Restart to complete upgrade - IsolatedLaunch thirdLaunch = installation.launchFML(); - assertEquals("5", thirdLaunch.getProperty("essential.stage2.version")); - assertEquals("4", thirdLaunch.getProperty("essential.version")); + assertEquals("4", secondLaunch.getProperty("essential.stage2.version")); + assertEquals("4", secondLaunch.getProperty("essential.version")); } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 3bc4712..a632356 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,6 +19,7 @@ include(":stage1:modlauncher9") include(":stage2:common") include(":stage2:fabric") include(":stage2:launchwrapper") +include(":stage2:launchwrapper-legacy") include(":stage2:modlauncher") include(":stage2:modlauncher8") include(":stage2:modlauncher9") diff --git a/stage0/launchwrapper/build.gradle b/stage0/launchwrapper/build.gradle index f7b870b..7cf851b 100644 --- a/stage0/launchwrapper/build.gradle +++ b/stage0/launchwrapper/build.gradle @@ -1,3 +1,10 @@ dependencies { compileOnly("net.minecraft:launchwrapper:1.12") } + +jar { + manifest { + // See Loader#isRawStage0 + attributes("ImplicitlyDependsOnEssential": "false") + } +} diff --git a/stage1/build.gradle b/stage1/build.gradle index c3077c2..badf6ee 100644 --- a/stage1/build.gradle +++ b/stage1/build.gradle @@ -8,7 +8,9 @@ configure(subprojects.findAll { it.name != "common" && it.name != "modlauncher" } dependencies { - bundle(implementation(parent.project("common"))) + if (project.name != "launchwrapper") { + bundle(implementation(parent.project("common"))) + } if (project.name.startsWith("modlauncher")) { bundle(compileOnly(parent.project("modlauncher"))) } diff --git a/stage1/launchwrapper/build.gradle b/stage1/launchwrapper/build.gradle index 5b22c03..1495706 100644 --- a/stage1/launchwrapper/build.gradle +++ b/stage1/launchwrapper/build.gradle @@ -14,3 +14,9 @@ dependencies { compileOnly("org.apache.logging.log4j:log4j-api:2.0-beta9") compileOnly("com.google.code.gson:gson:2.2.4") } + +jar { + from(evaluationDependsOn(":stage2:launchwrapper").tasks.named("jar").map { it.outputs }) { + rename { "gg/essential/loader/stage1/stage2.jar" } + } +} diff --git a/stage1/launchwrapper/src/main/java/gg/essential/loader/stage1/DelayedStage0Tweaker.java b/stage1/launchwrapper/src/main/java/gg/essential/loader/stage1/DelayedStage0Tweaker.java index 94a3090..01d7d41 100644 --- a/stage1/launchwrapper/src/main/java/gg/essential/loader/stage1/DelayedStage0Tweaker.java +++ b/stage1/launchwrapper/src/main/java/gg/essential/loader/stage1/DelayedStage0Tweaker.java @@ -1,3 +1,6 @@ +// +// This file has a copy in the :stage2:launchwrapper project. Keep in sync. +// package gg.essential.loader.stage1; import net.minecraft.launchwrapper.ITweaker; diff --git a/stage1/launchwrapper/src/main/java/gg/essential/loader/stage1/EssentialLoader.java b/stage1/launchwrapper/src/main/java/gg/essential/loader/stage1/EssentialLoader.java deleted file mode 100644 index dd52f27..0000000 --- a/stage1/launchwrapper/src/main/java/gg/essential/loader/stage1/EssentialLoader.java +++ /dev/null @@ -1,42 +0,0 @@ -package gg.essential.loader.stage1; - -import net.minecraft.launchwrapper.Launch; -import net.minecraft.launchwrapper.LaunchClassLoader; - -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.net.URL; -import java.net.URLClassLoader; - -public final class EssentialLoader extends EssentialLoaderBase { - - private static EssentialLoader instance; - public static synchronized EssentialLoader getInstance(String gameVersion) { - if (instance == null) { - instance = new EssentialLoader(gameVersion); - } - return instance; - } - - private EssentialLoader(final String gameVersion) { - super("launchwrapper", gameVersion); - } - - @Override - protected ClassLoader addToClassLoader(URL stage2Url) throws Exception { - // Add stage2 file to launch class loader (with an exception) and its parent (which will end up load it) - LaunchClassLoader classLoader = Launch.classLoader; - classLoader.addURL(stage2Url); - classLoader.addClassLoaderExclusion(STAGE2_PKG); - addUrlHack(classLoader.getClass().getClassLoader(), stage2Url); - return classLoader; - } - - private static void addUrlHack(ClassLoader loader, URL url) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { - // This breaks if the parent class loader is not a URLClassLoader, but so does Forge, so we should be fine. - final ClassLoader classLoader = Launch.classLoader.getClass().getClassLoader(); - final Method method = URLClassLoader.class.getDeclaredMethod("addURL", URL.class); - method.setAccessible(true); - method.invoke(classLoader, url); - } -} \ No newline at end of file diff --git a/stage1/launchwrapper/src/main/java/gg/essential/loader/stage1/EssentialSetupTweaker.java b/stage1/launchwrapper/src/main/java/gg/essential/loader/stage1/EssentialSetupTweaker.java index 1575265..43153eb 100644 --- a/stage1/launchwrapper/src/main/java/gg/essential/loader/stage1/EssentialSetupTweaker.java +++ b/stage1/launchwrapper/src/main/java/gg/essential/loader/stage1/EssentialSetupTweaker.java @@ -3,60 +3,63 @@ import net.minecraft.launchwrapper.ITweaker; import net.minecraft.launchwrapper.Launch; import net.minecraft.launchwrapper.LaunchClassLoader; -import net.minecraftforge.common.ForgeVersion; -import net.minecraftforge.fml.relauncher.CoreModManager; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; import java.io.File; -import java.io.IOException; -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.net.URI; +import java.io.InputStream; import java.net.URL; -import java.util.ArrayList; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.List; -import java.util.jar.Attributes; -import java.util.jar.JarFile; @SuppressWarnings("unused") public class EssentialSetupTweaker implements ITweaker { - private static final Logger LOGGER = LogManager.getLogger(EssentialSetupTweaker.class); private final ITweaker stage0; - private final EssentialLoader loader; + private final ITweaker stage2; + + private static Class stage2Cls; + private static synchronized ITweaker newStage2Tweaker(ITweaker stage0) throws Exception { + if (stage2Cls == null) { + Path extracted = Files.createTempFile("essential-loader-stage2-", ".jar"); + extracted.toFile().deleteOnExit(); + try (InputStream in = EssentialSetupTweaker.class.getResourceAsStream("stage2.jar")) { + assert(in != null); + Files.copy(in, extracted, StandardCopyOption.REPLACE_EXISTING); + } + URLClassLoader classLoader = new URLClassLoader(new URL[]{extracted.toUri().toURL()}, Launch.classLoader); + + stage2Cls = classLoader.loadClass("gg.essential.loader.stage2.EssentialSetupTweaker"); + } + return (ITweaker) stage2Cls.getConstructor(ITweaker.class).newInstance(stage0); + } public EssentialSetupTweaker(ITweaker stage0) throws Exception { this.stage0 = stage0; if (DelayedStage0Tweaker.isRequired()) { DelayedStage0Tweaker.prepare(stage0); - this.loader = null; + this.stage2 = null; return; } - final Forge forge = Forge.getIfPresent(); - final Unknown unknown = new Unknown.Impl(); - final Platform platform = forge != null ? forge : unknown; - - platform.setupPreLoad(this); - - this.loader = EssentialLoader.getInstance(platform.getVersion()); - this.loader.load(Launch.minecraftHome.toPath()); - - platform.setupPostLoad(this); + this.stage2 = newStage2Tweaker(stage0); } @Override public void acceptOptions(List args, File gameDir, File assetsDir, String profile) { + if (this.stage2 != null) { + this.stage2.acceptOptions(args, gameDir, assetsDir, profile); + } } @Override public void injectIntoClassLoader(LaunchClassLoader classLoader) { - if (this.loader == null) { + if (this.stage2 == null) { DelayedStage0Tweaker.inject(); return; } - this.loader.initialize(); + this.stage2.injectIntoClassLoader(classLoader); } @Override @@ -68,194 +71,4 @@ public String getLaunchTarget() { public String[] getLaunchArguments() { return new String[0]; } - - private interface Platform { - String getVersion(); - default void setupPreLoad(EssentialSetupTweaker stage1) throws Exception {} - default void setupPostLoad(EssentialSetupTweaker stage1) throws Exception {} - } - - private interface Unknown extends Platform { - class Impl implements Unknown { - @Override - public String getVersion() { - return "unknown"; - } - } - } - - private interface Forge extends Platform { - static Forge getIfPresent() throws IOException { - if (Launch.classLoader.getClassBytes("net.minecraftforge.common.ForgeVersion") != null) { - return getUnchecked(); - } else { - return null; - } - } - - static Forge getUnchecked() { - return new Impl(); - } - - class Impl implements Forge { - private static final String MIXIN_TWEAKER = "org.spongepowered.asm.launch.MixinTweaker"; - - @Override - public String getVersion() { - try { - // Accessing via reflection so the compiler does not inline the value at build time. - return "forge_" + ForgeVersion.class.getDeclaredField("mcVersion").get(null); - } catch (IllegalAccessException | NoSuchFieldException e) { - e.printStackTrace(); - return "unknown"; - } - } - - @Override - public void setupPostLoad(EssentialSetupTweaker stage1) throws Exception { - final List sourceFiles = getSourceFiles(stage1.stage0.getClass()); - if (sourceFiles.isEmpty()) { - System.out.println("Not able to determine current file. Mod will NOT work"); - return; - } - for (SourceFile sourceFile : sourceFiles) { - setupSourceFile(sourceFile); - } - } - - @SuppressWarnings("unchecked") - private void setupSourceFile(SourceFile sourceFile) throws Exception { - // Forge will by default ignore a mod file if it contains a tweaker - // So we need to remove ourselves from that exclusion list - Field ignoredModFile = CoreModManager.class.getDeclaredField("ignoredModFiles"); - ignoredModFile.setAccessible(true); - ((List) ignoredModFile.get(null)).remove(sourceFile.file.getName()); - - // And instead add ourselves to the mod candidate list - CoreModManager.getReparseableCoremods().add(sourceFile.file.getName()); - - // FML will not load CoreMods if it finds a tweaker, so we need to load the coremod manually if present - // We do this to reduce the friction of adding our tweaker if a mod has previously been relying on a - // coremod (cause ordinarily they would have to convert their coremod into a tweaker manually). - // Mixin takes care of this as well, so we mustn't if it will. - String coreMod = sourceFile.coreMod; - if (coreMod != null && !sourceFile.mixin) { - Method loadCoreMod = CoreModManager.class.getDeclaredMethod("loadCoreMod", LaunchClassLoader.class, String.class, File.class); - loadCoreMod.setAccessible(true); - ITweaker tweaker = (ITweaker) loadCoreMod.invoke(null, Launch.classLoader, coreMod, sourceFile.file); - ((List) Launch.blackboard.get("Tweaks")).add(tweaker); - } - - // If they declared our tweaker but also want to use mixin, then we'll inject the mixin tweaker - // for them. - if (sourceFile.mixin) { - // Mixin will only look at jar files which declare the MixinTweaker as their tweaker class, so we need - // to manually add our source files for inspection. - try { - injectMixinTweaker(); - - Class MixinBootstrap = Class.forName("org.spongepowered.asm.launch.MixinBootstrap"); - Class MixinPlatformManager = Class.forName("org.spongepowered.asm.launch.platform.MixinPlatformManager"); - Object platformManager = MixinBootstrap.getDeclaredMethod("getPlatform").invoke(null); - Method addContainer; - Object arg; - try { - // Mixin 0.7 - addContainer = MixinPlatformManager.getDeclaredMethod("addContainer", URI.class); - arg = sourceFile.file.toURI(); - } catch (NoSuchMethodException ignored) { - // Mixin 0.8 - Class IContainerHandle = Class.forName("org.spongepowered.asm.launch.platform.container.IContainerHandle"); - Class ContainerHandleURI = Class.forName("org.spongepowered.asm.launch.platform.container.ContainerHandleURI"); - addContainer = MixinPlatformManager.getDeclaredMethod("addContainer", IContainerHandle); - arg = ContainerHandleURI.getDeclaredConstructor(URI.class).newInstance(sourceFile.file.toURI()); - } - addContainer.invoke(platformManager, arg); - } catch (Exception e) { - e.printStackTrace(); - } - } - } - - private List getSourceFiles(Class tweakerClass) { - String tweakerClassName = tweakerClass.getName(); - List sourceFiles = new ArrayList<>(); - for (URL url : Launch.classLoader.getSources()) { - try { - URI uri = url.toURI(); - if (!"file".equals(uri.getScheme())) { - continue; - } - File file = new File(uri); - if (!file.exists() || !file.isFile()) { - continue; - } - String tweakClass = null; - String coreMod = null; - boolean mixin = false; - try (JarFile jar = new JarFile(file)) { - if (jar.getManifest() != null) { - Attributes attributes = jar.getManifest().getMainAttributes(); - tweakClass = attributes.getValue("TweakClass"); - coreMod = attributes.getValue("FMLCorePlugin"); - mixin = attributes.getValue("MixinConfigs") != null; - } - } - if (tweakerClassName.equals(tweakClass)) { - sourceFiles.add(new SourceFile(file, coreMod, mixin)); - } - } catch (Exception e) { - LOGGER.error("Failed to read manifest from " + url + ":", e); - } - } - return sourceFiles; - } - - private void injectMixinTweaker() throws ClassNotFoundException, IllegalAccessException, InstantiationException { - @SuppressWarnings("unchecked") - List tweakClasses = (List) Launch.blackboard.get("TweakClasses"); - - // If the MixinTweaker is already queued (because of another mod), then there's nothing we need to to - if (tweakClasses.contains(MIXIN_TWEAKER)) { - // Except we do need to initialize the MixinTweaker immediately so we can add containers - // for our mods. - // This is idempotent, so we can call it without adding to the tweaks list (and we must not add to - // it because the queued tweaker will already get added and there is nothing we can do about that). - initMixinTweaker(); - return; - } - - // If it is already booted, we're also good to go - if (Launch.blackboard.get("mixin.initialised") != null) { - return; - } - - System.out.println("Injecting MixinTweaker from EssentialSetupTweaker"); - - // Otherwise, we need to take things into our own hands because the normal way to chainload a tweaker - // (by adding it to the TweakClasses list during injectIntoClassLoader) is too late for Mixin. - // Instead we instantiate the MixinTweaker on our own and add it to the current Tweaks list immediately. - @SuppressWarnings("unchecked") - List tweaks = (List) Launch.blackboard.get("Tweaks"); - tweaks.add(initMixinTweaker()); - } - - private ITweaker initMixinTweaker() throws ClassNotFoundException, IllegalAccessException, InstantiationException { - Launch.classLoader.addClassLoaderExclusion(MIXIN_TWEAKER.substring(0, MIXIN_TWEAKER.lastIndexOf('.'))); - return (ITweaker) Class.forName(MIXIN_TWEAKER, true, Launch.classLoader).newInstance(); - } - - private static class SourceFile { - final File file; - final String coreMod; - final boolean mixin; - - private SourceFile(File file, String coreMod, boolean mixin) { - this.file = file; - this.coreMod = coreMod; - this.mixin = mixin; - } - } - } - } } diff --git a/stage2/common/src/main/java/gg/essential/loader/stage2/EssentialLoaderBase.java b/stage2/common/src/main/java/gg/essential/loader/stage2/EssentialLoaderBase.java index fad7b46..12e3acb 100644 --- a/stage2/common/src/main/java/gg/essential/loader/stage2/EssentialLoaderBase.java +++ b/stage2/common/src/main/java/gg/essential/loader/stage2/EssentialLoaderBase.java @@ -660,7 +660,7 @@ private URLConnection prepareConnection(final URL url) throws IOException { return urlConnection; } - private String getRequiredStage2VersionIfOutdated(Path modFile) { + protected String getRequiredStage2VersionIfOutdated(Path modFile) { // If we don't know our own version, then stage1 predates pinning, so it'll always auto-update and we're always // up-to-date enough for all mods (assuming the stage2 update is released before mods that require it). if (currentStage2Version == null) { diff --git a/stage2/launchwrapper-legacy/build.gradle b/stage2/launchwrapper-legacy/build.gradle new file mode 100644 index 0000000..693be04 --- /dev/null +++ b/stage2/launchwrapper-legacy/build.gradle @@ -0,0 +1,13 @@ +repositories { + maven { url "https://maven.minecraftforge.net/" } +} + +dependencies { + compileOnly("net.minecraft:launchwrapper:1.12") +} + +jar { + from(evaluationDependsOn(":stage2:launchwrapper").tasks.named("jar").map { it.outputs }) { + rename { "gg/essential/loader/stage2/real-stage2.jar" } + } +} diff --git a/stage2/launchwrapper-legacy/src/main/java/gg/essential/loader/stage2/EssentialLoader.java b/stage2/launchwrapper-legacy/src/main/java/gg/essential/loader/stage2/EssentialLoader.java new file mode 100644 index 0000000..daaa791 --- /dev/null +++ b/stage2/launchwrapper-legacy/src/main/java/gg/essential/loader/stage2/EssentialLoader.java @@ -0,0 +1,42 @@ +package gg.essential.loader.stage2; + +import net.minecraft.launchwrapper.ITweaker; +import net.minecraft.launchwrapper.Launch; + +import java.io.InputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +/** + * This class (and jar more generally) exists to be loaded by the auto-update mechanism of an old stage1. + * It is meant to be uploaded to Essential's infra where it can be downloaded by such old stage1s. + * It contains the real stage2 embedded inside it as a jar file, and when executed simply extracts that and then jumps + * to its `EssentialLoaderTweaker` class as a newer stage1 would do. + * + * Having this wrapper is necessary because old a stage1 will add the downloaded jar to the launch and system class + * loader before executing it, which prevents the stage2 self-update mechanism from working because if it tries to + * create a new ClassLoader for the newer stage2 it found and tries to load it, that'll just return the old stage2 + * that's already available in the parent class loader. + * That's why we have this wrapper, so only this wrapper class becomes un-updatable, but the real stage2 is loaded in a + * separate ClassLoader and can therefore load a newer version of itself in another separate ClassLoader. + */ +@Deprecated // called by old stage1 +@SuppressWarnings("unused") +public class EssentialLoader { + public EssentialLoader(Path gameDir, String gameVersion) throws Exception { + Path extracted = Files.createTempFile("essential-loader-stage2", ".jar"); + try (InputStream in = EssentialLoader.class.getResourceAsStream("real-stage2.jar")) { + assert(in != null); + Files.copy(in, extracted, StandardCopyOption.REPLACE_EXISTING); + } + URLClassLoader classLoader = new URLClassLoader(new URL[]{extracted.toUri().toURL()}, Launch.classLoader); + + Class cls = classLoader.loadClass("gg.essential.loader.stage2.EssentialSetupTweaker"); + cls.getConstructor(ITweaker.class).newInstance((ITweaker) null); + + throw new AssertionError("should relaunch and never return"); + } +} diff --git a/stage2/launchwrapper/build.gradle b/stage2/launchwrapper/build.gradle index a57ca72..9327fb5 100644 --- a/stage2/launchwrapper/build.gradle +++ b/stage2/launchwrapper/build.gradle @@ -1,8 +1,23 @@ +repositories { + maven { url "https://maven.minecraftforge.net/" } +} + dependencies { + compileOnly("net.minecraftforge:forge:1.8.9-11.15.1.2318-1.8.9:universal") compileOnly("net.minecraft:launchwrapper:1.12") // Versions based on the one which MC include by default in 1.8.9 (minimal supported version) // See https://github.com/MultiMC/meta-multimc/blob/master/net.minecraft/1.8.9.json compileOnly("com.google.guava:guava:17.0") compileOnly("org.apache.commons:commons-lang3:3.3.2") + compileOnly("com.google.code.gson:gson:2.2.4") +} + +jar { + def version = provider { project.version } + inputs.property("project.version", version) + manifest { + attributes("Name": "gg/essential/loader/stage2/") + attributes("Implementation-Version": version) + } } diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage1/DelayedStage0Tweaker.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage1/DelayedStage0Tweaker.java new file mode 100644 index 0000000..f796ba9 --- /dev/null +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage1/DelayedStage0Tweaker.java @@ -0,0 +1,116 @@ +// +// This file is a copy of the one in the :stage1:launchwrapper project. Keep in sync. +// +package gg.essential.loader.stage1; + +import net.minecraft.launchwrapper.ITweaker; +import net.minecraft.launchwrapper.Launch; +import net.minecraft.launchwrapper.LaunchClassLoader; +import net.minecraftforge.fml.relauncher.CoreModManager; +import net.minecraftforge.fml.relauncher.FMLRelaunchLog; + +import java.io.File; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; + +/** + * When launched in a dev environment via a `--tweakClass` argument, we will load in an earlier cycle than in production + * (where only the FMLTweaker loads in the first cycle, and it then queues us for the second one). This means that if + * there are any Essential in the mods folder, that those are not yet on the classpath at this time, which in turn means + * that our platform setup code does not consider them, meaning they won't load as mods. + * To work around that (and get dev closer to production), if we detect that we are in the first cycle, we will instead + * queue this tweaker for the second one (as a fake stage0) and then proceed as usual from there. + */ +public class DelayedStage0Tweaker implements ITweaker { + private static final String FML_TWEAKER = "net.minecraftforge.fml.common.launcher.FMLTweaker"; + private static final String COMMAND_LINE_COREMODS_PROP = "fml.coreMods.load"; + + private static ITweaker realStage0; + private static String[] commandLineCoremods; // we also delay these cause they may depend on our stuff + private final ITweaker stage1; + + @SuppressWarnings("unused") + public DelayedStage0Tweaker() throws Exception { + if (commandLineCoremods.length > 0) { + // Temporarily restore these in case we need to re-launch (they do not need to be delayed in that case) + System.setProperty(COMMAND_LINE_COREMODS_PROP, String.join(",", commandLineCoremods)); + } + + this.stage1 = new EssentialSetupTweaker(realStage0); + + System.clearProperty(COMMAND_LINE_COREMODS_PROP); + + for (String commandLineCoremod : commandLineCoremods) { + FMLRelaunchLog.info("Found a command line coremod : %s", commandLineCoremod); + + Method loadCoreMod = CoreModManager.class.getDeclaredMethod("loadCoreMod", LaunchClassLoader.class, String.class, File.class); + loadCoreMod.setAccessible(true); + ITweaker tweaker = (ITweaker) loadCoreMod.invoke(null, Launch.classLoader, commandLineCoremod, null); + + if (tweaker != null) { + @SuppressWarnings("unchecked") + List tweakers = ((List) Launch.blackboard.get("Tweaks")); + tweakers.add(tweaker); + } + } + } + + @Override + public void acceptOptions(List args, File gameDir, File assetsDir, String profile) { + this.stage1.acceptOptions(args, gameDir, assetsDir, profile); + } + + @Override + public void injectIntoClassLoader(LaunchClassLoader classLoader) { + this.stage1.injectIntoClassLoader(classLoader); + } + + @Override + public String getLaunchTarget() { + return this.stage1.getLaunchTarget(); + } + + @Override + public String[] getLaunchArguments() { + return this.stage1.getLaunchArguments(); + } + + public static boolean isRequired() { + @SuppressWarnings("unchecked") + List currentCycle = (List) Launch.blackboard.get("Tweaks"); + return currentCycle.stream().anyMatch(it -> it.getClass().getName().equals(FML_TWEAKER)); + } + + public static void prepare(ITweaker stage0) { + if (realStage0 != null) { + throw new IllegalStateException("Can only delay one stage0 tweaker. Why are there multiple anyway?"); + } + realStage0 = stage0; + + String commandLineCoremodsStr = System.getProperty(COMMAND_LINE_COREMODS_PROP, ""); + commandLineCoremods = commandLineCoremodsStr.isEmpty() ? new String[0] : commandLineCoremodsStr.split(","); + System.clearProperty(COMMAND_LINE_COREMODS_PROP); + } + + public static void inject() { + @SuppressWarnings("unchecked") + List nextCycle = (List) Launch.blackboard.get("TweakClasses"); + nextCycle.add(DelayedStage0Tweaker.class.getName()); + + // Tweaker arguments are consumed by Launch.launch, so when relaunching we assume the FMLTweaker to be the only + // one passed in (as common for production). However, if we end up here, then the Essential tweaker has also + // been passed (as common for dev) next to the FMLTweaker (rather than being chain-loaded by it). + // If we do not re-add ourselves to the tweaker list when we re-launch, then we may not get called at all (or + // too late if there are command line supplied coremods relying on us), so we add ourselves to the launchArgs + // which FMLTweaker makes available (and which we use in Relaunch to take an educated guess at the original + // arguments). + @SuppressWarnings("unchecked") + Map launchArgs = (Map) Launch.blackboard.get("launchArgs"); + String prevValue = launchArgs.put("--tweakClass", "gg.essential.loader.stage0.EssentialSetupTweaker"); + if (prevValue != null) { + throw new UnsupportedOperationException("Cannot re-register Essential tweaker because \"" + + prevValue + "\" was already there. This will require a more complex implementation."); + } + } +} diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage1/EssentialSetupTweaker.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage1/EssentialSetupTweaker.java new file mode 100644 index 0000000..b8fa0dd --- /dev/null +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage1/EssentialSetupTweaker.java @@ -0,0 +1,53 @@ +package gg.essential.loader.stage1; + +import net.minecraft.launchwrapper.ITweaker; +import net.minecraft.launchwrapper.LaunchClassLoader; + +import java.io.File; +import java.util.List; + +// This class replaces the stage1 in a relaunched environment. It simply delegates to the stage2 which is already on +// the classpath at this point. +// If a more recent stage2 was discovered during loading, this allows us to directly use that, while the regular stage1 +// would have loaded an older one from one of the mods in the mods folder. +@SuppressWarnings("unused") // called by stage0 +public class EssentialSetupTweaker implements ITweaker { + private final ITweaker stage2; + + public EssentialSetupTweaker(ITweaker stage0) { + if (DelayedStage0Tweaker.isRequired()) { + DelayedStage0Tweaker.prepare(stage0); + this.stage2 = null; + return; + } + + this.stage2 = new gg.essential.loader.stage2.EssentialSetupTweaker(stage0); + } + + @Override + public void acceptOptions(List args, File gameDir, File assetsDir, String profile) { + if (this.stage2 == null) { + return; + } + this.stage2.acceptOptions(args, gameDir, assetsDir, profile); + } + + @Override + public void injectIntoClassLoader(LaunchClassLoader classLoader) { + if (this.stage2 == null) { + DelayedStage0Tweaker.inject(); + return; + } + this.stage2.injectIntoClassLoader(classLoader); + } + + @Override + public String getLaunchTarget() { + return null; + } + + @Override + public String[] getLaunchArguments() { + return new String[0]; + } +} diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java index a2a3fef..c8c2230 100644 --- a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialLoader.java @@ -1,97 +1,7 @@ package gg.essential.loader.stage2; -import gg.essential.loader.stage2.relaunch.Relaunch; -import gg.essential.loader.stage2.util.MixinTweakerInjector; -import gg.essential.loader.stage2.util.Stage0Tracker; -import net.minecraft.launchwrapper.Launch; - -import java.lang.reflect.Method; -import java.net.MalformedURLException; -import java.net.URL; -import java.net.URLClassLoader; -import java.nio.file.Path; -import java.nio.file.Paths; - -public class EssentialLoader extends EssentialLoaderBase { - - private Path ourEssentialPath; - private URL ourEssentialUrl; - private URL ourMixinUrl; - - public EssentialLoader(Path gameDir, String gameVersion) { - super(gameDir, gameVersion); - } - - @Override - protected void loadPlatform() { - if (ourEssentialPath == null || ourEssentialUrl == null || ourMixinUrl == null) { - URL url = Launch.classLoader.findResource(CLASS_NAME.replace('.', '/') + ".class"); - if (url == null) { - throw new RuntimeException("Failed to find Essential jar on classpath."); - } - if (!"jar".equals(url.getProtocol())) { - throw new RuntimeException("Failed to find Essential jar on classpath, found URL with unexpected protocol: " + url); - } - try { - ourEssentialUrl = new URL(url.getFile().substring(0, url.getFile().lastIndexOf('!'))); - } catch (MalformedURLException e) { - throw new RuntimeException("Failed to find Essential jar on classpath, found URL with unexpected file: " + url, e); - } - try { - ourEssentialPath = Paths.get(ourEssentialUrl.toURI()); - } catch (Exception e) { - throw new RuntimeException("Failed to convert Essential jar URL to Path: " + url, e); - } - ourMixinUrl = ourEssentialUrl; - } - - if (Relaunch.checkEnabled()) { - Relaunch.relaunch(ourMixinUrl); - } - - MixinTweakerInjector.injectMixinTweaker(true); - } - - @Override - protected ClassLoader getModClassLoader() { - // FIXME we should ideally be using the launch class loader to load our bootstrap class but currently that - // causes our bootstrap code to break because the launch class loader creates a separate code source for - // each class rather than for the whole jar. - // We should switch this (because it allows us to use preloadLibrary on our api package) once our bootstrap - // loads fine under the launch class loader (the required change has been committed to master but needs to be - // deployed before we can switch here). - // return Launch.classLoader; - return Launch.classLoader.getClass().getClassLoader(); - } - - @Override - protected void addToClasspath(Path path) { - URL url; - try { - // Add to launch class loader - url = path.toUri().toURL(); - Launch.classLoader.addURL(url); - - // FIXME only if jar has a tweaker. and if so, we need to chain-load that tweaker; maybe also the AT? - // And its parent (for those classes that are excluded from the launch class loader) - final ClassLoader classLoader = Launch.classLoader.getClass().getClassLoader(); - final Method method = URLClassLoader.class.getDeclaredMethod("addURL", URL.class); - method.setAccessible(true); - method.invoke(classLoader, url); - } catch (Exception e) { - throw new RuntimeException("Unexpected error", e); - } - - // FIXME not everything that goes through here is necessarily Essential anymore - ourEssentialPath = path; - ourEssentialUrl = url; - ourMixinUrl = url; - } - - @Override - protected void doInitialize() { - Stage0Tracker.registerStage0Tweaker(); - - super.doInitialize(); - } -} +// This class name is reserved for the `launchwrapper-legacy` stage2 shim and must not be used here as it will +// always resolve to the shim class because the old stage1 puts that jar on the system class loader where we have no way +// to update it. +@Deprecated +public class EssentialLoader {} diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialModUpdater.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialModUpdater.java new file mode 100644 index 0000000..df32325 --- /dev/null +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialModUpdater.java @@ -0,0 +1,81 @@ +package gg.essential.loader.stage2; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import gg.essential.loader.stage2.data.ModJarMetadata; +import net.minecraft.launchwrapper.Launch; +import net.minecraftforge.common.ForgeVersion; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.function.Supplier; + +public class EssentialModUpdater implements Supplier { + @Override + public String get() { + // EssentialLoaderBase expects to be called only once per boot for its fallback gui to behave properly + // and running it multiple times is wasteful anyway. + // Using a system property, so we don't run multiple times even if invoked via multiple class loaders. + String key = "gg.essential.loader.stage2.mod-downloader-result"; + String result = System.getProperty(key); + if (result == null) { + result = load(); + System.setProperty(key, result); + } + return result; + } + + private String load() { + JsonArray result = new JsonArray(); + + EssentialLoaderBase loader = new EssentialLoaderBase(getGameDir(), getPlatform()) { + @Override + protected void addToClasspath(Mod mod, ModJarMetadata jarMeta, Path mainJar, List innerJars) { + JsonObject modJson = new JsonObject(); + modJson.addProperty("id", mod.id.getModSlug()); + modJson.addProperty("version", jarMeta.getVersion().getVersion()); + modJson.addProperty("file", mainJar.toAbsolutePath().toString()); + result.add(modJson); + + // Ignoring innerJars because we've never used that mechanism for our launchwrapper versions and won't + // be using it in the future either since it doesn't provide ids nor versions of the inner jars. + } + + @Override + protected void addToClasspath(Path path) { throw new UnsupportedOperationException(); } + @Override + protected void loadPlatform() {} + @Override + protected ClassLoader getModClassLoader() { return null; } + @Override + protected String getRequiredStage2VersionIfOutdated(Path modFile) { + return null; // we support in-place loader upgrades, we never need to prompt the user for a restart + } + }; + try { + loader.load(); + } catch (IOException e) { + throw new RuntimeException(e); + } + + return new Gson().toJson(result); + } + + static Path getGameDir() { + File minecraftHome = Launch.minecraftHome; + if (minecraftHome == null) minecraftHome = new File("."); + return minecraftHome.toPath(); + } + + static String getPlatform() { + try { + // Accessing via reflection so the compiler does not inline the value at build time. + return "forge_" + ForgeVersion.class.getDeclaredField("mcVersion").get(null); + } catch (IllegalAccessException | NoSuchFieldException e) { + throw new RuntimeException(e); + } + } +} diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialSetupTweaker.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialSetupTweaker.java new file mode 100644 index 0000000..7744aab --- /dev/null +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/EssentialSetupTweaker.java @@ -0,0 +1,47 @@ +package gg.essential.loader.stage2; + +import gg.essential.loader.stage2.util.Stage0Tracker; +import net.minecraft.launchwrapper.ITweaker; +import net.minecraft.launchwrapper.LaunchClassLoader; + +import java.io.File; +import java.util.List; + +public class EssentialSetupTweaker implements ITweaker { + public static final RelaunchedLoader LOADER; + static { + RelaunchInfo relaunchInfo = RelaunchInfo.get(); + if (relaunchInfo == null) { + Loader loader = new Loader(); + loader.loadAndRelaunch(); + throw new AssertionError("relaunch should not return"); + } else { + LOADER = new RelaunchedLoader(relaunchInfo); + } + } + + public EssentialSetupTweaker(ITweaker stage0) { + LOADER.initialize(stage0); + } + + @Override + public void acceptOptions(List args, File gameDir, File assetsDir, String profile) { + } + + @Override + public void injectIntoClassLoader(LaunchClassLoader classLoader) { + Stage0Tracker.registerStage0Tweaker(); + + LOADER.injectIntoClassLoader(classLoader); + } + + @Override + public String getLaunchTarget() { + return ""; + } + + @Override + public String[] getLaunchArguments() { + return new String[0]; + } +} diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/Loader.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/Loader.java new file mode 100644 index 0000000..f2aa2be --- /dev/null +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/Loader.java @@ -0,0 +1,519 @@ +package gg.essential.loader.stage2; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import gg.essential.loader.stage2.relaunch.Relaunch; +import net.minecraft.launchwrapper.ITweaker; +import net.minecraft.launchwrapper.Launch; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.jar.Attributes; +import java.util.jar.JarInputStream; +import java.util.jar.Manifest; +import java.util.stream.Collectors; + +import static gg.essential.loader.stage2.util.VersionComparison.compareVersions; +import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; + +public class Loader { + private static final Logger LOGGER = LogManager.getLogger(Loader.class); + private static final Gson GSON = new Gson(); + + private static final String ESSENTIAL_MOD_JSON = "essential.mod.json"; + + private static final String STAGE1_RESOURCE = "gg/essential/loader/stage0/stage1.jar"; + private static final String STAGE2_RESOURCE = "gg/essential/loader/stage1/stage2.jar"; + + private static final String STAGE1_PKG = "gg.essential.loader.stage1."; + private static final String STAGE1_PKG_PATH = STAGE1_PKG.replace('.', '/'); + + private static final String STAGE2_PKG = "gg.essential.loader.stage2."; + private static final String STAGE2_PKG_PATH = STAGE2_PKG.replace('.', '/'); + private static final String STAGE2_CLS = STAGE2_PKG + "EssentialSetupTweaker"; + + private static final String LOADED_STAGE2_VERSION; + + static { + // Note: Cannot just use `Loader.class.getPackage().getImplementationVersion()` because LaunchWrapper does not + // properly handle packages, we'll just get a dummy package with `null` version. + Path loadedPath; + try { + loadedPath = Paths.get(Loader.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + LOADED_STAGE2_VERSION = readStage2Version(loadedPath); + + LOGGER.info("Running Essential Loader v{}", LOADED_STAGE2_VERSION); + System.setProperty("essential.stage2.version", LOADED_STAGE2_VERSION); + } + + private final Path minecraftHome = (Launch.minecraftHome != null ? Launch.minecraftHome : new File(".")) + .toPath() + .toAbsolutePath(); + + /** Path to a more recent loader jar file if one was found during discovery. */ + private Path newerLoaderJar; + private String latestLoaderJarVersion = LOADED_STAGE2_VERSION; + + public void loadAndRelaunch() { + List jars = load(Launch.classLoader.getSources()); + + if (newerLoaderJar != null) { + relaunchViaNewerLoader(newerLoaderJar); + } + + relaunch(jars); + } + + private void relaunchViaNewerLoader(Path loaderJar) { + URL url; + try { + url = loaderJar.toUri().toURL(); + } catch (MalformedURLException e) { + throw new RuntimeException(e); // should never happen because `path` is a simple temporary file + } + try (URLClassLoader classLoader = new URLClassLoader(new URL[]{url}, Launch.classLoader)) { + classLoader.loadClass(STAGE2_CLS) + .getConstructor(ITweaker.class) + .newInstance((ITweaker) null); + } catch (ReflectiveOperationException | IOException e) { + throw new RuntimeException(e); + } + throw new AssertionError("should relaunch and never return"); + } + + private void relaunch(List jars) { + RelaunchInfo relaunchInfo = new RelaunchInfo(); + relaunchInfo.loadedIds = jars.stream() + .map(it -> it.id) + .collect(Collectors.toSet()); + RelaunchInfo.put(relaunchInfo); + + Set priorityClassPath = new LinkedHashSet<>(); + // Put ourselves on the classpath so we don't have to go re-discover ourselves. + // In particular this also puts our stage1 EssentialSetupTweaker on there, which shortcuts all the discovery by + // directly loading this stage2. + priorityClassPath.add(Loader.class.getProtectionDomain().getCodeSource().getLocation()); + for (JarInfo jarInfo : jars) { + priorityClassPath.add(jarInfo.url()); + } + + List loadedByForge = Launch.classLoader.getSources(); + String extraMods = jars.stream() + .filter(it -> !loadedByForge.contains(it.url())) + .map(it -> minecraftHome.relativize(it.path).toString()) + .collect(Collectors.joining(",")); + + Consumer> modifyArgsForExtraMods = extraMods.isEmpty() ? arg -> {} : args -> { + int index = args.indexOf("--mods"); + if (index == -1) { + args.add("--mods"); + args.add(extraMods); + } else { + args.set(index + 1, extraMods + "," + args.get(index + 1)); + } + }; + + Relaunch.relaunch(priorityClassPath, modifyArgsForExtraMods); + throw new AssertionError("relaunch should not return"); + } + + private List load(Collection sources) { + List topLevel = new ArrayList<>(); + Map allVersions = new LinkedHashMap<>(); + for (URL url : sources) { + JarInfo jarInfo = load(url, allVersions); + if (jarInfo != null) { + topLevel.add(jarInfo); + } + } + + Map latestJars = new HashMap<>(); + for (JarInfo info : allVersions.values()) { + JarInfo latestInfo = latestJars.get(info.id); + if (latestInfo == null || compareVersions(info.version, latestInfo.version) > 0) { + latestJars.put(info.id, info); + } + } + + List jars = new ArrayList<>(latestJars.values()); + jars.sort(Comparator.comparing(it -> it.id)); + + // Special case: Old Essential includes various things directly in its jar, so we'll put it last so other mods + // which properly use nested jars can overwrite that. + JarInfo essentialJarInfo = latestJars.get("essential"); + if (essentialJarInfo != null && essentialJarInfo.children.isEmpty()) { + jars.remove(essentialJarInfo); + jars.add(essentialJarInfo); + } + + if (newerLoaderJar == null) { + Set visited = new HashSet<>(); + StringBuilder sb = new StringBuilder(); + for (JarInfo jar : topLevel) { + sb.append(" - "); + prettyPrint(sb, " ", jar, visited, latestJars); + } + LOGGER.info("Essential Loader discovered {} jars ({} unique jars):\n{}", allVersions.size(), latestJars.size(), sb.toString()); + } + + return jars; + } + + private JarInfo load(URL url, Map allVersions) { + Path path; + try { + URI uri = url.toURI(); + if (!"file".equals(uri.getScheme())) { + return null; + } + File file = new File(uri); + if (!file.exists() || !file.isFile()) { + return null; + } + path = file.toPath().toAbsolutePath(); + } catch (Exception e) { + LOGGER.error("Failed to find path of {}:", url, e); + return null; + } + + return load(null, path, null, allVersions); + } + + private JarInfo load(JarInfo parent, Path jar, JsonObject descriptor, Map allVersions) { + assert(jar.getFileSystem() == FileSystems.getDefault()); + assert(jar.isAbsolute()); + + boolean hasStage1 = false; + + try (FileSystem fileSystem = FileSystems.newFileSystem(jar, (ClassLoader) null)) { + Path modJsonPath = fileSystem.getPath(ESSENTIAL_MOD_JSON); + if (Files.exists(modJsonPath)) { + try (BufferedReader in = Files.newBufferedReader(modJsonPath)) { + descriptor = GSON.fromJson(in, JsonObject.class); + } + } + + Path stage1Path = fileSystem.getPath(STAGE1_RESOURCE); + if (Files.exists(stage1Path)) { + hasStage1 = true; + checkForNewerLoaderInStage1(stage1Path); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + + // If this mod doesn't have a essential.mod.json but does have stage1 jar, then it's likely a mod which used + // Essential Loader before explicit dependencies via essential.mod.json became a thing when it was only loading + // the Essential mod, so we need to synthesize a essential.mod.json file for it. + // Special case being the raw stage0 file as it will appear when you depend on EssentialLoader in your + // development environment, that one we want to just ignore. + if (descriptor == null && hasStage1 && !isRawStage0(jar)) { + descriptor = new JsonObject(); + // We know neither its id nor version, so we use dummy values + descriptor.addProperty("id", guessId(jar)); + descriptor.addProperty("version", "[unknown version]"); + + // These old mods always implicitly depend on the Essential mod, so load that as a dependency + JsonArray jars = new JsonArray(); + JsonObject lib = new JsonObject(); + lib.addProperty("builtin", "essential"); + jars.add(lib); + descriptor.add("jars", jars); + } + + if (descriptor == null) { + // This mod doesn't appear to be using Essential Loader + return null; + } + + JsonPrimitive schemaRevisionJson = descriptor.getAsJsonPrimitive("schemaRevision"); + int schemaRevision = schemaRevisionJson != null ? schemaRevisionJson.getAsInt() : 0; + if (schemaRevision > 0) { + // Unsupported schema revision + // If we have a newer loader jar queued, that likely supports the newer revision, so let's not complain now. + // If we don't though, then let's print a warning about it. + if (newerLoaderJar == null) { + LOGGER.warn("Unsupported schema revision `{}` in `{}`. ", schemaRevision, jar); + } + return null; + } + + String id = descriptor.getAsJsonPrimitive("id").getAsString(); + String version = descriptor.getAsJsonPrimitive("version").getAsString(); + + String key = id + ":" + version; + JarInfo info = allVersions.get(key); + if (info != null) { + if (parent != null) parent.children.add(info); + return info; + } + + info = new JarInfo(); + info.path = jar; + info.id = id; + info.version = version; + if (parent != null) parent.children.add(info); + allVersions.put(key, info); + + JsonElement jarsElement = descriptor.get("jars"); + if (jarsElement != null && jarsElement.isJsonArray()) { + for (JsonElement jarElement : jarsElement.getAsJsonArray()) { + if (!jarElement.isJsonObject()) continue; + loadDependency(info, jarElement.getAsJsonObject(), allVersions); + } + } + + return info; + } + + private void loadDependency(JarInfo outerJar, JsonObject spec, Map allVersions) { + JsonElement builtIn = spec.get("builtin"); + if (builtIn != null && builtIn.isJsonPrimitive() && "essential".equals(builtIn.getAsJsonPrimitive().getAsString())) { + spec.addProperty("class", EssentialModUpdater.class.getName()); + } + + if (spec.has("class")) { + String clsName = spec.getAsJsonPrimitive("class").getAsString(); + LOGGER.trace("Loading {} from {}", clsName, outerJar.path); + String producedJson; + try (URLClassLoader classLoader = new URLClassLoader(new URL[]{outerJar.path.toUri().toURL()}, getClass().getClassLoader())) { + Supplier supplier; + try { + //noinspection unchecked + supplier = classLoader + .loadClass(clsName) + .asSubclass(Supplier.class) + .getConstructor() + .newInstance(); + } catch (ReflectiveOperationException e) { + LOGGER.error("Failed to load class `{}` from `{}`:", clsName, outerJar, e); + return; + } + producedJson = supplier.get(); + } catch (IOException e) { + throw new RuntimeException(e); + } + + for (JsonElement libElement : GSON.fromJson(producedJson, JsonArray.class)) { + loadDependency(outerJar, libElement.getAsJsonObject(), allVersions); + } + return; + } + + if (spec.has("file")) { + String file = spec.getAsJsonPrimitive("file").getAsString(); + Path path; + if (file.startsWith("/")) { + path = FileSystems.getDefault().getPath(file); + } else { + try (FileSystem fileSystem = FileSystems.newFileSystem(outerJar.path, (ClassLoader) null)) { + Path innerJar = fileSystem.getPath(file); + String name = innerJar.getFileName().toString(); + int extension = name.lastIndexOf('.'); + if (extension == -1) extension = name.length(); + path = Files.createTempFile(name.substring(0, extension) + "-", name.substring(extension)); + path.toFile().deleteOnExit(); + LOGGER.debug("Extracting {} to {}", innerJar, path); + Files.copy(innerJar, path, REPLACE_EXISTING); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + load(outerJar, path, spec, allVersions); + return; + } + + // Spec doesn't have any recognized keys + // If we have a newer loader jar queued, it might just be that this version of the loader doesn't yet support + // the spec, so let's not complain. + // If we don't though, then the spec is probably wrong, so let's print a warning about it. + if (newerLoaderJar == null) { + LOGGER.warn("Unsupported jar specification `{}` containing neither `file` nor `class` found in `{}`. ", spec, outerJar.path); + } + } + + private int latestStage1Version; + private void checkForNewerLoaderInStage1(Path stage1Jar) throws IOException { + int stage1Version = readStage1Version(stage1Jar); + if (stage1Version <= latestStage1Version) { + return; // stage1 jar is older than what we've previously tried, not even worth looking at embedded stage2 + } + latestStage1Version = stage1Version; + + // ZipFileSystem doesn't support nested jars, so we need to extract it to a temporary file + Path tmpStage1Jar = Files.createTempFile("essential-loader-stage1-", ".jar"); + try { + Files.copy(stage1Jar, tmpStage1Jar, REPLACE_EXISTING); + + try (FileSystem fileSystem = FileSystems.newFileSystem(tmpStage1Jar, (ClassLoader) null)) { + Path stage2Path = fileSystem.getPath(STAGE2_RESOURCE); + if (!Files.exists(stage2Path)) return; + checkForNewerLoader(stage2Path); + } + } finally { + Files.delete(tmpStage1Jar); + } + } + + private void checkForNewerLoader(Path stage2Jar) throws IOException { + String version = readStage2Version(stage2Jar); + if (version == null) return; + + if (compareVersions(version, latestLoaderJarVersion) <= 0) { + return; // given jar isn't an upgrade, nothing to do + } + + // Copy to temporary file, because we don't know how long the given path will remain valid for + Path copiedJar = Files.createTempFile("essential-loader-stage2-", ".jar"); + copiedJar.toFile().deleteOnExit(); + Files.copy(stage2Jar, copiedJar, REPLACE_EXISTING); + + newerLoaderJar = copiedJar; + latestLoaderJarVersion = version; + } + + private static int readStage1Version(Path path) { + String str = readImplementationVersion(path, STAGE1_PKG_PATH); + if (str == null) return -1; + try { + return Integer.parseInt(str); + } catch (Exception e) { + LOGGER.error("Failed to parse version from " + path, e); + return -1; + } + } + + private static String readStage2Version(Path path) { + return readImplementationVersion(path, STAGE2_PKG_PATH); + } + + private static String readImplementationVersion(Path jar, String name) { + try (InputStream rawIn = Files.newInputStream(jar); + JarInputStream in = new JarInputStream(rawIn, false)) { + Manifest manifest = in.getManifest(); + if (manifest == null) { + return null; + } + Attributes attributes = manifest.getMainAttributes(); + if (!name.equals(attributes.getValue("Name"))) { + return null; + } + return attributes.getValue("Implementation-Version"); + } catch (Exception e) { + LOGGER.error("Failed to read implementation version from " + jar, e); + return null; + } + } + + private static boolean isRawStage0(Path jar) { + // If this flag is set, then install the Essential mod in dev too + if (Boolean.getBoolean("essential.loader.installEssentialMod")) { + return false; + } + try (InputStream rawIn = Files.newInputStream(jar); + JarInputStream in = new JarInputStream(rawIn, false)) { + Manifest manifest = in.getManifest(); + if (manifest == null) { + return false; + } + Attributes attributes = manifest.getMainAttributes(); + return "false".equals(attributes.getValue("ImplicitlyDependsOnEssential")); + } catch (Exception e) { + LOGGER.error("Failed to read manifest from " + jar, e); + return false; + } + } + + private String guessId(Path jar) { + try (FileSystem fileSystem = FileSystems.newFileSystem(jar, (ClassLoader) null)) { + if (Files.exists(fileSystem.getPath("essential_container_marker.txt"))) { + return "essential-container"; + } + } catch (IOException e) { + throw new RuntimeException(e); + } + + String relPath = minecraftHome.relativize(jar).toString(); + if (!relPath.startsWith("..")) { + return relPath; + } + + return jar.toString(); + } + + private static void prettyPrint(StringBuilder sb, String childIndent, JarInfo jar, Set visited, Map latestVersions) { + sb.append(jar.id).append(' ').append(jar.version); + JarInfo latestVersion = latestVersions.get(jar.id); + if (latestVersion != null && !Objects.equals(latestVersion.version, jar.version)) { + sb.append(" -> ").append(latestVersion.version); + sb.append('\n'); + return; + } + if (!visited.add(jar.id)) { + sb.append(" (*)"); + sb.append('\n'); + return; + } + sb.append('\n'); + + if (jar.children.isEmpty()) return; + + String midIndent = childIndent + "| "; + String lastIndent = childIndent + " "; + int lastIndex = jar.children.size() - 1; + for (int i = 0; i <= lastIndex; i++) { + sb.append(childIndent).append(i < lastIndex ? "|-- " : "\\-- "); + prettyPrint(sb, i < lastIndex ? midIndent : lastIndent, jar.children.get(i), visited, latestVersions); + } + } + + private static class JarInfo { + List children = new ArrayList<>(); + + Path path; + + String id; + String version; + + URL url() { + try { + return path.toUri().toURL(); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchInfo.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchInfo.java new file mode 100644 index 0000000..77bef98 --- /dev/null +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchInfo.java @@ -0,0 +1,21 @@ +package gg.essential.loader.stage2; + +import com.google.gson.Gson; + +import java.util.Set; + +class RelaunchInfo { + public Set loadedIds; + + private static String PROPERTY = "gg.essential.loader.stage2.relaunch-info"; + + public static RelaunchInfo get() { + String relaunchInfoJson = System.getProperty(PROPERTY); + if (relaunchInfoJson == null) return null; + return new Gson().fromJson(relaunchInfoJson, RelaunchInfo.class); + } + + public static void put(RelaunchInfo value) { + System.setProperty(PROPERTY, new Gson().toJson(value)); + } +} diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchedLoader.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchedLoader.java new file mode 100644 index 0000000..55e1c0b --- /dev/null +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchedLoader.java @@ -0,0 +1,178 @@ +package gg.essential.loader.stage2; + +import gg.essential.loader.stage2.util.MixinTweakerInjector; +import net.minecraft.launchwrapper.ITweaker; +import net.minecraft.launchwrapper.Launch; +import net.minecraft.launchwrapper.LaunchClassLoader; +import net.minecraftforge.fml.relauncher.CoreModManager; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.URI; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.jar.Attributes; +import java.util.jar.JarFile; + +public class RelaunchedLoader { + private static final Logger LOGGER = LogManager.getLogger(RelaunchedLoader.class); + + private final RelaunchInfo relaunchInfo; + private final List sourceFiles; + private boolean injected; + + RelaunchedLoader(RelaunchInfo relaunchInfo) { + this.relaunchInfo = relaunchInfo; + + sourceFiles = SourceFile.readInfos(Launch.classLoader.getSources()); + + if (relaunchInfo.loadedIds.contains("essential")) { + MixinTweakerInjector.injectMixinTweaker(true); + } + } + + public void injectIntoClassLoader(LaunchClassLoader classLoader) { + if (injected) return; + injected = true; + + if (relaunchInfo.loadedIds.contains("essential")) { + try { + Class.forName("gg.essential.api.tweaker.EssentialTweaker", false, classLoader) + .getDeclaredMethod("initialize", File.class) + .invoke(null, Launch.minecraftHome != null ? Launch.minecraftHome : new File(".")); + } catch (Throwable e) { + LOGGER.error("Failed to initialize Essential mod", e); + } + } + } + + public void initialize(ITweaker stage0Tweaker) { + String tweakerName = stage0Tweaker.getClass().getName(); + for (SourceFile sourceFile : sourceFiles) { + if (tweakerName.equals(sourceFile.tweaker) && !sourceFile.initialized) { + sourceFile.initialized = true; + try { + setupSourceFile(sourceFile); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + } + + @SuppressWarnings("unchecked") + private void setupSourceFile(SourceFile sourceFile) throws Exception { + // Forge will by default ignore a mod file if it contains a tweaker + // So we need to remove the mod from that exclusion list + Field ignoredModFile = CoreModManager.class.getDeclaredField("ignoredModFiles"); + ignoredModFile.setAccessible(true); + ((List) ignoredModFile.get(null)).remove(sourceFile.file.getName()); + + // And instead add ourselves to the mod candidate list + CoreModManager.getReparseableCoremods().add(sourceFile.file.getName()); + + // FML will not load CoreMods if it finds a tweaker, so we need to load the coremod manually if present + // We do this to reduce the friction of adding our tweaker if a mod has previously been relying on a + // coremod (cause ordinarily they would have to convert their coremod into a tweaker manually). + // Mixin takes care of this as well, so we mustn't if it will. + String coreMod = sourceFile.coreMod; + if (coreMod != null && !sourceFile.mixin) { + loadCoreMod(sourceFile.file, coreMod); + } + + // If they declared our tweaker but also want to use mixin, then we'll inject the mixin tweaker + // for them. + if (sourceFile.mixin) { + MixinTweakerInjector.injectMixinTweaker(false); + + // Mixin will only look at jar files which declare the MixinTweaker as their tweaker class, so we need + // to manually add our source files for inspection. + try { + Class MixinBootstrap = Class.forName("org.spongepowered.asm.launch.MixinBootstrap"); + Class MixinPlatformManager = Class.forName("org.spongepowered.asm.launch.platform.MixinPlatformManager"); + Object platformManager = MixinBootstrap.getDeclaredMethod("getPlatform").invoke(null); + Method addContainer; + Object arg; + try { + // Mixin 0.7 + addContainer = MixinPlatformManager.getDeclaredMethod("addContainer", URI.class); + arg = sourceFile.file.toURI(); + } catch (NoSuchMethodException ignored) { + // Mixin 0.8 + Class IContainerHandle = Class.forName("org.spongepowered.asm.launch.platform.container.IContainerHandle"); + Class ContainerHandleURI = Class.forName("org.spongepowered.asm.launch.platform.container.ContainerHandleURI"); + addContainer = MixinPlatformManager.getDeclaredMethod("addContainer", IContainerHandle); + arg = ContainerHandleURI.getDeclaredConstructor(URI.class).newInstance(sourceFile.file.toURI()); + } + addContainer.invoke(platformManager, arg); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + + @SuppressWarnings("unchecked") + private void loadCoreMod(File file, String coreMod) throws ReflectiveOperationException { + Method loadCoreMod = CoreModManager.class.getDeclaredMethod("loadCoreMod", LaunchClassLoader.class, String.class, File.class); + loadCoreMod.setAccessible(true); + ITweaker tweaker = (ITweaker) loadCoreMod.invoke(null, Launch.classLoader, coreMod, file); + ((List) Launch.blackboard.get("Tweaks")).add(tweaker); + } + + private static class SourceFile { + final File file; + final String tweaker; + final String coreMod; + final boolean mixin; + + boolean initialized; + + private SourceFile(File file, String tweaker, String coreMod, boolean mixin) { + this.file = file; + this.tweaker = tweaker; + this.coreMod = coreMod; + this.mixin = mixin; + } + + public static SourceFile readInfo(File file) throws IOException { + String tweakClass = null; + String coreMod = null; + boolean mixin = false; + try (JarFile jar = new JarFile(file)) { + if (jar.getManifest() != null) { + Attributes attributes = jar.getManifest().getMainAttributes(); + tweakClass = attributes.getValue("TweakClass"); + coreMod = attributes.getValue("FMLCorePlugin"); + mixin = attributes.getValue("MixinConfigs") != null; + } + } + return new SourceFile(file, tweakClass, coreMod, mixin); + } + + public static List readInfos(Collection urls) { + List sourceFiles = new ArrayList<>(); + for (URL url : urls) { + try { + URI uri = url.toURI(); + if (!"file".equals(uri.getScheme())) { + continue; + } + File file = new File(uri); + if (!file.exists() || !file.isFile()) { + continue; + } + sourceFiles.add(readInfo(file)); + } catch (Exception e) { + LOGGER.error("Failed to read manifest from " + url + ":", e); + } + } + return sourceFiles; + } + } +} diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/Relaunch.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/Relaunch.java index b226807..4f6fbfe 100644 --- a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/Relaunch.java +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/relaunch/Relaunch.java @@ -17,66 +17,33 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Consumer; import java.util.jar.JarFile; import java.util.jar.Manifest; +@SuppressWarnings("UrlHashCode") // all our urls are local files public class Relaunch { private static final Logger LOGGER = LogManager.getLogger(Relaunch.class); static final String FML_TWEAKER = "net.minecraftforge.fml.common.launcher.FMLTweaker"; private static final String HAPPENED_PROPERTY = "essential.loader.relaunched"; - private static final String ENABLED_PROPERTY = "essential.loader.relaunch"; - /** Whether we are currently inside a re-launch due to classpath complications. */ - public static final boolean HAPPENED = Boolean.parseBoolean(System.getProperty(HAPPENED_PROPERTY, "false")); - /** Whether we should try to re-launch in case of classpath complications. */ - public static final boolean ENABLED = !HAPPENED && Boolean.parseBoolean(System.getProperty(ENABLED_PROPERTY, "true")); - - public static boolean checkEnabled() { - if (HAPPENED) { - return false; - } - if (ENABLED) { - return true; - } + public static void relaunch(Set prioritizedUrls, Consumer> modifyArgs) { LOGGER.warn(""); LOGGER.warn(""); LOGGER.warn(""); LOGGER.warn("=================================================================================="); - LOGGER.warn("Essential can automatically attempt to fix this but this feature has been disabled"); - LOGGER.warn("because \"" + ENABLED_PROPERTY + "\" is set to false."); - LOGGER.warn(""); - LOGGER.warn("THIS WILL CAUSE ISSUES, PROCEED AT YOUR OWN RISK!"); - LOGGER.warn(""); - LOGGER.warn("Remove \"-D" + ENABLED_PROPERTY + "=false\" from JVM args to enable re-launching."); + LOGGER.warn("Re-launching to load the newer versions of mods/libraries."); LOGGER.warn("=================================================================================="); LOGGER.warn(""); LOGGER.warn(""); LOGGER.warn(""); - return false; - } - public static void relaunch(URL essentialUrl) { - LOGGER.warn(""); - LOGGER.warn(""); - LOGGER.warn(""); - LOGGER.warn("=================================================================================="); - LOGGER.warn("Attempting re-launch to load the newer version instead."); - LOGGER.warn(""); - LOGGER.warn("If AND ONLY IF you know what you are doing, have fixed the issue manually and need"); - LOGGER.warn("to suppress this behavior (did you really fix it then?), you can set the"); - LOGGER.warn("\"" + ENABLED_PROPERTY + "\" system property to false."); - LOGGER.warn("=================================================================================="); - LOGGER.warn(""); - LOGGER.warn(""); - LOGGER.warn(""); - - // Set marker so we do not end up in a loop + // Set marker for our tests System.setProperty(HAPPENED_PROPERTY, "true"); // Clean up certain global state @@ -89,10 +56,6 @@ public static void relaunch(URL essentialUrl) { // Get the classpath from the system class loader, this will have had various tweaker mods appended to it. List urls = new ArrayList<>(Arrays.asList(systemClassLoader.getURLs())); - // So we need to make sure Essential is on the classpath before any other mod - urls.remove(essentialUrl); - urls.add(0, essentialUrl); - // And because LaunchClassLoader.getSources is buggy and returns a List rather than a Set, we need to try // to remove the tweaker jars from the classpath, so we do not end up with duplicate entries in that List. // We cannot just remove everything after the first mod jar, cause there are mods like "performant" which @@ -100,14 +63,11 @@ public static void relaunch(URL essentialUrl) { // So instead, we remove anything which declares a TweakClass which has in been loaded by the // CoreModManager. Set tweakClasses = getTweakClasses(); - Iterator iterator = urls.iterator(); - iterator.next(); // skip Essential - while (iterator.hasNext()) { - URL url = iterator.next(); - if (isTweaker(url, tweakClasses)) { - iterator.remove(); - } - } + urls.removeIf(url -> isTweaker(url, tweakClasses)); + + // Finally make sure our urls are on the classpath and before any other mod + urls.removeIf(prioritizedUrls::contains); + urls.addAll(0, prioritizedUrls); LOGGER.debug("Re-launching with classpath:"); for (URL url : urls) { @@ -119,6 +79,7 @@ public static void relaunch(URL essentialUrl) { Launch.blackboard.put("gg.essential.loader.stage2.relaunchClassLoader", relaunchClassLoader); List args = new ArrayList<>(LaunchArgs.guessLaunchArgs()); + modifyArgs.accept(args); String main = args.remove(0); Class innerLaunch = Class.forName(main, false, relaunchClassLoader); From 2974be0376acc2b3d48c07ff6bfebf7fa1dd9f54 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Fri, 4 Jul 2025 13:40:24 +0200 Subject: [PATCH 19/37] mixin: Add This commit imports Essential's mixin patches which improve backwards compatibility of Mixin 0.8.x with mods expecting Mixin 0.7.x internals and with Minecraft 1.8.9 which has libs older than what Mixin 0.8 needs. This allows third-party mods to use Mixin 0.8 on 1.8.9 and 1.12.2 that way it previously was only possible to do with full Essential. --- .github/workflows/publish-mixin.yml | 26 +++ build-logic/build.gradle.kts | 2 + .../main/kotlin/essential/CompatMixinTask.kt | 180 +++++++++++++++++ integrationTest/essential.mod.json | 7 + integrationTest/launchwrapper/build.gradle | 38 ++++ .../essential/loader/stage2/Stage2Tests.java | 11 ++ mixin/build.gradle.kts | 94 +++++++++ .../gg/essential/CompatAccessTransformer.java | 17 ++ .../main/java/gg/essential/CompatMixin.java | 16 ++ .../main/java/gg/essential/CompatShadow.java | 13 ++ .../mixincompat/BundledAsmTransformer.java | 83 ++++++++ .../mixincompat/CallbackInjectorCompat.java | 80 ++++++++ .../mixincompat/GlobalPropertiesCompat.java | 36 ++++ ...calVariableDiscriminatorContextCompat.java | 22 +++ .../essential/mixincompat/LocalsCompat.java | 182 +++++++++++++++++ .../mixincompat/MixinConfigCompat.java | 17 ++ .../MixinPlatformManagerCompat.java | 20 ++ .../mixincompat/MixinProcessorCompat.java | 26 +++ .../MixinServiceLaunchWrapperCompat.java | 20 ++ .../mixincompat/MixinTransformerCompat.java | 38 ++++ .../ModifyVariableInjectorCompat.java | 40 ++++ .../mixincompat/TargetSelectorCompat.java | 23 +++ .../extensions/MixinConfigExt.java | 7 + .../mixincompat/util/MixinCompatUtils.java | 185 ++++++++++++++++++ mixin/src/main/resources/essential.mod.json | 11 ++ settings.gradle.kts | 12 ++ .../loader/stage2/RelaunchedLoader.java | 2 +- 27 files changed, 1207 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish-mixin.yml create mode 100644 build-logic/src/main/kotlin/essential/CompatMixinTask.kt create mode 100644 integrationTest/essential.mod.json create mode 100644 mixin/build.gradle.kts create mode 100644 mixin/src/main/java/gg/essential/CompatAccessTransformer.java create mode 100644 mixin/src/main/java/gg/essential/CompatMixin.java create mode 100644 mixin/src/main/java/gg/essential/CompatShadow.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/BundledAsmTransformer.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/CallbackInjectorCompat.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/GlobalPropertiesCompat.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/LocalVariableDiscriminatorContextCompat.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/LocalsCompat.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/MixinConfigCompat.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/MixinPlatformManagerCompat.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/MixinProcessorCompat.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/MixinServiceLaunchWrapperCompat.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/MixinTransformerCompat.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/ModifyVariableInjectorCompat.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/TargetSelectorCompat.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/extensions/MixinConfigExt.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/util/MixinCompatUtils.java create mode 100644 mixin/src/main/resources/essential.mod.json diff --git a/.github/workflows/publish-mixin.yml b/.github/workflows/publish-mixin.yml new file mode 100644 index 0000000..3ee3657 --- /dev/null +++ b/.github/workflows/publish-mixin.yml @@ -0,0 +1,26 @@ +name: Publish mixin + +on: + push: + tags: + - mixin/v* + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: | + 8 + 16 + 17 + + - name: Publish + run: ./gradlew :mixin:publish --stacktrace + env: + ORG_GRADLE_PROJECT_nexus_user: ${{ secrets.NEXUS_USER }} + ORG_GRADLE_PROJECT_nexus_password: ${{ secrets.NEXUS_PASSWORD }} diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index d21050d..b73ab85 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -8,4 +8,6 @@ repositories { dependencies { api("gradle.plugin.com.github.johnrengelman:shadow:7.1.2") + + implementation("org.ow2.asm:asm-commons:9.3") } diff --git a/build-logic/src/main/kotlin/essential/CompatMixinTask.kt b/build-logic/src/main/kotlin/essential/CompatMixinTask.kt new file mode 100644 index 0000000..a382ce2 --- /dev/null +++ b/build-logic/src/main/kotlin/essential/CompatMixinTask.kt @@ -0,0 +1,180 @@ +package essential + +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.Opcodes +import org.objectweb.asm.commons.ClassRemapper +import org.objectweb.asm.commons.SimpleRemapper +import org.objectweb.asm.tree.AnnotationNode +import org.objectweb.asm.tree.ClassNode +import java.nio.file.Path +import java.util.* +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +abstract class CompatMixinTask : DefaultTask() { + + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val mixinClasses: ConfigurableFileCollection + + @get:InputFile + abstract val input: RegularFileProperty + + @get:OutputFile + abstract val output: RegularFileProperty + + @TaskAction + fun apply() { + val excludedClasses = mutableSetOf( + CompatMixin, + CompatShadow, + CompatAccessTransformer, + ) + + val mixins = mutableMapOf() + for (classFile in this.mixinClasses.asFileTree.files) { + if (classFile.extension != "class") { + continue + } + + val cls = ClassNode().apply { ClassReader(classFile.readBytes()).accept(this, 0) } + val annotation = cls.invisibleAnnotations?.find { it.desc == CompatMixin.desc } ?: continue + val args = annotation.args + val target = args["value"]?.toString()?.removeSurrounding("L", ";")?.replace('/', '.') + ?: args["target"]?.toString() + ?: throw IllegalArgumentException("`@CompatMixin` annotation in $classFile is invalid.") + + if (target in mixins) { + throw IllegalArgumentException("Multiple `@CompatMixin`s for \"$target\".") + } + mixins[target] = Mixin(classFile.toPath(), cls) + excludedClasses += cls.name.replace('/', '.') + } + + val mixinToTargetMapping = mixins.entries.associate { (target, mixin) -> + mixin.node.name to target.replace('.', '/') + } + val mixinRemapper = SimpleRemapper(mixinToTargetMapping) + + ZipOutputStream(output.get().asFile.outputStream()).use { zipOut -> + ZipInputStream(input.get().asFile.inputStream()).use { zipIn -> + while (true) { + val inputEntry = zipIn.nextEntry ?: break + if (classForFile(inputEntry.name) in excludedClasses) { + continue + } + + val outputEntry = ZipEntry(inputEntry.name) + outputEntry.time = CONSTANT_TIME_FOR_ZIP_ENTRIES + zipOut.putNextEntry(outputEntry) + + val mixin = mixins.remove(classForFile(inputEntry.name)) + if (mixin != null) { + val cls = ClassNode().apply { ClassReader(zipIn).accept(this, 0) } + + merge(mixin.node, cls) + + zipOut.write(ClassWriter(0).apply { + cls.accept(ClassRemapper(this, mixinRemapper)) + }.toByteArray()) + } else { + zipIn.copyTo(zipOut) + } + + zipOut.closeEntry() + } + } + } + + if (mixins.isNotEmpty()) { + throw IllegalArgumentException(mixins.map { (cls, mixin) -> + "Failed to find target \"$cls\" for \"${mixin.source}\"" + }.joinToString("\n")) + } + } + + private fun classForFile(path: String) = path + .removeSuffix(".class") + .replace('/', '.') + .replace('\\', '.') + + private fun merge(mixin: ClassNode, cls: ClassNode) { + // Mixin targets Java 6, but we don't want to be as limited in terms of language features + cls.version = Opcodes.V1_8 + + // Process shadows first, before we add other methods (with potentially the same name) + mixin.methods.removeIf { method -> + val shadow = method.invisibleAnnotations?.find { it.desc == CompatShadow.desc } + ?: return@removeIf false + + val originalName = shadow.args["original"] + if (originalName != null) { + val originalMethod = cls.methods.find { it.name == originalName && it.desc == method.desc } + ?: throw IllegalArgumentException("Could not find original method \"$originalName\" in ${cls.name}") + originalMethod.name = method.name + } + true + } + + // Then merge the remaining methods into the target class + for (method in mixin.methods) { + if (method.name == "") { + continue + } + + if (method.name == "") { + throw UnsupportedOperationException("Class initializer merging is not implemented.") + } + + cls.methods.add(method) + } + + // Apply access transformations + val accessTransformer = mixin.invisibleAnnotations?.find { it.desc == CompatAccessTransformer.desc } + if (accessTransformer != null) { + (accessTransformer.args["add"] as? List<*>)?.forEach { + cls.access = cls.access or it as Int + } + (accessTransformer.args["remove"] as? List<*>)?.forEach { + cls.access = cls.access and (it as Int).inv() + } + } + + // Merge interfaces + for (itf in mixin.interfaces) { + if (itf !in cls.interfaces) { + cls.interfaces.add(itf) + } + } + } + + private val AnnotationNode.args get() = (values ?: emptyList()).chunked(2) { (k, v) -> k to v }.toMap() + + private val String.desc get() = "L${replace('.', '/')};" + + private data class Mixin( + val source: Path, + val node: ClassNode, + ) + + companion object { + const val CompatMixin = "gg.essential.CompatMixin" + const val CompatShadow = "gg.essential.CompatShadow" + const val CompatAccessTransformer = "gg.essential.CompatAccessTransformer" + + // A safe, constant value for creating consistent zip entries + // From: https://github.com/gradle/gradle/blob/d6c7fd470449a59fc57a26b4ebc0ad83c64af50a/subprojects/core/src/main/java/org/gradle/api/internal/file/archive/ZipCopyAction.java#L42-L57 + private val CONSTANT_TIME_FOR_ZIP_ENTRIES = GregorianCalendar(1980, Calendar.FEBRUARY, 1, 0, 0, 0).timeInMillis + } +} \ No newline at end of file diff --git a/integrationTest/essential.mod.json b/integrationTest/essential.mod.json new file mode 100644 index 0000000..5a08484 --- /dev/null +++ b/integrationTest/essential.mod.json @@ -0,0 +1,7 @@ +{ + "id": "${id}", + "version": "${version}", + "jars": [ + ${jars.get()} + ] +} \ No newline at end of file diff --git a/integrationTest/launchwrapper/build.gradle b/integrationTest/launchwrapper/build.gradle index c9c42e6..d3d8312 100644 --- a/integrationTest/launchwrapper/build.gradle +++ b/integrationTest/launchwrapper/build.gradle @@ -196,6 +196,42 @@ tasks.register("example2ModEssentialTweakerWithMixin08Jar", Jar, configureExampl tasks.register("exampleModMixinTweakerWithMixin08Jar", Jar, configureExampleModJar("org.spongepowered.asm.launch.MixinTweaker", configurations.mixin08)) tasks.register("example2ModMixinTweakerWithMixin08Jar", Jar, configureExample2ModJar("org.spongepowered.asm.launch.MixinTweaker", configurations.mixin08)) +tasks.register("exampleModWithOurMixinJar", Jar) { + def configure = configureExampleModJar("com.example.mod.tweaker.ExampleModTweaker") + configure.delegate = delegate + configure(it) + + def jij = configurations.detachedConfiguration( + dependencies.create(project(":mixin")), + ) + dependsOn(jij) + from(jij.files) { + into("META-INF/jars") + } + def expansions = [ + "id": "examplemod", + "version": "1.0.0", + "jars": provider { + jij.resolvedConfiguration.resolvedArtifacts.collect { artifact -> + def id = artifact.moduleVersion.id + """ + { + "id": "${id.group}:${id.name}", + "version": "${id.version}", + "file": "META-INF/jars/${artifact.file.name}" + } + """.stripIndent() + }.join(",\n") + }, + ] + inputs.property("expansions", expansions) + from(file("../essential.mod.json")) { + expand(expansions) + } + + manifest.attributes "MixinConfigs": "examplemod.mixins.json,examplemod.init.mixins.json" +} + tasks.register("exampleRelocatedModJar", com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { def configure = configureExampleModJar("com.example.mod.tweaker.ExampleModTweaker") configure.delegate = delegate @@ -454,6 +490,8 @@ tasks.register("setupDownloadsApi", Sync) { mod(tasks.exampleModMixinTweakerWithMixin08Jar.archiveFile, "example:mod", "mixin-tweaker-with-mixin-08") mod(tasks.example2ModMixinTweakerWithMixin08Jar.archiveFile, "example:mod2", "mixin-tweaker-with-mixin-08") + mod(tasks.exampleModWithOurMixinJar.archiveFile, "example:mod", "stable-with-our-mixin") + mod(tasks.exampleRelocatedModJar.archiveFile, "example:mod", "relocated") (1..5).each { i -> mod(tasks.named("exampleBundledModJar$i").map { it.archiveFile }, "example:mod", "bundled-$i") diff --git a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2Tests.java b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2Tests.java index f65624c..cb7a087 100644 --- a/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2Tests.java +++ b/integrationTest/launchwrapper/src/main/java/gg/essential/loader/stage2/Stage2Tests.java @@ -119,4 +119,15 @@ public void testUpdateRequiringNewerStage2(Installation installation) throws Exc assertEquals("4", secondLaunch.getProperty("essential.stage2.version")); assertEquals("4", secondLaunch.getProperty("essential.version")); } + + @Test + public void testOurMixin(Installation installation) throws Exception { + installation.addExampleMod("stable-with-our-mixin"); + + IsolatedLaunch isolatedLaunch = installation.launchFML(); + + installation.assertModLaunched(isolatedLaunch); + assertTrue(isolatedLaunch.getModLoadState("mixin"), "Example mixin plugin ran"); + assertTrue(isolatedLaunch.getModLoadState("mixinInitPhase"), "Example INIT-phase mixin applied"); + } } diff --git a/mixin/build.gradle.kts b/mixin/build.gradle.kts new file mode 100644 index 0000000..f2f8d4b --- /dev/null +++ b/mixin/build.gradle.kts @@ -0,0 +1,94 @@ +import essential.CompatMixinTask +import gg.essential.gradle.util.prebundle +import gg.essential.gradle.util.RelocationTransform.Companion.registerRelocationAttribute + +plugins { + id("java-library") + id("gg.essential.defaults") version "0.6.7" apply false // for the relocation utils + id("gg.essential.defaults.maven-publish") version "0.6.7" + id("essential.build-logic") +} + +val patchesVersion = "0.0.0" +val mixinVersion = "0.8.4" +val asmVersion = "5.2" + +version = "$patchesVersion+mixin.$mixinVersion" +java.toolchain.languageVersion.set(JavaLanguageVersion.of(8)) + +repositories { + mavenCentral() + maven("https://repo.spongepowered.org/repository/maven-releases/") + maven("https://libraries.minecraft.net") +} + +val relocated = registerRelocationAttribute("essential-guava21-relocated") { + relocate("com.google.common", "gg.essential.lib.guava21") + relocate("com.google.thirdparty.publicsuffix", "gg.essential.lib.guava21.publicsuffix") +} + +val fatMixinContent by configurations.creating { + attributes { attribute(relocated, true) } +} +val fatMixin by configurations.creating +configurations.api { extendsFrom(fatMixin) } +val asm by configurations.creating +configurations.compileOnly { extendsFrom(asm) } + +dependencies { + fatMixinContent("org.spongepowered:mixin:$mixinVersion") + // this is usually provided by MC but 1.8.9's is too old, so we need to bundle (and relocate) our own + fatMixinContent("com.google.guava:guava:21.0") + + // Our special mixin which has its Guava 21 dependency relocated, so it can run alongside Guava 17 + fatMixin(prebundle(fatMixinContent)) + // Mixin needs at least asm 5.2 but older versions provide only 5.0.3 + asm("org.ow2.asm:asm-debug-all:$asmVersion") + + compileOnly("net.minecraft:launchwrapper:1.12") +} + +tasks.processResources { + val expansions = mapOf( + "mixinVersion" to mixinVersion, + "patchesVersion" to patchesVersion, + "asmVersion" to asmVersion, + ) + inputs.property("expansions", expansions) + filesMatching("essential.mod.json") { + expand(expansions) + } +} + +val patchedJar by tasks.registering(CompatMixinTask::class) { + mixinClasses.from(sourceSets.main.map { it.output }) + input.set(fatMixin.files.single()) + output.set(buildDir.resolve("patched.jar")) +} + +tasks.jar { + from(patchedJar.flatMap { it.output }.map { zipTree(it) }) { + // Signatures were invalidated by patching + exclude("META-INF/*.RSA", "META-INF/*.SF", "META-INF/*.DSA") + // Legacy Forge chokes on these (and they are useless for it anyway cause it only supports Java 8) + exclude("**/module-info.class") + exclude("META-INF/versions/9/**") + // Same with these coming from Mixin for ModLauncher9 support + exclude("org/spongepowered/asm/launch/MixinLaunchPlugin.class") + exclude("org/spongepowered/asm/launch/MixinTransformationService.class") + exclude("org/spongepowered/asm/launch/platform/container/ContainerHandleModLauncherEx*") + } + + dependsOn(asm) + from({ asm.files.single() }) { + into("META-INF/jars") + } +} + +publishing { + publications { + named("maven") { + artifactId = "mixin" + } + } +} diff --git a/mixin/src/main/java/gg/essential/CompatAccessTransformer.java b/mixin/src/main/java/gg/essential/CompatAccessTransformer.java new file mode 100644 index 0000000..78656ff --- /dev/null +++ b/mixin/src/main/java/gg/essential/CompatAccessTransformer.java @@ -0,0 +1,17 @@ +package gg.essential; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +public @interface CompatAccessTransformer { + /** + * Access flags to be added to the target + */ + int[] add() default {}; + + /** + * Access flags to be removed from the target + */ + int[] remove() default {}; +} diff --git a/mixin/src/main/java/gg/essential/CompatMixin.java b/mixin/src/main/java/gg/essential/CompatMixin.java new file mode 100644 index 0000000..d0156c2 --- /dev/null +++ b/mixin/src/main/java/gg/essential/CompatMixin.java @@ -0,0 +1,16 @@ +package gg.essential; + +import org.spongepowered.asm.mixin.Mixin; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Target; + +/** + * Like {@link Mixin}, but at build time, and no injectors. But it allows public static methods! + */ +@Target(ElementType.TYPE) +public @interface CompatMixin { + Class value() default Void.class; + + String target() default ""; +} diff --git a/mixin/src/main/java/gg/essential/CompatShadow.java b/mixin/src/main/java/gg/essential/CompatShadow.java new file mode 100644 index 0000000..ebf3cc7 --- /dev/null +++ b/mixin/src/main/java/gg/essential/CompatShadow.java @@ -0,0 +1,13 @@ +package gg.essential; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.FIELD}) +public @interface CompatShadow { + /** + * If set, specifies the name of the original method and renames it to the name of the shadow method. + * This allows you to overwrite the original method but still call its content. + */ + String original() default ""; +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/BundledAsmTransformer.java b/mixin/src/main/java/gg/essential/mixincompat/BundledAsmTransformer.java new file mode 100644 index 0000000..ac0d6a0 --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/BundledAsmTransformer.java @@ -0,0 +1,83 @@ +package gg.essential.mixincompat; + +import gg.essential.lib.guava21.primitives.Bytes; +import net.minecraft.launchwrapper.IClassTransformer; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.objectweb.asm.*; +import org.objectweb.asm.commons.ClassRemapper; +import org.objectweb.asm.commons.Remapper; + +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.Set; + +/** + * Converts any usages of the old (relocated) mixin asm classes (as used by Mixin 0.7) to the original (non-relocated) + * class names (as used by Mixin 0.8). This vastly improves compatibility of 0.8 with mods compiled against 0.7. + */ +public class BundledAsmTransformer implements IClassTransformer { + private static final Logger LOGGER = LogManager.getLogger(BundledAsmTransformer.class); + + private static final String originalPackage = "org/objectweb/asm/"; + private static final String legacyPackage = "org/spongepowered/asm/lib/"; + + private static final byte[] legacyPackageBytes = legacyPackage.getBytes(StandardCharsets.UTF_8); + + private static final Remapper remapper = new Remapper() { + @Override + public String map(String typeName) { + if (typeName.startsWith(legacyPackage)) { + return originalPackage + typeName.substring(legacyPackage.length()); + } else { + return typeName; + } + } + }; + + @Override + public byte[] transform(String name, String transformedName, byte[] bytes) { + if (bytes == null || Bytes.indexOf(bytes, legacyPackageBytes) == -1) { + return bytes; + } + + LOGGER.debug("Found reference to legacy mixin asm in \"{}\", remapping to upstream package..", name); + + ClassReader classReader = new ClassReader(bytes); + ClassWriter classWriter = new ClassWriter(0); + try { + classReader.accept(new ClassRemapper(new DuplicateDetectingClassWriter(classWriter), remapper), 0); + return classWriter.toByteArray(); + } catch (DuplicateMethodException e) { + LOGGER.debug("Aborting transformation of \"" + name + '"', e); + return bytes; + } + } + + // Some mods have Mixin plugins with methods for both renamed and not renamed ASM. We want to avoid causing crashes by transforming them. + private static class DuplicateDetectingClassWriter extends ClassVisitor { + private final Set detectedMethods = new HashSet<>(); + + public DuplicateDetectingClassWriter(ClassWriter writer) { + super(Opcodes.ASM5); + this.cv = writer; + } + + @Override + public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { + String identifier = name + desc; + if (detectedMethods.contains(identifier)) { + throw new DuplicateMethodException(identifier); + } + detectedMethods.add(identifier); + + return super.visitMethod(access, name, desc, signature, exceptions); + } + } + + private static class DuplicateMethodException extends RuntimeException { + public DuplicateMethodException(String message) { + super(message); + } + } +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/CallbackInjectorCompat.java b/mixin/src/main/java/gg/essential/mixincompat/CallbackInjectorCompat.java new file mode 100644 index 0000000..64fae0a --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/CallbackInjectorCompat.java @@ -0,0 +1,80 @@ +package gg.essential.mixincompat; + +import gg.essential.CompatMixin; +import gg.essential.CompatShadow; +import gg.essential.mixincompat.util.MixinCompatUtils; +import org.objectweb.asm.tree.LocalVariableNode; +import org.objectweb.asm.tree.MethodNode; +import org.spongepowered.asm.mixin.injection.callback.CallbackInjector; +import org.spongepowered.asm.mixin.injection.callback.LocalCapture; +import org.spongepowered.asm.mixin.injection.code.Injector; +import org.spongepowered.asm.mixin.injection.struct.InjectionInfo; +import org.spongepowered.asm.mixin.injection.struct.InjectionNodes; +import org.spongepowered.asm.mixin.injection.struct.Target; +import org.spongepowered.asm.util.Locals; + +import java.util.Locale; + +@CompatMixin(CallbackInjector.class) +public abstract class CallbackInjectorCompat extends Injector { + public CallbackInjectorCompat(InjectionInfo info, String annotationType) { + super(info, annotationType); + } + + @CompatShadow + private LocalCapture localCapture; + + @CompatShadow(original = "preInject") + protected void preInject$old(Target target, InjectionNodes.InjectionNode node) { throw new LinkageError(); } + + @CompatShadow(original = "inject") + protected void inject$old(Target target, InjectionNodes.InjectionNode node) { throw new LinkageError(); } + + @CompatShadow + private void inject(final CallbackBridge callback) { throw new LinkageError(); } + + @Override + protected void preInject(Target target, InjectionNodes.InjectionNode node) { + MixinCompatUtils.withCurrentMixinInfo(this.info.getMixin().getMixin(), () -> { + if ((isCaptureLocals(this.localCapture) || isPrintLocals(this.localCapture)) && !node.hasDecoration(getDecorationKey())) { + LocalVariableNode[] locals = Locals.getLocalsAt(this.classNode, target.method, node.getCurrentTarget()); + + for (int j = 0; j < locals.length; ++j) { + if (locals[j] != null && locals[j].desc != null && locals[j].desc.startsWith("Lorg/spongepowered/asm/mixin/injection/callback/")) { + locals[j] = null; + } + } + + node.decorate(getDecorationKey(), locals); + } + }); + } + + @Override + protected void inject(Target target, InjectionNodes.InjectionNode node) { + MixinCompatUtils.withCurrentMixinInfo(this.info.getMixin().getMixin(), () -> { + LocalVariableNode[] locals = node.getDecoration(getDecorationKey()); + this.inject(new CallbackBridge(this.methodNode, target, node, locals, isCaptureLocals(this.localCapture))); + }); + } + + private String getDecorationKey() { + return String.format(Locale.ROOT, "locals(useNewAlgorithm=%s)", MixinCompatUtils.canUseNewLocalsAlgorithm()); + } + + private boolean isCaptureLocals(LocalCapture localCapture) { + return localCapture == LocalCapture.CAPTURE_FAILHARD || localCapture == LocalCapture.CAPTURE_FAILSOFT || localCapture == LocalCapture.CAPTURE_FAILEXCEPTION; + } + + private boolean isPrintLocals(LocalCapture localCapture) { + return localCapture == LocalCapture.PRINT; + } + + // Bridge class. Will not affect the target but can be used by the outer CompatMixin and will be replaced by the real one during application. + @SuppressWarnings("InnerClassMayBeStatic") + @CompatMixin(target = "org.spongepowered.asm.mixin.injection.callback.CallbackInjector$Callback") + private class CallbackBridge { + CallbackBridge(MethodNode handler, Target target, InjectionNodes.InjectionNode node, LocalVariableNode[] locals, boolean captureLocals) { + } + } +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/GlobalPropertiesCompat.java b/mixin/src/main/java/gg/essential/mixincompat/GlobalPropertiesCompat.java new file mode 100644 index 0000000..af9b018 --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/GlobalPropertiesCompat.java @@ -0,0 +1,36 @@ +package gg.essential.mixincompat; + +import gg.essential.CompatMixin; +import gg.essential.CompatShadow; +import org.spongepowered.asm.launch.GlobalProperties; + +@CompatMixin(GlobalProperties.class) +public abstract class GlobalPropertiesCompat { + @CompatShadow + public static T get(GlobalProperties.Keys key) { throw new LinkageError(); } + + @CompatShadow + public static T get(GlobalProperties.Keys key, T defaultValue) { throw new LinkageError(); } + + @CompatShadow + public static String getString(GlobalProperties.Keys key, String defaultValue) { throw new LinkageError(); } + + @CompatShadow + public static void put(GlobalProperties.Keys key, Object value) { throw new LinkageError(); } + + public static Object get(String key) { + return get(GlobalProperties.Keys.of(key)); + } + + public static Object get(String key, Object defaultValue) { + return get(GlobalProperties.Keys.of(key), defaultValue); + } + + public static String getString(String key, String defaultValue) { + return getString(GlobalProperties.Keys.of(key), defaultValue); + } + + public static void put(String key, Object value) { + put(GlobalProperties.Keys.of(key), value); + } +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/LocalVariableDiscriminatorContextCompat.java b/mixin/src/main/java/gg/essential/mixincompat/LocalVariableDiscriminatorContextCompat.java new file mode 100644 index 0000000..3386cf7 --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/LocalVariableDiscriminatorContextCompat.java @@ -0,0 +1,22 @@ +package gg.essential.mixincompat; + +import gg.essential.CompatMixin; +import gg.essential.CompatShadow; +import gg.essential.mixincompat.util.MixinCompatUtils; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.spongepowered.asm.mixin.injection.modify.LocalVariableDiscriminator; +import org.spongepowered.asm.mixin.injection.struct.InjectionInfo; +import org.spongepowered.asm.mixin.injection.struct.Target; + +@CompatMixin(LocalVariableDiscriminator.Context.class) +public class LocalVariableDiscriminatorContextCompat { + @CompatShadow + InjectionInfo info; + + @CompatShadow(original = "initLocals") + private LocalVariableDiscriminator.Context.Local[] initLocals$original(Target target, boolean argsOnly, AbstractInsnNode node) { throw new LinkageError(); } + + private LocalVariableDiscriminator.Context.Local[] initLocals(Target target, boolean argsOnly, AbstractInsnNode node) { + return MixinCompatUtils.withCurrentMixinInfo(this.info.getMixin().getMixin(), () -> initLocals$original(target, argsOnly, node)); + } +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/LocalsCompat.java b/mixin/src/main/java/gg/essential/mixincompat/LocalsCompat.java new file mode 100644 index 0000000..53dfad1 --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/LocalsCompat.java @@ -0,0 +1,182 @@ +package gg.essential.mixincompat; + +import gg.essential.CompatMixin; +import gg.essential.CompatShadow; +import gg.essential.mixincompat.util.MixinCompatUtils; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.*; +import org.spongepowered.asm.mixin.transformer.ClassInfo; +import org.spongepowered.asm.util.Locals; +import org.spongepowered.asm.util.throwables.LVTGeneratorError; + +import java.util.Iterator; +import java.util.List; + +@CompatMixin(Locals.class) +public class LocalsCompat { + @CompatShadow + private static AbstractInsnNode nextNode(InsnList insns, AbstractInsnNode insn) { throw new LinkageError(); } + + @CompatShadow + private static int getAdjustedFrameSize(int currentSize, int type, int size, int initialFrameSize) { throw new LinkageError(); } + + @CompatShadow(original = "getLocalsAt") + public static LocalVariableNode[] getLocalsAt_0_8_4(ClassNode classNode, MethodNode method, AbstractInsnNode node) { throw new LinkageError(); } + + public static LocalVariableNode[] getLocalsAt(ClassNode classNode, MethodNode method, AbstractInsnNode node) { + if (MixinCompatUtils.canUseNewLocalsAlgorithm()) { + return LocalsCompat.getLocalsAt_0_8_4(classNode, method, node); + } + return LocalsCompat.getLocalsAt_0_8_2(classNode, method, node); + } + + // The old algorithm used on mods which were built against versions 0.8.3 and below. + private static LocalVariableNode[] getLocalsAt_0_8_2(ClassNode classNode, MethodNode method, AbstractInsnNode node) { + for (int i = 0; i < 3 && (node instanceof LabelNode || node instanceof LineNumberNode); i++) { + node = LocalsCompat.nextNode(method.instructions, node); + } + + ClassInfo classInfo = ClassInfo.forName(classNode.name); + if (classInfo == null) { + throw new LVTGeneratorError("Could not load class metadata for " + classNode.name + " generating LVT for " + method.name); + } + ClassInfo.Method methodInfo = classInfo.findMethod(method, method.access | ClassInfo.INCLUDE_INITIALISERS); + if (methodInfo == null) { + throw new LVTGeneratorError("Could not locate method metadata for " + method.name + " generating LVT in " + classNode.name); + } + List frames = methodInfo.getFrames(); + + LocalVariableNode[] frame = new LocalVariableNode[method.maxLocals]; + int local = 0, index = 0; + + // Initialise implicit "this" reference in non-static methods + if ((method.access & Opcodes.ACC_STATIC) == 0) { + frame[local++] = new LocalVariableNode("this", Type.getObjectType(classNode.name).toString(), null, null, null, 0); + } + + // Initialise method arguments + for (Type argType : Type.getArgumentTypes(method.desc)) { + frame[local] = new LocalVariableNode("arg" + index++, argType.toString(), null, null, null, local); + local += argType.getSize(); + } + + int initialFrameSize = local; + int frameSize = local; + int frameIndex = -1; + int lastFrameSize = local; + VarInsnNode storeInsn = null; + + for (Iterator iter = method.instructions.iterator(); iter.hasNext();) { + AbstractInsnNode insn = iter.next(); + if (storeInsn != null) { + frame[storeInsn.var] = Locals.getLocalVariableAt(classNode, method, insn, storeInsn.var); + storeInsn = null; + } + + handleFrame: if (insn instanceof FrameNode) { + frameIndex++; + FrameNode frameNode = (FrameNode)insn; + if (frameNode.type == Opcodes.F_SAME || frameNode.type == Opcodes.F_SAME1) { + break handleFrame; + } + + ClassInfo.FrameData frameData = frameIndex < frames.size() ? frames.get(frameIndex) : null; + + if (frameData != null) { + if (frameData.type == Opcodes.F_FULL) { + frameSize = Math.min(frameSize, frameData.locals); + lastFrameSize = frameSize; + } else { + frameSize = LocalsCompat.getAdjustedFrameSize(frameSize, frameData); + } + } else { + frameSize = LocalsCompat.getAdjustedFrameSize(frameSize, frameNode); + } + + if (frameNode.type == Opcodes.F_CHOP) { + for (int framePos = frameSize; framePos < frame.length; framePos++) { + frame[framePos] = null; + } + lastFrameSize = frameSize; + break handleFrame; + } + + int framePos = frameNode.type == Opcodes.F_APPEND ? lastFrameSize : 0; + lastFrameSize = frameSize; + + // localPos tracks the location in the frame node's locals list, which doesn't leave space for TOP entries + for (int localPos = 0; framePos < frame.length; framePos++, localPos++) { + // Get the local at the current position in the FrameNode's locals list + final Object localType = (localPos < frameNode.local.size()) ? frameNode.local.get(localPos) : null; + + if (localType instanceof String) { // String refers to a reference type + frame[framePos] = Locals.getLocalVariableAt(classNode, method, insn, framePos); + } else if (localType instanceof Integer) { // Integer refers to a primitive type or other marker + boolean isMarkerType = localType == Opcodes.UNINITIALIZED_THIS || localType == Opcodes.NULL; + boolean is32bitValue = localType == Opcodes.INTEGER || localType == Opcodes.FLOAT; + boolean is64bitValue = localType == Opcodes.DOUBLE || localType == Opcodes.LONG; + if (localType == Opcodes.TOP) { + // Do nothing, explicit TOP entries are pretty much always bogus, and real ones are handled below + } else if (isMarkerType) { + frame[framePos] = null; + } else if (is32bitValue || is64bitValue) { + frame[framePos] = Locals.getLocalVariableAt(classNode, method, insn, framePos); + + if (is64bitValue) { + framePos++; + frame[framePos] = null; // TOP + } + } else { + throw new LVTGeneratorError("Unrecognised locals opcode " + localType + " in locals array at position " + localPos + + " in " + classNode.name + "." + method.name + method.desc); + } + } else if (localType == null) { + if (framePos >= initialFrameSize && framePos >= frameSize && frameSize > 0) { + frame[framePos] = null; + } + } else if (localType instanceof LabelNode) { + // Uninitialised + } else { + throw new LVTGeneratorError("Invalid value " + localType + " in locals array at position " + localPos + + " in " + classNode.name + "." + method.name + method.desc); + } + } + } else if (insn instanceof VarInsnNode) { + VarInsnNode varNode = (VarInsnNode) insn; + boolean isLoad = insn.getOpcode() >= Opcodes.ILOAD && insn.getOpcode() <= Opcodes.SALOAD; + if (isLoad) { + frame[varNode.var] = Locals.getLocalVariableAt(classNode, method, insn, varNode.var); + } else { + // Update the LVT for the opcode AFTER this one, since we always want to know + // the frame state BEFORE the *current* instruction to match the contract of + // injection points + storeInsn = varNode; + } + } + + if (insn == node) { + break; + } + } + + // Null out any "unknown" locals + for (int l = 0; l < frame.length; l++) { + if (frame[l] != null && frame[l].desc == null) { + frame[l] = null; + } + } + + return frame; + } + + // No longer present, copied from 0.8.2 with [initialFrameSize] as 0 to preserve logic. + private static int getAdjustedFrameSize(int currentSize, FrameNode frameNode) { + return LocalsCompat.getAdjustedFrameSize(currentSize, frameNode.type, Locals.computeFrameSize(frameNode, 0), 0); + } + + // No longer present, copied from 0.8.2 with [initialFrameSize] as 0 to preserve logic. + private static int getAdjustedFrameSize(int currentSize, ClassInfo.FrameData frameData) { + return LocalsCompat.getAdjustedFrameSize(currentSize, frameData.type, frameData.size, 0); + } +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/MixinConfigCompat.java b/mixin/src/main/java/gg/essential/mixincompat/MixinConfigCompat.java new file mode 100644 index 0000000..e052161 --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/MixinConfigCompat.java @@ -0,0 +1,17 @@ +package gg.essential.mixincompat; + +import gg.essential.CompatMixin; +import gg.essential.CompatShadow; +import gg.essential.mixincompat.extensions.MixinConfigExt; +import org.spongepowered.asm.util.VersionNumber; + +@CompatMixin(target = "org.spongepowered.asm.mixin.transformer.MixinConfig") +public class MixinConfigCompat implements MixinConfigExt { + @CompatShadow + private String version; + + @Override + public VersionNumber getMinVersion() { + return VersionNumber.parse(this.version); + } +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/MixinPlatformManagerCompat.java b/mixin/src/main/java/gg/essential/mixincompat/MixinPlatformManagerCompat.java new file mode 100644 index 0000000..1928d60 --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/MixinPlatformManagerCompat.java @@ -0,0 +1,20 @@ +package gg.essential.mixincompat; + +import gg.essential.CompatMixin; +import gg.essential.CompatShadow; +import org.spongepowered.asm.launch.platform.MixinContainer; +import org.spongepowered.asm.launch.platform.MixinPlatformManager; +import org.spongepowered.asm.launch.platform.container.ContainerHandleURI; +import org.spongepowered.asm.launch.platform.container.IContainerHandle; + +import java.net.URI; + +@CompatMixin(MixinPlatformManager.class) +public abstract class MixinPlatformManagerCompat { + @CompatShadow + public abstract MixinContainer addContainer(IContainerHandle handle); + + public final MixinContainer addContainer(URI uri) { + return addContainer(new ContainerHandleURI(uri)); + } +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/MixinProcessorCompat.java b/mixin/src/main/java/gg/essential/mixincompat/MixinProcessorCompat.java new file mode 100644 index 0000000..0c592ec --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/MixinProcessorCompat.java @@ -0,0 +1,26 @@ +package gg.essential.mixincompat; + +import gg.essential.CompatAccessTransformer; +import gg.essential.CompatMixin; +import gg.essential.CompatShadow; +import org.objectweb.asm.Opcodes; +import org.spongepowered.asm.mixin.MixinEnvironment; +import org.spongepowered.asm.mixin.transformer.ext.Extensions; + + +@CompatAccessTransformer(add = {Opcodes.ACC_PUBLIC}) +@CompatMixin(target = "org.spongepowered.asm.mixin.transformer.MixinProcessor") +public class MixinProcessorCompat { + @CompatShadow + private Extensions extensions; + + @CompatShadow + private int prepareConfigs(MixinEnvironment environment, Extensions extensions) { + throw new LinkageError(); + } + + // Used via reflection by quite a few mods on 1.12.2, e.g. VanillaFix + private int prepareConfigs(MixinEnvironment environment) { + return prepareConfigs(environment, this.extensions); + } +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/MixinServiceLaunchWrapperCompat.java b/mixin/src/main/java/gg/essential/mixincompat/MixinServiceLaunchWrapperCompat.java new file mode 100644 index 0000000..7a2dce2 --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/MixinServiceLaunchWrapperCompat.java @@ -0,0 +1,20 @@ +package gg.essential.mixincompat; + +import gg.essential.CompatMixin; +import gg.essential.CompatShadow; +import net.minecraft.launchwrapper.Launch; +import org.spongepowered.asm.service.mojang.MixinServiceLaunchWrapper; + +@CompatMixin(MixinServiceLaunchWrapper.class) +public abstract class MixinServiceLaunchWrapperCompat { + @CompatShadow(original = "prepare") + public abstract void prepare$org(); + + public void prepare() { + prepare$org(); + + // Initialize our 0.7 asm compat transformer (see BundledAsmTransformer class) + Launch.classLoader.addTransformerExclusion("gg.essential.lib.guava21."); + Launch.classLoader.registerTransformer(BundledAsmTransformer.class.getName()); + } +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/MixinTransformerCompat.java b/mixin/src/main/java/gg/essential/mixincompat/MixinTransformerCompat.java new file mode 100644 index 0000000..b22bcd3 --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/MixinTransformerCompat.java @@ -0,0 +1,38 @@ +package gg.essential.mixincompat; + +import gg.essential.CompatAccessTransformer; +import gg.essential.CompatMixin; +import org.objectweb.asm.Opcodes; +import org.spongepowered.asm.mixin.MixinEnvironment; + +import java.lang.reflect.Method; + +// Accessed from fml.common.Loader by Inject from some 1.12.2 mods, e.g. mixin 0.7 versions of VanillaFix and JEID +@CompatAccessTransformer(add = {Opcodes.ACC_PUBLIC}) +@CompatMixin(target = "org.spongepowered.asm.mixin.transformer.MixinTransformer") +public abstract class MixinTransformerCompat { + // + // These two are called via reflection by quite a few old (mixin 0.7) mods on 1.12.2, e.g. VanillaFix and JEID + // They were moved to MixinProcessor in Mixin 0.8, so we'll forward those calls. + // + + private void selectConfigs(MixinEnvironment environment) { + invokeOnProcessor("selectConfigs", environment); + } + + private int prepareConfigs(MixinEnvironment environment) { + return (int) invokeOnProcessor("prepareConfigs", environment); + } + + private Object invokeOnProcessor(String methodName, MixinEnvironment environment) { + // It's all package private, so reflection it is + try { + Object processor = getClass().getDeclaredField("processor").get(this); + Method method = processor.getClass().getDeclaredMethod(methodName, MixinEnvironment.class); + method.setAccessible(true); + return method.invoke(processor, environment); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/ModifyVariableInjectorCompat.java b/mixin/src/main/java/gg/essential/mixincompat/ModifyVariableInjectorCompat.java new file mode 100644 index 0000000..8cd4098 --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/ModifyVariableInjectorCompat.java @@ -0,0 +1,40 @@ +package gg.essential.mixincompat; + +import gg.essential.CompatMixin; +import gg.essential.CompatShadow; +import gg.essential.mixincompat.util.MixinCompatUtils; +import org.spongepowered.asm.mixin.injection.code.Injector; +import org.spongepowered.asm.mixin.injection.modify.LocalVariableDiscriminator; +import org.spongepowered.asm.mixin.injection.modify.ModifyVariableInjector; +import org.spongepowered.asm.mixin.injection.struct.InjectionInfo; +import org.spongepowered.asm.mixin.injection.struct.InjectionNodes; +import org.spongepowered.asm.mixin.injection.struct.Target; + +import java.util.Locale; + +@CompatMixin(ModifyVariableInjector.class) +public abstract class ModifyVariableInjectorCompat extends Injector { + public ModifyVariableInjectorCompat(InjectionInfo info, String annotationType) { + super(info, annotationType); + } + + @CompatShadow + private LocalVariableDiscriminator discriminator; + + @CompatShadow(original = "getTargetNodeKey") + protected abstract String getTargetNodeKey$old(Target target, InjectionNodes.InjectionNode node); + + protected String getTargetNodeKey(Target target, InjectionNodes.InjectionNode node) { + return MixinCompatUtils.withCurrentMixinInfo( + this.info.getMixin().getMixin(), + () -> String.format( + Locale.ROOT, + "localcontext(%s,%s,#%s,useNewAlgorithm=%s)", + this.returnType, + this.discriminator.isArgsOnly() ? "argsOnly" : "fullFrame", + node.getId(), + MixinCompatUtils.canUseNewLocalsAlgorithm() + ) + ); + } +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/TargetSelectorCompat.java b/mixin/src/main/java/gg/essential/mixincompat/TargetSelectorCompat.java new file mode 100644 index 0000000..07744fc --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/TargetSelectorCompat.java @@ -0,0 +1,23 @@ +package gg.essential.mixincompat; + +import gg.essential.CompatMixin; +import gg.essential.CompatShadow; +import org.spongepowered.asm.mixin.injection.selectors.ISelectorContext; +import org.spongepowered.asm.mixin.injection.selectors.ITargetSelector; +import org.spongepowered.asm.mixin.injection.selectors.TargetSelector; +import org.spongepowered.asm.mixin.injection.struct.MemberInfo; +import org.spongepowered.asm.util.Quantifier; + +@CompatMixin(TargetSelector.class) +public class TargetSelectorCompat { + @CompatShadow(original = "parse") + public static ITargetSelector parse$original(String string, ISelectorContext context) { throw new LinkageError(); } + + // Mixin 0.7 supported target-less selectors just fine, and this patch brings that functionality back to 0.8+ + public static ITargetSelector parse(String string, ISelectorContext context) { + if (string == null) { + return new MemberInfo(null, Quantifier.DEFAULT); + } + return parse$original(string, context); + } +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/extensions/MixinConfigExt.java b/mixin/src/main/java/gg/essential/mixincompat/extensions/MixinConfigExt.java new file mode 100644 index 0000000..ed21160 --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/extensions/MixinConfigExt.java @@ -0,0 +1,7 @@ +package gg.essential.mixincompat.extensions; + +import org.spongepowered.asm.util.VersionNumber; + +public interface MixinConfigExt { + VersionNumber getMinVersion(); +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/util/MixinCompatUtils.java b/mixin/src/main/java/gg/essential/mixincompat/util/MixinCompatUtils.java new file mode 100644 index 0000000..1f550d4 --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/util/MixinCompatUtils.java @@ -0,0 +1,185 @@ +package gg.essential.mixincompat.util; + +import gg.essential.mixincompat.extensions.MixinConfigExt; +import net.minecraft.launchwrapper.Launch; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldNode; +import org.spongepowered.asm.launch.MixinBootstrap; +import org.spongepowered.asm.mixin.MixinEnvironment; +import org.spongepowered.asm.mixin.extensibility.IMixinConfig; +import org.spongepowered.asm.mixin.extensibility.IMixinInfo; +import org.spongepowered.asm.util.VersionNumber; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Paths; +import java.util.Set; +import java.util.function.Supplier; +import java.util.jar.JarFile; +import java.util.zip.ZipEntry; + +public class MixinCompatUtils { + private static final Logger LOGGER = LogManager.getLogger(); + private static final String STAGE0_TWEAKERS_KEY = "essential.loader.stage2.stage0tweakers"; + private static final String MIXIN_BOOTSTRAP_CLASS = MixinBootstrap.class.getName().replace('.', '/') + ".class"; + private static final String OUR_CLASS = MixinCompatUtils.class.getName().replace('.', '/') + ".class"; + + private static final VersionNumber VERSION_0_8_4 = VersionNumber.parse("0.8.4"); // Local capture algorithm changed. + private static final VersionNumber VERSION_LATEST = VersionNumber.parse(MixinEnvironment.getCurrentEnvironment().getVersion()); + + private static final ThreadLocal currentMixinInfo = new ThreadLocal<>(); + private static VersionNumber mixinVersionBeforeUs = null; + + public static void withCurrentMixinInfo(IMixinInfo info, Runnable block) { + IMixinInfo old = currentMixinInfo.get(); + currentMixinInfo.set(info); + try { + block.run(); + } finally { + currentMixinInfo.set(old); + } + } + + public static T withCurrentMixinInfo(IMixinInfo info, Supplier block) { + IMixinInfo old = currentMixinInfo.get(); + currentMixinInfo.set(info); + try { + return block.get(); + } finally { + currentMixinInfo.set(old); + } + } + + public static boolean canUseNewLocalsAlgorithm() { + IMixinInfo info = currentMixinInfo.get(); + if (info == null) { + // We're not being invoked from a known call-site, so we provide the new behaviour immediately. + return true; + } + return canUseFeature(info, VERSION_0_8_4); + } + + private static boolean canUseFeature(IMixinInfo mixin, VersionNumber introducedIn) { + IMixinConfig config = mixin.getConfig(); + String decorationKey = "essential.can_use_feature_from_" + introducedIn; + if (config.hasDecoration(decorationKey)) return config.getDecoration(decorationKey); + boolean result = canUseFeatureImpl(mixin, introducedIn); + config.decorate(decorationKey, result); + return result; + } + + private static boolean canUseFeatureImpl(IMixinInfo mixin, VersionNumber introducedIn) { + IMixinConfig config = mixin.getConfig(); + + // If they have set their `minVersion` to a version new enough, it was built against the new behaviour. + if (config instanceof MixinConfigExt) { + MixinConfigExt ext = ((MixinConfigExt) config); + if (ext.getMinVersion().compareTo(introducedIn) >= 0) { + return true; + } + } + + try (JarFile jar = getJar(mixin)) { + if (jar != null) { + // If they bundle a version of Mixin, they almost certainly want the behaviour for said version. + VersionNumber bundled = getBundledMixinVersion(jar); + if (bundled != null) { + return bundled.compareTo(introducedIn) >= 0; + } + // Otherwise, if they use Essential, we'll treat them as bundling 0.8.4, + // unless they opt in to newer behaviour via their min version. + if (dependsOnEssential(jar)) { + return VERSION_0_8_4.compareTo(introducedIn) >= 0; + } + } + } catch (Throwable e) { + LOGGER.error("An error occurred while trying to read the jar file for " + mixin + ": ", e); + } + + // Finally, if none of the above ways worked, we'll fall back to whatever behaviour would've occurred had Essential not been installed. + return getFallbackMixinVersion().compareTo(introducedIn) >= 0; + } + + private static VersionNumber getBundledMixinVersion(JarFile jar) throws IOException { + ZipEntry mixinBootstrap = jar.getEntry(MIXIN_BOOTSTRAP_CLASS); + if (mixinBootstrap == null) return null; + + try (InputStream stream = jar.getInputStream(mixinBootstrap)) { + return getMixinVersion(stream); + } + } + + @SuppressWarnings("unchecked") + public static boolean dependsOnEssential(JarFile jar) { + Set tweakers = (Set) Launch.blackboard.get(STAGE0_TWEAKERS_KEY); + return tweakers.stream().anyMatch(name -> jar.getEntry(name.replace('.', '/') + ".class") != null); + } + + private static VersionNumber getFallbackMixinVersion() { + if (mixinVersionBeforeUs != null) return mixinVersionBeforeUs; + + try { + for (URL source : Launch.classLoader.getSources()) { + URI uri = source.toURI(); + if (!"file".equals(uri.getScheme())) continue; + File file = Paths.get(uri).toFile(); + if (file.isFile() && file.getName().endsWith(".jar")) { + try (JarFile jar = new JarFile(file)) { + if (jar.getEntry(OUR_CLASS) != null) { + // Don't consider our own version of Mixin. + continue; + } + ZipEntry entry = jar.getEntry(MIXIN_BOOTSTRAP_CLASS); + if (entry == null) continue; + + try (InputStream stream = jar.getInputStream(entry)) { + return mixinVersionBeforeUs = getMixinVersion(stream); + } + } + } + } + } catch (Throwable e) { + LOGGER.error("Unable to determine fallback Mixin version. Defaulting to latest functionality: ", e); + } + // We are the only version on the classpath, and we can use the latest functionality hopefully without issue. + return mixinVersionBeforeUs = VERSION_LATEST; + } + + private static VersionNumber getMixinVersion(InputStream mixinBootstrapStream) throws IOException { + ClassNode node = new ClassNode(); + new ClassReader(mixinBootstrapStream).accept(node, ClassReader.SKIP_CODE); + for (FieldNode field : node.fields) { + if (field.name.equals("VERSION") && field.value instanceof String) { + return VersionNumber.parse((String) field.value); + } + } + return null; + } + + private static JarFile getJar(IMixinInfo mixin) throws IOException, URISyntaxException { + String resourceName = mixin.getClassRef() + ".class"; + URL url = Launch.classLoader.findResource(resourceName); + if (url == null) return null; + + // With LaunchWrapper, every class gets their own protection domain and urls are of the form + // jar:file:/some/path/to/archive.jar!/package/of/javaClass.class + // or for directories + // file:/some/path/to/dir/package/of/javaClass.class + String jarSuffix = "!/" + resourceName; + String file = url.getFile(); + if ("jar".equals(url.getProtocol()) && file.endsWith(jarSuffix)) { + URI uri = new URL(file.substring(0, file.lastIndexOf(jarSuffix))).toURI(); + return new JarFile(Paths.get(uri).toFile()); + } else { + // Likely comes from a directory. We're probably in dev, let's not worry too much. + return null; + } + } +} diff --git a/mixin/src/main/resources/essential.mod.json b/mixin/src/main/resources/essential.mod.json new file mode 100644 index 0000000..2787b05 --- /dev/null +++ b/mixin/src/main/resources/essential.mod.json @@ -0,0 +1,11 @@ +{ + "id": "mixin", + "version": "${mixinVersion}-essential.${patchesVersion}", + "jars": [ + { + "id": "org.ow2.asm:asm-debug-all", + "version": "${asmVersion}", + "file": "META-INF/jars/asm-debug-all-${asmVersion}.jar" + } + ] +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index a632356..ae917da 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,14 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + maven("https://maven.fabricmc.net") + maven("https://maven.architectury.dev/") + maven("https://repo.essential.gg/repository/maven-public") + maven("https://maven.minecraftforge.net") + } +} + includeBuild("build-logic") include(":container:fabric") @@ -32,6 +43,7 @@ include(":stage2:modlauncher9:neoforge1") include(":stage2:modlauncher9:neoforge4") include(":stage2:modlauncher9:modlauncher10") include(":stage2:modlauncher9:modlauncher11") +include(":mixin") include(":integrationTest:common") include(":integrationTest:fabric") include(":integrationTest:launchwrapper") diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchedLoader.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchedLoader.java index 55e1c0b..a7c93d6 100644 --- a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchedLoader.java +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchedLoader.java @@ -32,7 +32,7 @@ public class RelaunchedLoader { sourceFiles = SourceFile.readInfos(Launch.classLoader.getSources()); - if (relaunchInfo.loadedIds.contains("essential")) { + if (relaunchInfo.loadedIds.contains("mixin") || /* older versions of */ relaunchInfo.loadedIds.contains("essential")) { MixinTweakerInjector.injectMixinTweaker(true); } } From 6d25095cdc718d42d99761d743c0bb5ae23c9f27 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Fri, 4 Jul 2025 13:44:49 +0200 Subject: [PATCH 20/37] stage2/lw: Add builtin support for MixinExtras That is, we'll automatically initialize it for mods which bundle it. This is so mods which previously used Essential's relocated MixinExtras, for which initialization was handled by Essential, without Essential without any further changes. And since we're at it, we'll also provide the same for upstream MixinExtras to make using it convenient too. --- .../loader/stage2/RelaunchedLoader.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchedLoader.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchedLoader.java index a7c93d6..895249d 100644 --- a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchedLoader.java +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchedLoader.java @@ -41,6 +41,26 @@ public void injectIntoClassLoader(LaunchClassLoader classLoader) { if (injected) return; injected = true; + if (relaunchInfo.loadedIds.contains("io.github.llamalad7:mixinextras-common")) { + try { + Class.forName("com.llamalad7.mixinextras.MixinExtrasBootstrap", false, classLoader) + .getMethod("init") + .invoke(null); + } catch (Throwable e) { + LOGGER.error("Failed to initialize MixinExtras", e); + } + } + + if (relaunchInfo.loadedIds.contains("gg.essential.lib:mixinextras")) { + try { + Class.forName("gg.essential.lib.mixinextras.MixinExtrasBootstrap", false, classLoader) + .getMethod("init") + .invoke(null); + } catch (Throwable e) { + LOGGER.error("Failed to initialize MixinExtras", e); + } + } + if (relaunchInfo.loadedIds.contains("essential")) { try { Class.forName("gg.essential.api.tweaker.EssentialTweaker", false, classLoader) From 52c525e8d0cb0b7e527c0f49505a0a2c72c2660c Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 7 Jul 2025 11:11:01 +0200 Subject: [PATCH 21/37] stage2/lw: Import third-party mod compatibility patches from Essential This commit imports various patches/fixes for third-party mods from Essential, mostly related to them doing something which breaks Mixin. --- stage2/launchwrapper/build.gradle | 3 + .../loader/stage2/RelaunchedLoader.java | 41 +++++++++++++ .../compat/BetterFpsTransformerWrapper.java | 18 ++++++ .../stage2/compat/PhosphorTransformer.java | 44 ++++++++++++++ ...hreadUnsafeTransformersListWorkaround.java | 38 ++++++++++++ .../tweaker/BetterFpsWrappingTweaker.java | 60 +++++++++++++++++++ 6 files changed, 204 insertions(+) create mode 100644 stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/compat/BetterFpsTransformerWrapper.java create mode 100644 stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/compat/PhosphorTransformer.java create mode 100644 stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/compat/ThreadUnsafeTransformersListWorkaround.java create mode 100644 stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/compat/tweaker/BetterFpsWrappingTweaker.java diff --git a/stage2/launchwrapper/build.gradle b/stage2/launchwrapper/build.gradle index 9327fb5..fbcd195 100644 --- a/stage2/launchwrapper/build.gradle +++ b/stage2/launchwrapper/build.gradle @@ -11,6 +11,9 @@ dependencies { compileOnly("com.google.guava:guava:17.0") compileOnly("org.apache.commons:commons-lang3:3.3.2") compileOnly("com.google.code.gson:gson:2.2.4") + + // For compatibility patches, only accessed when mixin is actually loaded + compileOnly(project(":mixin")) } jar { diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchedLoader.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchedLoader.java index 895249d..be086f5 100644 --- a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchedLoader.java +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchedLoader.java @@ -1,5 +1,9 @@ package gg.essential.loader.stage2; +import gg.essential.loader.stage2.compat.BetterFpsTransformerWrapper; +import gg.essential.loader.stage2.compat.PhosphorTransformer; +import gg.essential.loader.stage2.compat.ThreadUnsafeTransformersListWorkaround; +import gg.essential.loader.stage2.compat.tweaker.BetterFpsWrappingTweaker; import gg.essential.loader.stage2.util.MixinTweakerInjector; import net.minecraft.launchwrapper.ITweaker; import net.minecraft.launchwrapper.Launch; @@ -7,6 +11,8 @@ import net.minecraftforge.fml.relauncher.CoreModManager; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.spongepowered.asm.service.ITransformerProvider; +import org.spongepowered.asm.service.MixinService; import java.io.File; import java.io.IOException; @@ -70,6 +76,27 @@ public void injectIntoClassLoader(LaunchClassLoader classLoader) { LOGGER.error("Failed to initialize Essential mod", e); } } + + + // + // Compatibility patches for various third-party mods + // + + ThreadUnsafeTransformersListWorkaround.apply(); + + if (relaunchInfo.loadedIds.contains("mixin")) { + addMixinTransformerExclusion("bre.smoothfont.asm.Transformer"); // fails silently if called more than once + addMixinTransformerExclusion("com.therandomlabs.randompatches.core.RPTransformer"); + addMixinTransformerExclusion("lakmoore.sel.common.Transformer"); + addMixinTransformerExclusion("openmods.core.OpenModsClassTransformer"); + addMixinTransformerExclusion("net.creeperhost.launchertray.transformer.MinecraftTransformer"); + addMixinTransformerExclusion("vazkii.quark.base.asm.ClassTransformer"); + addMixinTransformerExclusion(BetterFpsTransformerWrapper.class.getName()); + } + + BetterFpsWrappingTweaker.inject(); + + Launch.classLoader.registerTransformer(PhosphorTransformer.class.getName()); } public void initialize(ITweaker stage0Tweaker) { @@ -145,6 +172,20 @@ private void loadCoreMod(File file, String coreMod) throws ReflectiveOperationEx ((List) Launch.blackboard.get("Tweaks")).add(tweaker); } + private void addMixinTransformerExclusion(String name) { + if (relaunchInfo.loadedIds.contains("mixin")) { + addMixinTransformerExclusionImpl(name); + } + } + + // Separate method because mixin classes are only available if mixin is loaded + private static void addMixinTransformerExclusionImpl(String name) { + ITransformerProvider transformers = MixinService.getService().getTransformerProvider(); + if (transformers != null) { + transformers.addTransformerExclusion(name); + } + } + private static class SourceFile { final File file; final String tweaker; diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/compat/BetterFpsTransformerWrapper.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/compat/BetterFpsTransformerWrapper.java new file mode 100644 index 0000000..a69c344 --- /dev/null +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/compat/BetterFpsTransformerWrapper.java @@ -0,0 +1,18 @@ +package gg.essential.loader.stage2.compat; + +import net.minecraft.launchwrapper.IClassTransformer; + +// Wraps transformers from BetterFPS which replace a null class with an empty one, breaking lots of stuff. +public class BetterFpsTransformerWrapper implements IClassTransformer { + private final IClassTransformer delegate; + + public BetterFpsTransformerWrapper(IClassTransformer delegate) { + this.delegate = delegate; + } + + @Override + public byte[] transform(String name, String transformedName, byte[] basicClass) { + if (basicClass == null) return null; + return delegate.transform(name, transformedName, basicClass); + } +} diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/compat/PhosphorTransformer.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/compat/PhosphorTransformer.java new file mode 100644 index 0000000..40ec7ea --- /dev/null +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/compat/PhosphorTransformer.java @@ -0,0 +1,44 @@ +package gg.essential.loader.stage2.compat; + +import net.minecraft.launchwrapper.IClassTransformer; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.tree.AnnotationNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.MethodNode; + +import java.util.Iterator; + +public class PhosphorTransformer implements IClassTransformer { + @Override + public byte[] transform(String name, String transformedName, byte[] basicClass) { + if ("me.jellysquid.mods.phosphor.mixins.lighting.common.MixinChunk$Vanilla".equals(transformedName)) { + ClassReader classReader = new ClassReader(basicClass); + ClassNode classNode = new ClassNode(); + classReader.accept(classNode, 0); + for (MethodNode method : classNode.methods) { + if (method.visibleAnnotations == null) continue; + for (AnnotationNode annotation : method.visibleAnnotations) { + if (annotation.desc.endsWith("ModifyVariable;")) { + for (Iterator it = annotation.values.iterator(); it.hasNext(); ) { + Object value = it.next(); + // Mixin 0.8.2 didn't respect these Slices anyway. 0.8.4 does, and that breaks Phosphor. + if ("slice".equals(value)) { + it.remove(); + if (it.hasNext()) { + it.next(); + it.remove(); + break; + } + } + } + } + } + } + ClassWriter writer = new ClassWriter(0); + classNode.accept(writer); + return writer.toByteArray(); + } + return basicClass; + } +} diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/compat/ThreadUnsafeTransformersListWorkaround.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/compat/ThreadUnsafeTransformersListWorkaround.java new file mode 100644 index 0000000..051c100 --- /dev/null +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/compat/ThreadUnsafeTransformersListWorkaround.java @@ -0,0 +1,38 @@ +package gg.essential.loader.stage2.compat; + +import net.minecraft.launchwrapper.IClassTransformer; +import net.minecraft.launchwrapper.Launch; +import net.minecraft.launchwrapper.LaunchClassLoader; +import org.apache.logging.log4j.LogManager; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +// Registering a transformer is not a thread safe operation. +// Usually this isn't an issue because all transformers are registered early during boot where there is only one +// thread. +// Forge however also registers a transformer way later when loading its mods, and at that point other threads may +// already be active, so thread safety becomes a concern and classes may randomly fail to load due to +// `ConcurrentModificationException`s. +// This method patches the issue by replacing the transformers list with a copy-on-write one. +public class ThreadUnsafeTransformersListWorkaround { + @SuppressWarnings("unchecked") + public static void apply() { + try { + LaunchClassLoader classLoader = Launch.classLoader; + Field field = LaunchClassLoader.class.getDeclaredField("transformers"); + field.setAccessible(true); + List value = (List) field.get(classLoader); + if (value instanceof CopyOnWriteArrayList) { + LogManager.getLogger().debug("LaunchClassLoader.transformers appears to already be copy-on-write"); + return; + } + LogManager.getLogger().debug("Replacing LaunchClassLoader.transformers list with a copy-on-write list"); + field.set(classLoader, new CopyOnWriteArrayList<>(value)); + } catch (Throwable t) { + LogManager.getLogger().error( + "Failed to replace plain LaunchClassLoader.transformers list with copy-on-write one", t); + } + } +} diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/compat/tweaker/BetterFpsWrappingTweaker.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/compat/tweaker/BetterFpsWrappingTweaker.java new file mode 100644 index 0000000..6039fb8 --- /dev/null +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/compat/tweaker/BetterFpsWrappingTweaker.java @@ -0,0 +1,60 @@ +package gg.essential.loader.stage2.compat.tweaker; + +import com.google.common.collect.ImmutableSet; +import gg.essential.loader.stage2.compat.BetterFpsTransformerWrapper; +import net.minecraft.launchwrapper.IClassTransformer; +import net.minecraft.launchwrapper.ITweaker; +import net.minecraft.launchwrapper.Launch; +import net.minecraft.launchwrapper.LaunchClassLoader; + +import java.io.File; +import java.lang.reflect.Field; +import java.util.List; +import java.util.Set; + +public class BetterFpsWrappingTweaker implements ITweaker { + private static final Set BROKEN_TRANSFORMERS = ImmutableSet.of( + "me.guichaguri.betterfps.transformers.EventTransformer", + "me.guichaguri.betterfps.transformers.MathTransformer" + ); + + @Override + public void acceptOptions(List args, File gameDir, File assetsDir, String profile) { + } + + @SuppressWarnings("unchecked") + @Override + public void injectIntoClassLoader(LaunchClassLoader classLoader) { + try { + Field transformersField = LaunchClassLoader.class.getDeclaredField("transformers"); + transformersField.setAccessible(true); + List transformers = (List) transformersField.get(classLoader); + + for (int i = 0; i < transformers.size(); i++) { + IClassTransformer transformer = transformers.get(i); + if (BROKEN_TRANSFORMERS.contains(transformer.getClass().getName())) { + transformers.set(i, new BetterFpsTransformerWrapper(transformer)); + } + } + } catch (Throwable e) { + System.err.println("Failed to wrap BetterFPS' broken transformers! Chaos incoming..."); + e.printStackTrace(); + } + } + + @Override + public String getLaunchTarget() { + return null; + } + + @Override + public String[] getLaunchArguments() { + return new String[0]; + } + + @SuppressWarnings("unchecked") + public static void inject() { + List tweakClasses = (List) Launch.blackboard.get("TweakClasses"); + tweakClasses.add(BetterFpsWrappingTweaker.class.getName()); + } +} From e73a543ae815236338a5deeadc58647b548a21d5 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 8 Jul 2025 10:10:37 +0200 Subject: [PATCH 22/37] stage2/lw: Fix erroneous "corrupt" jars errors emitted by Forge's JarDiscoverer Based on `MixinJarDiscoverer` from Essential. --- .../loader/stage2/RelaunchedLoader.java | 2 + .../compat/ForgeJarDiscovererTransformer.java | 85 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/compat/ForgeJarDiscovererTransformer.java diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchedLoader.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchedLoader.java index be086f5..27e3d8d 100644 --- a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchedLoader.java +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/RelaunchedLoader.java @@ -1,6 +1,7 @@ package gg.essential.loader.stage2; import gg.essential.loader.stage2.compat.BetterFpsTransformerWrapper; +import gg.essential.loader.stage2.compat.ForgeJarDiscovererTransformer; import gg.essential.loader.stage2.compat.PhosphorTransformer; import gg.essential.loader.stage2.compat.ThreadUnsafeTransformersListWorkaround; import gg.essential.loader.stage2.compat.tweaker.BetterFpsWrappingTweaker; @@ -97,6 +98,7 @@ public void injectIntoClassLoader(LaunchClassLoader classLoader) { BetterFpsWrappingTweaker.inject(); Launch.classLoader.registerTransformer(PhosphorTransformer.class.getName()); + Launch.classLoader.registerTransformer(ForgeJarDiscovererTransformer.class.getName()); } public void initialize(ITweaker stage0Tweaker) { diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/compat/ForgeJarDiscovererTransformer.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/compat/ForgeJarDiscovererTransformer.java new file mode 100644 index 0000000..c71bd73 --- /dev/null +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/compat/ForgeJarDiscovererTransformer.java @@ -0,0 +1,85 @@ +package gg.essential.loader.stage2.compat; + +import net.minecraft.launchwrapper.IClassTransformer; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.InsnList; +import org.objectweb.asm.tree.InsnNode; +import org.objectweb.asm.tree.LdcInsnNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; + +// Forge's mod discovery code fails when trying to read Java 9 classes, unnecessarily spamming the log about them being +// "corrupt". +// This transformer excludes the Java 9 version specific jar directory from this search to avoid this spam. +// +// Functionally equivalent to this Mixin: +// @Mixin(value = JarDiscoverer.class, remap = false) +// public abstract class MixinJarDiscoverer { +// @Redirect(method = {"discover", "findClassesASM"}, at = @At(value = "INVOKE", target = "Ljava/lang/String;startsWith(Ljava/lang/String;)Z")) +// private boolean shouldSkip(String entry, String originalPattern) { +// if (entry.startsWith("META-INF/versions/9/")) { +// return true; +// } +// return entry.startsWith(originalPattern); +// } +// } +public class ForgeJarDiscovererTransformer implements IClassTransformer { + @Override + public byte[] transform(String name, String transformedName, byte[] basicClass) { + if ("net.minecraftforge.fml.common.discovery.JarDiscoverer".equals(transformedName)) { + ClassReader classReader = new ClassReader(basicClass); + ClassNode classNode = new ClassNode(); + classReader.accept(classNode, 0); + for (MethodNode method : classNode.methods) { + if (method.name.equals("discover") || method.name.equals("findClassesASM")) { + InsnList insnList = method.instructions; + AbstractInsnNode insn = insnList.getFirst(); + while (insn != null) { + if (insn instanceof MethodInsnNode) { + MethodInsnNode methodInsn = (MethodInsnNode) insn; + if (methodInsn.owner.equals("java/lang/String") && methodInsn.name.equals("startsWith")) { + injectExtraChecks(insnList, methodInsn); + break; + } + } + + insn = insn.getNext(); + } + } + } + ClassWriter writer = new ClassWriter(0); + classNode.accept(writer); + return writer.toByteArray(); + } + return basicClass; + } + + private void injectExtraChecks(InsnList insnList, MethodInsnNode orgStartsWith) { + InsnList before = new InsnList(); + InsnList after = new InsnList(); + + // Stack: .., entry, needle + before.add(new InsnNode(Opcodes.SWAP)); + // Stack: .., needle, entry + before.add(new InsnNode(Opcodes.DUP_X1)); + // Stack: .., entry, needle, entry + before.add(new InsnNode(Opcodes.SWAP)); + // Stack: .., entry, entry, needle + // orgStartsWith + // Stack: .., entry, boolean + after.add(new InsnNode(Opcodes.SWAP)); + // Stack: .., boolean, entry + after.add(new LdcInsnNode("META-INF/versions/9/")); + // Stack: .., boolean, entry, needle + after.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, orgStartsWith.owner, orgStartsWith.name, orgStartsWith.desc, false)); + // Stack: .., boolean, boolean + after.add(new InsnNode(Opcodes.IOR)); + + insnList.insertBefore(orgStartsWith, before); + insnList.insert(orgStartsWith, after); + } +} From c226736ec68a5b478ef987ab11695392b7ef6d62 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 8 Jul 2025 11:21:41 +0200 Subject: [PATCH 23/37] misc: Update README for new LaunchWrapper functionality --- README.md | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 06f0038..fa153d2 100644 --- a/README.md +++ b/README.md @@ -1 +1,118 @@ -[Docs](https://github.com/EssentialGG/EssentialLoader/tree/master/docs) +## Internals +For an explanation of how the internals of Essential Loader on various platforms function, see the [docs](https://github.com/EssentialGG/EssentialLoader/tree/master/docs) folder. + +## Using Essential Loader with your mod on 1.8.9 - 1.12.2 +Essential Loader for Minecraft 1.8.9 through 1.12.2 (commonly called "legacy Forge" or "LaunchWrapper") provides support +for Mixin 0.8.x (even on 1.8.9, and with improved third-party mod compatibility on 1.12.2) and Jar-in-Jar style +dependency management (e.g. if two mods ship different versions of the same dependency, it will automatically select +the more recent one). + +### Setup +To use it with your mod, firstly you must declare a dependency in your build script and include it in your jar file: +```kotlin +repositories { + maven("https://repo.essential.gg/repository/maven-public/") +} + +val embed by configurations.creating +configurations.implementation.get().extendsFrom(embed) + +dependencies { + embed("gg.essential:loader-launchwrapper:1.3.0") +} + +tasks.jar { + // Embed the contents of the Essential Loader jar into your mod jar. + dependsOn(embed) + from(embed.files.map { zipTree(it) }) + // Set Essential Loader as the Tweaker for your mod. + // If you already have a custom Tweaker, you can have it extend the class instead. + // If you previously used the Mixin Tweaker, you can simply use Essential Loader instead, it'll automatically + // initialize Mixin for any mod with a `MixinConfigs` attribute. + manifest.attributes(mapOf("TweakClass" to "gg.essential.loader.stage0.EssentialSetupTweaker")) +} +``` +You then need to create a `essential.mod.json` file in your `src/main/resources` folder which defines some metadata and +specifies which libraries your mod includes and where inside the mod jar they can be found: +```json +{ + "id": "your_mod_id_goes_here", + "version": "1.0.0", + "jars": [ + { + "id": "com.example:examplelib", + "version": "0.1.0", + "file": "META-INF/jars/examplelib-0.1.0.jar" + } + ] +} +``` +If you don't have any special needs, you can auto-generate the `version` and `jars` entries and automate the inclusion +of the libraries in your mod based on Gradle dependencies with the following snippet in your build script: +```kotlin +val jij by configurations.creating +configurations.implementation.get().extendsFrom(jij) + +dependencies { + // Add all the dependencies you wish to jar-in-jar to the custom `jij` configuration + jij("com.example:examplelib:0.1.0") +} + +tasks.processResources { + val expansions = mapOf( + "version" to version, + "jars" to provider { + jij.resolvedConfiguration.resolvedArtifacts.joinToString(",\n") { artifact -> + val id = artifact.moduleVersion.id + """ + { + "id": "${id.group}:${id.name}", + "version": "${id.version}", + "file": "META-INF/jars/${artifact.file.name}" + } + """.trimIndent() + } + }, + ) + inputs.property("expansions", expansions) + filesMatching("essential.mod.json") { + expand(expansions) + } +} + +tasks.jar { + dependsOn(jij) + from(jij.files) { + into("META-INF/jars") + } +} +``` +and the following `essential.mod.json` template file: +```json +{ + "id": "your_mod_id_goes_here", + "version": "${version}", + "jars": [ + ${jars.get()} + ] +} +``` + +### Mixin 0.8.x + +To use our Mixin with your mod, assuming you've already included Essential Loader as per above instructions, and you've +already set up Mixin's annotation processor and refmap generation via your build system (this is done the same way as +it would be done with stock Mixin 0.8.x or 0.7.10, Essential Loader only affects things at runtime), simply +add it as a jar-in-jar dependency: +```kotlin +dependencies { + jij("gg.essential:mixin:0.1.0+mixin.0.8.4") + + // MixinExtras may be added in the same way: + // Essential Loader will automatically initialize it for you, no need to call `MixinExtrasBootstrap`. + // `annotationProcessor` is necessary to generate refmaps if your build system does not setup MixinExtras for you. + jij(annotationProcessor("io.github.llamalad7:mixinextras-common:0.4.1")!!) + // or if you've previously used Essential's relocated MixinExtras version: + jij(annotationProcessor("gg.essential.lib:mixinextras:0.4.0")!!) +} +``` From 7bb888e80205aad10baafd4428833c412f1d4502 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 8 Jul 2025 11:51:07 +0200 Subject: [PATCH 24/37] mixin: Release 0.1.0 --- mixin/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mixin/build.gradle.kts b/mixin/build.gradle.kts index f2f8d4b..8e2d455 100644 --- a/mixin/build.gradle.kts +++ b/mixin/build.gradle.kts @@ -9,7 +9,7 @@ plugins { id("essential.build-logic") } -val patchesVersion = "0.0.0" +val patchesVersion = "0.1.0" val mixinVersion = "0.8.4" val asmVersion = "5.2" From 81d4a41cfc4b4edc48f69f45cc4a9a0c68b4d4d3 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 8 Jul 2025 11:49:53 +0200 Subject: [PATCH 25/37] stage2: Release 1.7.0 --- stage2/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stage2/build.gradle b/stage2/build.gradle index db869f7..cc46406 100644 --- a/stage2/build.gradle +++ b/stage2/build.gradle @@ -1,4 +1,4 @@ -version = "1.6.5" +version = "1.7.0" configure(subprojects) { version = parent.version From e0c926f8a2b27b63698c21faa838b9b6cec16ee1 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 8 Jul 2025 11:50:05 +0200 Subject: [PATCH 26/37] stage1: Release 11 --- stage1/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stage1/build.gradle b/stage1/build.gradle index badf6ee..c198d91 100644 --- a/stage1/build.gradle +++ b/stage1/build.gradle @@ -1,4 +1,4 @@ -version = "10" +version = "11" configure(subprojects.findAll { it.name != "common" && it.name != "modlauncher" }) { version = parent.version From e1678e881ed54bef93c0bde24a20656e25f37e15 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Tue, 8 Jul 2025 11:50:19 +0200 Subject: [PATCH 27/37] stage0: Release 1.3.0 --- stage0/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stage0/build.gradle b/stage0/build.gradle index 230282b..572b8d4 100644 --- a/stage0/build.gradle +++ b/stage0/build.gradle @@ -1,4 +1,4 @@ -version = "1.2.5" +version = "1.3.0" configure(subprojects.findAll { it.name != "common" && it.name != "modlauncher" }) { apply plugin: 'maven-publish' From df4b5a1306a9a88282210101b448d2456f286ce0 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 14 Jul 2025 10:57:07 +0200 Subject: [PATCH 28/37] build/mixin: Implement merging of class initializers --- .../src/main/kotlin/essential/CompatMixinTask.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/build-logic/src/main/kotlin/essential/CompatMixinTask.kt b/build-logic/src/main/kotlin/essential/CompatMixinTask.kt index a382ce2..6fef4d0 100644 --- a/build-logic/src/main/kotlin/essential/CompatMixinTask.kt +++ b/build-logic/src/main/kotlin/essential/CompatMixinTask.kt @@ -21,6 +21,7 @@ import java.util.* import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream +import kotlin.math.max abstract class CompatMixinTask : DefaultTask() { @@ -134,7 +135,16 @@ abstract class CompatMixinTask : DefaultTask() { } if (method.name == "") { - throw UnsupportedOperationException("Class initializer merging is not implemented.") + val existingMethod = cls.methods.find { it.name == "" } + if (existingMethod != null) { + existingMethod.maxLocals = max(existingMethod.maxLocals, method.maxLocals) + existingMethod.maxStack = max(existingMethod.maxStack, method.maxStack) + existingMethod.instructions.remove(existingMethod.instructions.last.also { assert(it.opcode == Opcodes.RETURN) }) + existingMethod.instructions.add(method.instructions) + } else { + cls.methods.add(method) + } + continue } cls.methods.add(method) From c7c07e9fc4278f5d5eeed452a2c5f1ae9294ebdc Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 14 Jul 2025 10:57:36 +0200 Subject: [PATCH 29/37] build/mixin: Implement adding fields to target class --- .../src/main/kotlin/essential/CompatMixinTask.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/build-logic/src/main/kotlin/essential/CompatMixinTask.kt b/build-logic/src/main/kotlin/essential/CompatMixinTask.kt index 6fef4d0..0b35834 100644 --- a/build-logic/src/main/kotlin/essential/CompatMixinTask.kt +++ b/build-logic/src/main/kotlin/essential/CompatMixinTask.kt @@ -127,8 +127,17 @@ abstract class CompatMixinTask : DefaultTask() { } true } + mixin.fields.removeIf { field -> + val shadow = field.invisibleAnnotations?.find { it.desc == CompatShadow.desc } + ?: return@removeIf false + val originalName = shadow.args["original"] + if (originalName != null) { + throw UnsupportedOperationException("Renaming fields is not supported") + } + true + } - // Then merge the remaining methods into the target class + // Then merge the remaining methods and fields into the target class for (method in mixin.methods) { if (method.name == "") { continue @@ -149,6 +158,10 @@ abstract class CompatMixinTask : DefaultTask() { cls.methods.add(method) } + for (field in mixin.fields) { + if (field.name == "this$0") continue // synthetic accessor for outer class in inner class + cls.fields.add(field) + } // Apply access transformations val accessTransformer = mixin.invisibleAnnotations?.find { it.desc == CompatAccessTransformer.desc } From 9c8d439db64ea771223cbfb3eea989b4e39e918a Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 14 Jul 2025 11:09:16 +0200 Subject: [PATCH 30/37] build/mixin: Implement access transformer on shadow methods and fields --- .../main/kotlin/essential/CompatMixinTask.kt | 46 ++++++++++++++----- .../gg/essential/CompatAccessTransformer.java | 2 +- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/build-logic/src/main/kotlin/essential/CompatMixinTask.kt b/build-logic/src/main/kotlin/essential/CompatMixinTask.kt index 0b35834..7523987 100644 --- a/build-logic/src/main/kotlin/essential/CompatMixinTask.kt +++ b/build-logic/src/main/kotlin/essential/CompatMixinTask.kt @@ -120,20 +120,34 @@ abstract class CompatMixinTask : DefaultTask() { ?: return@removeIf false val originalName = shadow.args["original"] + val targetName = originalName ?: method.name + + val targetMethod = cls.methods.find { it.name == targetName && it.desc == method.desc } + ?: throw IllegalArgumentException("Could not find target method \"$targetName\" in ${cls.name}") + if (originalName != null) { - val originalMethod = cls.methods.find { it.name == originalName && it.desc == method.desc } - ?: throw IllegalArgumentException("Could not find original method \"$originalName\" in ${cls.name}") - originalMethod.name = method.name + targetMethod.name = method.name } + + targetMethod.access = applyAccessTransformer(targetMethod.access, method.invisibleAnnotations) + true } mixin.fields.removeIf { field -> val shadow = field.invisibleAnnotations?.find { it.desc == CompatShadow.desc } ?: return@removeIf false val originalName = shadow.args["original"] + val targetName = originalName ?: field.name + + val targetField = cls.fields.find { it.name == targetName && it.desc == field.desc } + ?: throw IllegalArgumentException("Could not find target field \"$targetName\" in ${cls.name}") + if (originalName != null) { throw UnsupportedOperationException("Renaming fields is not supported") } + + targetField.access = applyAccessTransformer(targetField.access, field.invisibleAnnotations) + true } @@ -164,15 +178,7 @@ abstract class CompatMixinTask : DefaultTask() { } // Apply access transformations - val accessTransformer = mixin.invisibleAnnotations?.find { it.desc == CompatAccessTransformer.desc } - if (accessTransformer != null) { - (accessTransformer.args["add"] as? List<*>)?.forEach { - cls.access = cls.access or it as Int - } - (accessTransformer.args["remove"] as? List<*>)?.forEach { - cls.access = cls.access and (it as Int).inv() - } - } + cls.access = applyAccessTransformer(cls.access, mixin.invisibleAnnotations) // Merge interfaces for (itf in mixin.interfaces) { @@ -182,6 +188,22 @@ abstract class CompatMixinTask : DefaultTask() { } } + private fun applyAccessTransformer(orgAccess: Int, annotations: List?): Int { + val annotation = annotations?.find { it.desc == CompatAccessTransformer.desc } + return if (annotation != null) applyAccessTransformer(orgAccess, annotation) else orgAccess + } + + private fun applyAccessTransformer(orgAccess: Int, accessTransformer: AnnotationNode): Int { + var access = orgAccess + (accessTransformer.args["add"] as? List<*>)?.forEach { + access = access or it as Int + } + (accessTransformer.args["remove"] as? List<*>)?.forEach { + access = access and (it as Int).inv() + } + return access + } + private val AnnotationNode.args get() = (values ?: emptyList()).chunked(2) { (k, v) -> k to v }.toMap() private val String.desc get() = "L${replace('.', '/')};" diff --git a/mixin/src/main/java/gg/essential/CompatAccessTransformer.java b/mixin/src/main/java/gg/essential/CompatAccessTransformer.java index 78656ff..c2069ef 100644 --- a/mixin/src/main/java/gg/essential/CompatAccessTransformer.java +++ b/mixin/src/main/java/gg/essential/CompatAccessTransformer.java @@ -3,7 +3,7 @@ import java.lang.annotation.ElementType; import java.lang.annotation.Target; -@Target(ElementType.TYPE) +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD}) public @interface CompatAccessTransformer { /** * Access flags to be added to the target From 31b5b5c34d5364f434411b2aeaac4b3552d2be63 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 14 Jul 2025 11:13:12 +0200 Subject: [PATCH 31/37] build/mixin: Handle bridge types in shadow method/field descriptor E.g. the `inject(CallbackBridge)` in our `CallbackInjectorCompat` mixin uses our `CallbackBridge` type instead of Mixin's `Callback` type because the latter is private. We already remap `CallbackBridge` to `Callback` everywhere via `ClassRemapper` after merging the mixin into the target, but we also need to do that when comparing type descriptors of methods/fields in the mixin and the target class, otherwise we won't be able to find the target method/field. --- .../src/main/kotlin/essential/CompatMixinTask.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/build-logic/src/main/kotlin/essential/CompatMixinTask.kt b/build-logic/src/main/kotlin/essential/CompatMixinTask.kt index 7523987..01bb617 100644 --- a/build-logic/src/main/kotlin/essential/CompatMixinTask.kt +++ b/build-logic/src/main/kotlin/essential/CompatMixinTask.kt @@ -84,7 +84,7 @@ abstract class CompatMixinTask : DefaultTask() { if (mixin != null) { val cls = ClassNode().apply { ClassReader(zipIn).accept(this, 0) } - merge(mixin.node, cls) + merge(mixin.node, cls, mixinRemapper) zipOut.write(ClassWriter(0).apply { cls.accept(ClassRemapper(this, mixinRemapper)) @@ -110,7 +110,7 @@ abstract class CompatMixinTask : DefaultTask() { .replace('/', '.') .replace('\\', '.') - private fun merge(mixin: ClassNode, cls: ClassNode) { + private fun merge(mixin: ClassNode, cls: ClassNode, remapper: SimpleRemapper) { // Mixin targets Java 6, but we don't want to be as limited in terms of language features cls.version = Opcodes.V1_8 @@ -122,7 +122,8 @@ abstract class CompatMixinTask : DefaultTask() { val originalName = shadow.args["original"] val targetName = originalName ?: method.name - val targetMethod = cls.methods.find { it.name == targetName && it.desc == method.desc } + val mappedDesc = remapper.mapMethodDesc(method.desc) + val targetMethod = cls.methods.find { it.name == targetName && it.desc == mappedDesc } ?: throw IllegalArgumentException("Could not find target method \"$targetName\" in ${cls.name}") if (originalName != null) { @@ -139,7 +140,8 @@ abstract class CompatMixinTask : DefaultTask() { val originalName = shadow.args["original"] val targetName = originalName ?: field.name - val targetField = cls.fields.find { it.name == targetName && it.desc == field.desc } + val mappedDesc = remapper.mapDesc(field.desc) + val targetField = cls.fields.find { it.name == targetName && it.desc == mappedDesc } ?: throw IllegalArgumentException("Could not find target field \"$targetName\" in ${cls.name}") if (originalName != null) { From fb8dffe3c42f7de5a8e09520c483e9feaf8468ad Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 14 Jul 2025 11:37:26 +0200 Subject: [PATCH 32/37] build/mixin: Support multiple mixins per target class --- .../main/kotlin/essential/CompatMixinTask.kt | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/build-logic/src/main/kotlin/essential/CompatMixinTask.kt b/build-logic/src/main/kotlin/essential/CompatMixinTask.kt index 01bb617..657efb0 100644 --- a/build-logic/src/main/kotlin/essential/CompatMixinTask.kt +++ b/build-logic/src/main/kotlin/essential/CompatMixinTask.kt @@ -43,7 +43,7 @@ abstract class CompatMixinTask : DefaultTask() { CompatAccessTransformer, ) - val mixins = mutableMapOf() + val mixins = mutableMapOf>() for (classFile in this.mixinClasses.asFileTree.files) { if (classFile.extension != "class") { continue @@ -56,14 +56,13 @@ abstract class CompatMixinTask : DefaultTask() { ?: args["target"]?.toString() ?: throw IllegalArgumentException("`@CompatMixin` annotation in $classFile is invalid.") - if (target in mixins) { - throw IllegalArgumentException("Multiple `@CompatMixin`s for \"$target\".") - } - mixins[target] = Mixin(classFile.toPath(), cls) + mixins.getOrPut(target, ::mutableListOf).add(Mixin(classFile.toPath(), cls)) excludedClasses += cls.name.replace('/', '.') } - val mixinToTargetMapping = mixins.entries.associate { (target, mixin) -> + val mixinToTargetMapping = mixins.entries.flatMap { (target, mixins) -> + mixins.map { target to it } + }.associate { (target, mixin) -> mixin.node.name to target.replace('.', '/') } val mixinRemapper = SimpleRemapper(mixinToTargetMapping) @@ -80,11 +79,13 @@ abstract class CompatMixinTask : DefaultTask() { outputEntry.time = CONSTANT_TIME_FOR_ZIP_ENTRIES zipOut.putNextEntry(outputEntry) - val mixin = mixins.remove(classForFile(inputEntry.name)) - if (mixin != null) { + val targetMixins = mixins.remove(classForFile(inputEntry.name)) + if (targetMixins != null) { val cls = ClassNode().apply { ClassReader(zipIn).accept(this, 0) } - merge(mixin.node, cls, mixinRemapper) + for (targetMixin in targetMixins) { + merge(targetMixin.node, cls, mixinRemapper) + } zipOut.write(ClassWriter(0).apply { cls.accept(ClassRemapper(this, mixinRemapper)) @@ -99,8 +100,8 @@ abstract class CompatMixinTask : DefaultTask() { } if (mixins.isNotEmpty()) { - throw IllegalArgumentException(mixins.map { (cls, mixin) -> - "Failed to find target \"$cls\" for \"${mixin.source}\"" + throw IllegalArgumentException(mixins.map { (cls, mixins) -> + "Failed to find target `$cls` for ${mixins.joinToString { "`${it.source}`" }}" }.joinToString("\n")) } } From 88a7b3e48651812fd32241d04ce2b84d20f184b9 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 14 Jul 2025 11:50:01 +0200 Subject: [PATCH 33/37] build/mixin: Allow creating new classes Via a CompatMixin so 1) we can keep them organized in packages, and 2) we can create class nested inside of Mixin classes --- .../main/kotlin/essential/CompatMixinTask.kt | 33 ++++++++++++++++++- .../main/java/gg/essential/CompatMixin.java | 2 ++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/build-logic/src/main/kotlin/essential/CompatMixinTask.kt b/build-logic/src/main/kotlin/essential/CompatMixinTask.kt index 657efb0..d3a9b0e 100644 --- a/build-logic/src/main/kotlin/essential/CompatMixinTask.kt +++ b/build-logic/src/main/kotlin/essential/CompatMixinTask.kt @@ -55,8 +55,9 @@ abstract class CompatMixinTask : DefaultTask() { val target = args["value"]?.toString()?.removeSurrounding("L", ";")?.replace('/', '.') ?: args["target"]?.toString() ?: throw IllegalArgumentException("`@CompatMixin` annotation in $classFile is invalid.") + val createTarget = (args["createTarget"] as Boolean?) ?: false - mixins.getOrPut(target, ::mutableListOf).add(Mixin(classFile.toPath(), cls)) + mixins.getOrPut(target, ::mutableListOf).add(Mixin(classFile.toPath(), cls, createTarget)) excludedClasses += cls.name.replace('/', '.') } @@ -97,6 +98,35 @@ abstract class CompatMixinTask : DefaultTask() { zipOut.closeEntry() } } + + mixins.entries.removeIf { (target, targetMixins) -> + val source = targetMixins.find { it.createTarget } + if (source == null) return@removeIf false + + val outputEntry = ZipEntry(target.replace(".", "/") + ".class") + outputEntry.time = CONSTANT_TIME_FOR_ZIP_ENTRIES + zipOut.putNextEntry(outputEntry) + + val cls = ClassNode() + cls.name = target.replace(".", "/") + cls.version = source.node.version + cls.access = source.node.access + cls.superName = mixinRemapper.mapType(source.node.superName) + cls.outerClass = mixinRemapper.mapType(source.node.outerClass) + cls.sourceFile = source.node.sourceFile + cls.sourceDebug = source.node.sourceDebug + + for (targetMixin in targetMixins) { + merge(targetMixin.node, cls, mixinRemapper) + } + + zipOut.write(ClassWriter(0).apply { + cls.accept(ClassRemapper(this, mixinRemapper)) + }.toByteArray()) + + zipOut.closeEntry() + true + } } if (mixins.isNotEmpty()) { @@ -214,6 +244,7 @@ abstract class CompatMixinTask : DefaultTask() { private data class Mixin( val source: Path, val node: ClassNode, + val createTarget: Boolean, ) companion object { diff --git a/mixin/src/main/java/gg/essential/CompatMixin.java b/mixin/src/main/java/gg/essential/CompatMixin.java index d0156c2..f262f9a 100644 --- a/mixin/src/main/java/gg/essential/CompatMixin.java +++ b/mixin/src/main/java/gg/essential/CompatMixin.java @@ -13,4 +13,6 @@ Class value() default Void.class; String target() default ""; + + boolean createTarget() default false; } From 0faedcbfb9d31aa00aef5d6a72ccd15b172081f7 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 14 Jul 2025 13:20:38 +0200 Subject: [PATCH 34/37] mixin: Add support for MixinBooter This commit adds stubs and custom functionality as required by MixinBooter to not crash when booting. Mods which rely on other features only available in MixinBooter's Mixin fork or which rely on the Mixin version shipped with MixinBooter, may of course still not function properly. Although we are not currently aware of any such mod. --- .../cleanroom/ClassInfoCompat.java | 23 ++++++++++++++++ .../cleanroom/GlobalPropertiesKeysCompat.java | 13 ++++++++++ .../cleanroom/IMixinProcessor.java | 16 ++++++++++++ .../cleanroom/IMixinTransformerCompat.java | 11 ++++++++ .../cleanroom/MixinConfigCompat.java | 26 +++++++++++++++++++ .../cleanroom/MixinProcessorCompat.java | 24 +++++++++++++++++ .../cleanroom/MixinTransformerCompat.java | 15 +++++++++++ .../mixincompat/cleanroom/ProxyCompat.java | 17 ++++++++++++ .../mixincompat/cleanroom/package-info.java | 9 +++++++ 9 files changed, 154 insertions(+) create mode 100644 mixin/src/main/java/gg/essential/mixincompat/cleanroom/ClassInfoCompat.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/cleanroom/GlobalPropertiesKeysCompat.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/cleanroom/IMixinProcessor.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/cleanroom/IMixinTransformerCompat.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/cleanroom/MixinConfigCompat.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/cleanroom/MixinProcessorCompat.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/cleanroom/MixinTransformerCompat.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/cleanroom/ProxyCompat.java create mode 100644 mixin/src/main/java/gg/essential/mixincompat/cleanroom/package-info.java diff --git a/mixin/src/main/java/gg/essential/mixincompat/cleanroom/ClassInfoCompat.java b/mixin/src/main/java/gg/essential/mixincompat/cleanroom/ClassInfoCompat.java new file mode 100644 index 0000000..583466f --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/cleanroom/ClassInfoCompat.java @@ -0,0 +1,23 @@ +package gg.essential.mixincompat.cleanroom; + +import gg.essential.CompatMixin; +import org.spongepowered.asm.mixin.transformer.ClassInfo; + +@CompatMixin(ClassInfo.class) +public class ClassInfoCompat { + // Method added in Cleanroom's Mixin fork, and MixinBooter calls it + // https://github.com/CleanroomMC/MixinBooter/blob/05fc6c7b4b36a714c90eb4cc2f2364c681da0bc8/src/main/java/zone/rong/mixinbooter/fix/MixinFixer.java#L35 + // https://github.com/CleanroomMC/MixinBooter-UniMix/blob/9d4b487ed32501137645cdf0da484b076f0bfaf4/src/main/java/org/spongepowered/asm/mixin/transformer/ClassInfo.java#L2239 + public static void registerCallback(Callback callback) { + // It seems like MixinBooter uses these callbacks to replace init-phase mixins targeting Forge's Loader to + // make it possible to target ordinary mod classes via mixins. + // Given we already add back in the methods which those old mixins call, it should be fine for us to just + // allow them to apply as they also would without MixinBooter, and it should just work. + // This method can therefore just do nothing. + } + + @CompatMixin(target = "org.spongepowered.asm.mixin.transformer.ClassInfo$Callback", createTarget = true) + public interface Callback { + void onInit(ClassInfo classInfo); + } +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/cleanroom/GlobalPropertiesKeysCompat.java b/mixin/src/main/java/gg/essential/mixincompat/cleanroom/GlobalPropertiesKeysCompat.java new file mode 100644 index 0000000..7c84d5c --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/cleanroom/GlobalPropertiesKeysCompat.java @@ -0,0 +1,13 @@ +package gg.essential.mixincompat.cleanroom; + +import gg.essential.CompatMixin; +import org.spongepowered.asm.launch.GlobalProperties; + +@CompatMixin(GlobalProperties.Keys.class) +public class GlobalPropertiesKeysCompat { + // Required for MixinBooter to not crash on boot + // https://github.com/CleanroomMC/MixinBooter/blob/05fc6c7b4b36a714c90eb4cc2f2364c681da0bc8/src/main/java/zone/rong/mixinbooter/MixinBooterPlugin.java#L87 + // https://github.com/CleanroomMC/MixinBooter/blob/05fc6c7b4b36a714c90eb4cc2f2364c681da0bc8/src/main/java/zone/rong/mixinbooter/MixinBooterPlugin.java#L198 + // https://github.com/CleanroomMC/MixinBooter-UniMix/blob/9d4b487ed32501137645cdf0da484b076f0bfaf4/src/main/java/org/spongepowered/asm/launch/GlobalProperties.java#L55 + public static final GlobalProperties.Keys CLEANROOM_DISABLE_MIXIN_CONFIGS = GlobalProperties.Keys.of("mixin.cleanroom.disablemixinconfigs"); +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/cleanroom/IMixinProcessor.java b/mixin/src/main/java/gg/essential/mixincompat/cleanroom/IMixinProcessor.java new file mode 100644 index 0000000..4d0e3c1 --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/cleanroom/IMixinProcessor.java @@ -0,0 +1,16 @@ +package gg.essential.mixincompat.cleanroom; + +import gg.essential.CompatMixin; +import org.spongepowered.asm.mixin.extensibility.IMixinConfig; +import org.spongepowered.asm.service.IMixinService; + +import java.util.List; + +// Interface added in Cleanroom's Mixin fork to expose certain internals +// https://github.com/CleanroomMC/MixinBooter-UniMix/blob/9d4b487ed32501137645cdf0da484b076f0bfaf4/src/main/java/org/spongepowered/asm/mixin/extensibility/IMixinProcessor.java +@CompatMixin(target = "org.spongepowered.asm.mixin.extensibility.IMixinProcessor", createTarget = true) +public interface IMixinProcessor { + IMixinService getMixinService(); + List getMixinConfigs(); + List getPendingMixinConfigs(); +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/cleanroom/IMixinTransformerCompat.java b/mixin/src/main/java/gg/essential/mixincompat/cleanroom/IMixinTransformerCompat.java new file mode 100644 index 0000000..811e683 --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/cleanroom/IMixinTransformerCompat.java @@ -0,0 +1,11 @@ +package gg.essential.mixincompat.cleanroom; + +import gg.essential.CompatMixin; +import org.spongepowered.asm.mixin.transformer.IMixinTransformer; + +@CompatMixin(IMixinTransformer.class) +public interface IMixinTransformerCompat { + // Method which exists in Cleanroom's Mixin fork, and MixinBooter crashes if it's missing + // https://github.com/CleanroomMC/MixinBooter/blob/05fc6c7b4b36a714c90eb4cc2f2364c681da0bc8/src/main/java/zone/rong/mixinbooter/mixin/LoadControllerMixin.java#L115 + default IMixinProcessor getProcessor() { throw new UnsupportedOperationException(); } +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/cleanroom/MixinConfigCompat.java b/mixin/src/main/java/gg/essential/mixincompat/cleanroom/MixinConfigCompat.java new file mode 100644 index 0000000..194dad6 --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/cleanroom/MixinConfigCompat.java @@ -0,0 +1,26 @@ +package gg.essential.mixincompat.cleanroom; + +import gg.essential.CompatMixin; +import gg.essential.CompatShadow; +import org.spongepowered.asm.launch.GlobalProperties; +import org.spongepowered.asm.mixin.MixinEnvironment; +import org.spongepowered.asm.mixin.transformer.Config; + +import java.util.Collections; +import java.util.Set; + +@CompatMixin(target = "org.spongepowered.asm.mixin.transformer.MixinConfig") +public class MixinConfigCompat { + // See GlobalPropertiesKeysCompat + // https://github.com/CleanroomMC/MixinBooter-UniMix/blob/9d4b487ed32501137645cdf0da484b076f0bfaf4/src/main/java/org/spongepowered/asm/mixin/transformer/MixinConfig.java#L1400 + static Config create(String configFile, MixinEnvironment outer) { + Set disabledMixinConfigs = GlobalProperties.get(GlobalPropertiesKeysCompat.CLEANROOM_DISABLE_MIXIN_CONFIGS, Collections.emptySet()); + if (disabledMixinConfigs.contains(configFile)) { + return null; + } + return create$original(configFile, outer); + } + + @CompatShadow(original = "create") + static Config create$original(String configFile, MixinEnvironment outer) { throw new LinkageError(); } +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/cleanroom/MixinProcessorCompat.java b/mixin/src/main/java/gg/essential/mixincompat/cleanroom/MixinProcessorCompat.java new file mode 100644 index 0000000..a594b6f --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/cleanroom/MixinProcessorCompat.java @@ -0,0 +1,24 @@ +package gg.essential.mixincompat.cleanroom; + +import gg.essential.CompatMixin; +import gg.essential.CompatShadow; +import org.spongepowered.asm.mixin.extensibility.IMixinConfig; +import org.spongepowered.asm.service.IMixinService; + +import java.util.Collections; +import java.util.List; + +@CompatMixin(target = "org.spongepowered.asm.mixin.transformer.MixinProcessor") +public class MixinProcessorCompat implements IMixinProcessor { + @CompatShadow + private IMixinService service; + @CompatShadow + private List configs; + @CompatShadow + private List pendingConfigs; + + // See IMixinTransformerCompat + @Override public IMixinService getMixinService() { return service; } + @Override public List getMixinConfigs() { return Collections.unmodifiableList(configs); } + @Override public List getPendingMixinConfigs() { return Collections.unmodifiableList(pendingConfigs); } +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/cleanroom/MixinTransformerCompat.java b/mixin/src/main/java/gg/essential/mixincompat/cleanroom/MixinTransformerCompat.java new file mode 100644 index 0000000..03d4366 --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/cleanroom/MixinTransformerCompat.java @@ -0,0 +1,15 @@ +package gg.essential.mixincompat.cleanroom; + +import gg.essential.CompatMixin; +import gg.essential.CompatShadow; + +@CompatMixin(target = "org.spongepowered.asm.mixin.transformer.MixinTransformer") +public class MixinTransformerCompat { + @CompatShadow + private MixinProcessorCompat processor; + + // Overrides the interface method added in IMixinTransformerCompat + public IMixinProcessor getProcessor() { + return processor; + } +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/cleanroom/ProxyCompat.java b/mixin/src/main/java/gg/essential/mixincompat/cleanroom/ProxyCompat.java new file mode 100644 index 0000000..c9103b1 --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/cleanroom/ProxyCompat.java @@ -0,0 +1,17 @@ +package gg.essential.mixincompat.cleanroom; + +import gg.essential.CompatAccessTransformer; +import gg.essential.CompatMixin; +import gg.essential.CompatShadow; +import gg.essential.mixincompat.MixinTransformerCompat; +import org.objectweb.asm.Opcodes; +import org.spongepowered.asm.mixin.transformer.Proxy; + +@CompatMixin(Proxy.class) +public class ProxyCompat { + // This field is public in Cleanroom's Mixin fork, and MixinBooter accesses it as such + // https://github.com/CleanroomMC/MixinBooter/blob/05fc6c7b4b36a714c90eb4cc2f2364c681da0bc8/src/main/java/zone/rong/mixinbooter/mixin/LoadControllerMixin.java#L115 + @CompatShadow + @CompatAccessTransformer(add = Opcodes.ACC_PUBLIC, remove = Opcodes.ACC_PRIVATE) + private static MixinTransformerCompat transformer; +} diff --git a/mixin/src/main/java/gg/essential/mixincompat/cleanroom/package-info.java b/mixin/src/main/java/gg/essential/mixincompat/cleanroom/package-info.java new file mode 100644 index 0000000..4577c8b --- /dev/null +++ b/mixin/src/main/java/gg/essential/mixincompat/cleanroom/package-info.java @@ -0,0 +1,9 @@ +/** + * This package contains patches to make Cleanroom's MixinBooter (which relies Cleanroom's Mixin fork) not crash + * (whether it is fully functional is a different question; it at least appears to be mostly functional) + * when its custom Mixin fork is replaced by our patched Mixin. + * + * @link https://github.com/CleanroomMC/MixinBooter/tree/05fc6c7b4b36a714c90eb4cc2f2364c681da0bc8/ + * @link https://github.com/CleanroomMC/MixinBooter-UniMix/tree/9d4b487ed32501137645cdf0da484b076f0bfaf4/ + */ +package gg.essential.mixincompat.cleanroom; \ No newline at end of file From 225b93fff09c7c5a548c6886a3d4d20670a6f5f4 Mon Sep 17 00:00:00 2001 From: Jonas Herzig Date: Mon, 14 Jul 2025 15:55:29 +0200 Subject: [PATCH 35/37] stage2/lw: Support extracting MixinExtras versions from third-party jars This way if a third-party mod ships a MixinExtras version newer than anything any EssentialLoader-using mod ships, we'll correctly use the newer version from the third-party mod instead of simply the latest (but older) version from all our jars. E.g. MixinBooter currently ships MixinExtras 0.5.0-rc.1, so if an EssentialLoader-using mod very reasonably ships 0.4.1, we'd simply be loading 0.4.1 prior to this commit. --- .../gg/essential/loader/stage2/Loader.java | 33 +++++ .../stage2/util/MixinExtrasExtractor.java | 117 ++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/util/MixinExtrasExtractor.java diff --git a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/Loader.java b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/Loader.java index f2aa2be..5d67e93 100644 --- a/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/Loader.java +++ b/stage2/launchwrapper/src/main/java/gg/essential/loader/stage2/Loader.java @@ -6,6 +6,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import gg.essential.loader.stage2.relaunch.Relaunch; +import gg.essential.loader.stage2.util.MixinExtrasExtractor; import net.minecraft.launchwrapper.ITweaker; import net.minecraft.launchwrapper.Launch; import org.apache.logging.log4j.LogManager; @@ -217,6 +218,7 @@ private JarInfo load(JarInfo parent, Path jar, JsonObject descriptor, Map")) { + clinit = method; + break; + } + } + if (clinit == null) throw new UnsupportedOperationException("Failed to find static initializer"); + + AbstractInsnNode insn = clinit.instructions.getFirst(); + + // Search for field assignment + while (insn != null) { + if (insn instanceof FieldInsnNode && ((FieldInsnNode) insn).name.equals(lastEnumField.name)) { + break; + } + insn = insn.getNext(); + } + if (insn == null) throw new UnsupportedOperationException("Failed to find enum field initializer"); + + // Search backwards for the version string + while (insn != null) { + if (insn instanceof LdcInsnNode && ((LdcInsnNode) insn).cst instanceof String) { + return (String) ((LdcInsnNode) insn).cst; + } + insn = insn.getPrevious(); + } + + throw new UnsupportedOperationException("Failed to find version argument"); + } catch (Exception e) { + LOGGER.error("Failed to determine version of MixinExtras in {}", jar, e); + return null; + } + } + + public static void extractMixinExtras(Path sourceJar, Path extractedJar, String version) throws IOException { + // Create manifest file + // One is implicitly required by LaunchClassLoader, otherwise won't be declaring `Package`s for the classes in + // the jar, and MixinExtras initialization code will consequently NPE. + Manifest manifest = new Manifest(); + // and while we're at it, may as well set the correct version + manifest.getMainAttributes().putValue("Implementation-Version", version); + + // Create empty jar file + try (OutputStream out = Files.newOutputStream(extractedJar, StandardOpenOption.TRUNCATE_EXISTING)) { + new JarOutputStream(out, manifest).close(); + } + + // Copy MixinExtras package from source jar to our new jar + try (FileSystem srcFs = FileSystems.newFileSystem(sourceJar, (ClassLoader) null)) { + try (FileSystem dstFs = FileSystems.newFileSystem(extractedJar, (ClassLoader) null)) { + // Note: These are `toAbsolutePath`ed as a workaround for ZipFileSystem sometimes returning absolute + // `Path`s from `walk` even when the `start` `Path` is relative. + Path srcRoot = srcFs.getPath(MIXINEXTRAS_PACKAGE_PATH).toAbsolutePath(); + Path dstRoot = dstFs.getPath(MIXINEXTRAS_PACKAGE_PATH).toAbsolutePath(); + + try (Stream stream = Files.walk(srcRoot)) { + for (Path src : (Iterable) stream::iterator) { + src = src.toAbsolutePath(); // necessary because *see note above* + Path dst = dstRoot.resolve(srcRoot.relativize(src)); + Files.createDirectories(dst.getParent()); + Files.copy(src, dst); + } + } + } + } + } +} From e3b6f2620aa4c62f863a16ca69de46aa8378cd2b Mon Sep 17 00:00:00 2001 From: Traben Date: Sun, 9 Nov 2025 21:20:18 +1000 Subject: [PATCH 36/37] add stage 2 compatibility for FabricLoader subclasses e.g. lunar client Linear: EM-3472 --- .../loader/stage2/EssentialLoader.java | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/stage2/fabric/src/main/java/gg/essential/loader/stage2/EssentialLoader.java b/stage2/fabric/src/main/java/gg/essential/loader/stage2/EssentialLoader.java index 1ba2058..9848867 100644 --- a/stage2/fabric/src/main/java/gg/essential/loader/stage2/EssentialLoader.java +++ b/stage2/fabric/src/main/java/gg/essential/loader/stage2/EssentialLoader.java @@ -347,10 +347,26 @@ private Object createCandidate(Path path, URL url, Object metadata) throws Class } } + private Class findFabricLoaderClass(FabricLoader fabricLoader) throws ClassNotFoundException { + Class clazz = fabricLoader.getClass(); + while (clazz != null) { + try { + clazz.getDeclaredField("modMap"); + clazz.getDeclaredField("mods"); + clazz.getDeclaredField("entrypointStorage"); + clazz.getDeclaredField("adapterMap"); + return clazz; + } catch (NoSuchFieldException ignored) { + clazz = clazz.getSuperclass(); + } + } + throw new ClassNotFoundException("Could not find the required fields [modMap, mods, entrypointStorage, adapterMap] anywhere in the class hierarchy of FabricLoader.getInstance()"); + } + @SuppressWarnings("unchecked") private void injectFakeMod(final Path path, final URL url, final ModMetadata metadata) throws NoSuchFieldException, IllegalAccessException, NoSuchMethodException, InvocationTargetException, ClassNotFoundException, InstantiationException { FabricLoader fabricLoader = FabricLoader.getInstance(); - Class fabricLoaderClass = fabricLoader.getClass(); + Class fabricLoaderClass = findFabricLoaderClass(fabricLoader); Class ModContainerImpl; try { // fabric-loader 0.12 From 7365e6fd6e0ede36d184a66ddfb9b798977660c9 Mon Sep 17 00:00:00 2001 From: Traben Date: Sun, 9 Nov 2025 21:21:00 +1000 Subject: [PATCH 37/37] Revert "add stage 2 compatibility for FabricLoader subclasses e.g. lunar client" This reverts commit e3b6f2620aa4c62f863a16ca69de46aa8378cd2b. --- .../loader/stage2/EssentialLoader.java | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/stage2/fabric/src/main/java/gg/essential/loader/stage2/EssentialLoader.java b/stage2/fabric/src/main/java/gg/essential/loader/stage2/EssentialLoader.java index 9848867..1ba2058 100644 --- a/stage2/fabric/src/main/java/gg/essential/loader/stage2/EssentialLoader.java +++ b/stage2/fabric/src/main/java/gg/essential/loader/stage2/EssentialLoader.java @@ -347,26 +347,10 @@ private Object createCandidate(Path path, URL url, Object metadata) throws Class } } - private Class findFabricLoaderClass(FabricLoader fabricLoader) throws ClassNotFoundException { - Class clazz = fabricLoader.getClass(); - while (clazz != null) { - try { - clazz.getDeclaredField("modMap"); - clazz.getDeclaredField("mods"); - clazz.getDeclaredField("entrypointStorage"); - clazz.getDeclaredField("adapterMap"); - return clazz; - } catch (NoSuchFieldException ignored) { - clazz = clazz.getSuperclass(); - } - } - throw new ClassNotFoundException("Could not find the required fields [modMap, mods, entrypointStorage, adapterMap] anywhere in the class hierarchy of FabricLoader.getInstance()"); - } - @SuppressWarnings("unchecked") private void injectFakeMod(final Path path, final URL url, final ModMetadata metadata) throws NoSuchFieldException, IllegalAccessException, NoSuchMethodException, InvocationTargetException, ClassNotFoundException, InstantiationException { FabricLoader fabricLoader = FabricLoader.getInstance(); - Class fabricLoaderClass = findFabricLoaderClass(fabricLoader); + Class fabricLoaderClass = fabricLoader.getClass(); Class ModContainerImpl; try { // fabric-loader 0.12