diff --git a/.gitignore b/.gitignore index 4f41d45d..55d7972b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .gradle +.gradle-user-home/ build/ +run/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ diff --git a/LEGACY.md b/LEGACY.md index e7fab58f..1ae3a470 100644 --- a/LEGACY.md +++ b/LEGACY.md @@ -1,6 +1,6 @@ # ModDevGradle Legacy Forge Plugin ModDevGradle has a secondary plugin (ID: `net.neoforged.moddev.legacyforge`, released alongside the normal plugin with the same version) -that adds support for developing mods against MinecraftForge and Vanilla Minecraft versions 1.17 up to 1.20.1. +that adds support for developing mods against MinecraftForge and Vanilla Minecraft versions 1.12.2 up to 1.20.1. The legacy plugin is an "addon" plugin, meaning it operates on top of the normal plugin. This means that the APIs normally used are also available when using the legacy plugin. @@ -41,6 +41,30 @@ legacyForge { } ``` +For Minecraft 1.12.2, use a Forge version such as: + +```groovy +legacyForge { + version = "1.12.2-14.23.5.2860" +} +``` + +MDG will use Forge's `userdev3` artifact for 1.12.2. This requires an NFRT build with legacy MCP support +(`--mcp-mappings` and the legacy MCP mapping result IDs). Until that NFRT release is the MDG default, set +`neoForge.neoFormRuntime.version` to a compatible published version or substitute a local NFRT legacy build. + +NFRT's 1.12.2 legacy MCP pipeline uses `de.oceanlabs.mcp:mcp_stable:39-1.12@zip` by default; if you need a +different MCP CSV mapping zip, configure it explicitly: + +```groovy +legacyForge { + enable { + forgeVersion = "1.12.2-14.23.5.2860" + mcpMappings = "de.oceanlabs.mcp:mcp_stable:39-1.12@zip" + } +} +``` + ## Reobfuscating artifacts Forge used SRG mappings as intermediary mappings in 1.20.1 and below. While your mod is developed against the mappings provided by Mojang (known as official mappings), you need to reobfuscate it to SRG mappings for it to work in production. diff --git a/build.gradle b/build.gradle index 0ac18c2b..dc3d5c45 100644 --- a/build.gradle +++ b/build.gradle @@ -71,9 +71,12 @@ sourceSets { runtimeClasspath += java8.output } legacy + mcpforge test { compileClasspath += legacy.output runtimeClasspath += legacy.output + compileClasspath += mcpforge.output + runtimeClasspath += mcpforge.output } } @@ -83,6 +86,7 @@ configurations { // Place shaded dependencies into `compileOnly` so that they do not leak into our publications' dependencies. compileOnly.extendsFrom shaded legacyCompileOnly.extendsFrom shaded + mcpforgeCompileOnly.extendsFrom shaded testCompileOnly.extendsFrom shaded testRuntimeOnly.extendsFrom shaded shadowRuntimeElements { @@ -113,6 +117,8 @@ dependencies { compileOnly "com.intellij:annotations:9.0.4" testCompileOnly "com.intellij:annotations:9.0.4" shaded "com.google.code.gson:gson:2.11.0" + shaded "org.ow2.asm:asm-commons:9.7.1" + shaded "org.tukaani:xz:1.10" implementation "gradle.plugin.org.jetbrains.gradle.plugin.idea-ext:gradle-idea-ext:1.2" shaded "net.neoforged:EclipseLaunchConfigs:0.1.11" shaded "net.neoforged:VscLaunchConfigs:1.0.8" @@ -134,6 +140,11 @@ dependencies { legacyImplementation(sourceSets.main.output) legacyImplementation(sourceSets.java8.output) legacyImplementation gradleApi() + + mcpforgeImplementation(sourceSets.main.output) + mcpforgeImplementation(sourceSets.legacy.output) + mcpforgeImplementation(sourceSets.java8.output) + mcpforgeImplementation gradleApi() } java { @@ -147,6 +158,7 @@ jar { archiveClassifier = 'slim' from sourceSets.java8.output from sourceSets.legacy.output + from sourceSets.mcpforge.output } shadowJar { @@ -154,6 +166,7 @@ shadowJar { from sourceSets.java8.output from sourceSets.legacy.output + from sourceSets.mcpforge.output configurations = [project.configurations.shaded] enableRelocation = true @@ -164,8 +177,9 @@ assemble.dependsOn shadowJar tasks.named("compileJava8Java").configure { javaCompiler = javaToolchains.compilerFor { - languageVersion = JavaLanguageVersion.of(8) + languageVersion = JavaLanguageVersion.of(17) } + options.release = 8 } javadoc { @@ -205,7 +219,7 @@ gradlePlugin { id = 'net.neoforged.moddev.legacyforge' implementationClass = 'net.neoforged.moddevgradle.boot.LegacyForgeModDevPlugin' displayName = "Mod Development Plugin for Legacy Forge" - description = "This plugin helps you create Minecraft mods using the Forge platform, up to 1.20.1" + description = "This plugin helps you create Minecraft mods using the Forge platform, from 1.12.2 up to 1.20.1" tags = ["minecraft", "neoforge", "forge", "java", "mod"] } repositories { @@ -219,9 +233,16 @@ gradlePlugin { id = 'net.neoforged.moddev.legacyforge.repositories' implementationClass = 'net.neoforged.moddevgradle.boot.LegacyRepositoriesPlugin' displayName = "Mod Development Repositories Plugin for Legacy Forge" - description = "This plugin adds the repositories needed for developing Minecraft mods using the Forge platform, up to 1.20.1. It is applied automatically by the legacyforge plugin, but can be applied manually in settings.gradle to make use of Gradle dependency management." + description = "This plugin adds the repositories needed for developing Minecraft mods using the Forge platform, from 1.12.2 up to 1.20.1. It is applied automatically by the legacyforge plugin, but can be applied manually in settings.gradle to make use of Gradle dependency management." tags = ["minecraft", "neoforge", "forge", "java", "mod"] } + mcpforge { + id = 'net.neoforged.moddev.mcpforge' + implementationClass = 'net.neoforged.moddevgradle.boot.McpForgePlugin' + displayName = "Mod Development Plugin for Legacy Forge 1.12.2 (MCP)" + description = "This plugin helps you create Minecraft mods using the Forge platform for 1.12.2 (MCP mappings). It is the isolated home for the 1.12.2/MCP-specific toolchain, distinct from legacyforge." + tags = ["minecraft", "forge", "java", "mod", "1.12.2", "mcp"] + } } } @@ -337,6 +358,9 @@ abstract class GenerateRepoFilter extends DefaultTask { artifacts.add(new Artifact(location[0], location[1])) } } + // Required by Forge 1.12.2 and still hosted on Mojang's libraries maven, but not + // referenced by Mojang's vanilla version manifests. + artifacts.add(new Artifact('lzma', 'lzma')) final artifactList = artifacts.toList() Collections.sort(artifactList) final clazz = """ diff --git a/legacytest/forge1122/build.gradle b/legacytest/forge1122/build.gradle new file mode 100644 index 00000000..619db5d2 --- /dev/null +++ b/legacytest/forge1122/build.gradle @@ -0,0 +1,39 @@ +plugins { + id 'net.neoforged.moddev.legacyforge' +} + +repositories { + // The Forge 1.12.2 smoke can use a locally published NFRT build until a compatible release is default. + mavenLocal() +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(8) + } +} + +neoFormRuntime { + version = '2.0.19-legacy' +} + +legacyForge { + enable { + forgeVersion = '1.12.2-14.23.5.2860' + mcpMappings = 'de.oceanlabs.mcp:mcp_stable:39-1.12@zip' + // Recompilation now works end-to-end: NFRT runs ForgeFlower/MCPCleanup/recompile, with the legacy + // Java-8 toolchain auto-provisioned by Gradle (Azul Zulu on aarch64) and the legacy Maven repositories + // (Maven Central, Mojang libraries, Forge Maven) passed to NFRT via CreateMinecraftArtifacts. + disableRecompilation = false + } + runs { + client { + client() + } + } + mods { + mymod1122 { + sourceSet(sourceSets.main) + } + } +} diff --git a/legacytest/forge1122/src/main/java/mymod1122/MyMod1122.java b/legacytest/forge1122/src/main/java/mymod1122/MyMod1122.java new file mode 100644 index 00000000..5da85bba --- /dev/null +++ b/legacytest/forge1122/src/main/java/mymod1122/MyMod1122.java @@ -0,0 +1,8 @@ +package mymod1122; + +import net.minecraftforge.fml.common.Mod; + +@Mod(modid = MyMod1122.MOD_ID, name = "Legacy 1.12.2 Smoke Test", version = "1.0.0") +public class MyMod1122 { + public static final String MOD_ID = "mymod1122"; +} diff --git a/legacytest/forge1122/src/main/resources/mcmod.info b/legacytest/forge1122/src/main/resources/mcmod.info new file mode 100644 index 00000000..88fa5c4c --- /dev/null +++ b/legacytest/forge1122/src/main/resources/mcmod.info @@ -0,0 +1,16 @@ +[ + { + "modid": "mymod1122", + "name": "Legacy 1.12.2 Smoke Test", + "description": "Minimal Forge 1.12.2 fixture for ModDevGradle.", + "version": "1.0.0", + "mcversion": "1.12.2", + "url": "", + "updateUrl": "", + "authorList": [], + "credits": "", + "logoFile": "", + "screenshots": [], + "dependencies": [] + } +] diff --git a/legacytest/forge1122/src/main/resources/pack.mcmeta b/legacytest/forge1122/src/main/resources/pack.mcmeta new file mode 100644 index 00000000..c7664ada --- /dev/null +++ b/legacytest/forge1122/src/main/resources/pack.mcmeta @@ -0,0 +1,6 @@ +{ + "pack": { + "pack_format": 3, + "description": "Legacy 1.12.2 smoke test resources" + } +} diff --git a/legacytest/settings.gradle b/legacytest/settings.gradle index 912cdaf9..a61995d8 100644 --- a/legacytest/settings.gradle +++ b/legacytest/settings.gradle @@ -6,5 +6,6 @@ plugins { includeBuild '..' include 'forge' +include 'forge1122' include 'forgedownstream' include 'nonmc' diff --git a/src/generated/java/net/neoforged/moddevgradle/internal/generated/MojangRepositoryFilter.java b/src/generated/java/net/neoforged/moddevgradle/internal/generated/MojangRepositoryFilter.java index 37aa5259..1d7b031d 100644 --- a/src/generated/java/net/neoforged/moddevgradle/internal/generated/MojangRepositoryFilter.java +++ b/src/generated/java/net/neoforged/moddevgradle/internal/generated/MojangRepositoryFilter.java @@ -58,6 +58,7 @@ public static void filter(org.gradle.api.artifacts.repositories.RepositoryConten filter.includeModule("io.netty", "netty-transport-native-unix-common"); filter.includeModule("it.unimi.dsi", "fastutil"); filter.includeModule("java3d", "vecmath"); + filter.includeModule("lzma", "lzma"); filter.includeModule("net.java.dev.jna", "jna"); filter.includeModule("net.java.dev.jna", "jna-platform"); filter.includeModule("net.java.dev.jna", "platform"); @@ -92,6 +93,7 @@ public static void filter(org.gradle.api.artifacts.repositories.RepositoryConten filter.includeModule("org.lwjgl.lwjgl", "lwjgl"); filter.includeModule("org.lwjgl.lwjgl", "lwjgl-platform"); filter.includeModule("org.lwjgl.lwjgl", "lwjgl_util"); + filter.includeModule("org.lwjgl.lwjgl", "parent"); filter.includeModule("org.lz4", "lz4-java"); filter.includeModule("org.ow2.asm", "asm"); filter.includeModule("org.ow2.asm", "asm-all"); diff --git a/src/java8/java/net/neoforged/moddevgradle/boot/McpForgePlugin.java b/src/java8/java/net/neoforged/moddevgradle/boot/McpForgePlugin.java new file mode 100644 index 00000000..14ddd5bd --- /dev/null +++ b/src/java8/java/net/neoforged/moddevgradle/boot/McpForgePlugin.java @@ -0,0 +1,14 @@ +package net.neoforged.moddevgradle.boot; + +import org.gradle.api.Project; + +/** + * Boot trampoline for the {@code net.neoforged.moddev.mcpforge} plugin (the isolated 1.12.2/MCP toolchain). + * Mirrors {@link LegacyForgeModDevPlugin}. Kept in the java8 source set so it can be loaded by the + * bootstrap classloader before the main plugin classpath is wired up. + */ +public class McpForgePlugin extends TrampolinePlugin { + public McpForgePlugin() { + super("net.neoforged.moddevgradle.mcpforge.internal.McpForgeModDevPlugin"); + } +} diff --git a/src/legacy/java/net/neoforged/moddevgradle/legacyforge/internal/LegacyForgeLibraryMetadataRule.java b/src/legacy/java/net/neoforged/moddevgradle/legacyforge/internal/LegacyForgeLibraryMetadataRule.java new file mode 100644 index 00000000..42251150 --- /dev/null +++ b/src/legacy/java/net/neoforged/moddevgradle/legacyforge/internal/LegacyForgeLibraryMetadataRule.java @@ -0,0 +1,60 @@ +package net.neoforged.moddevgradle.legacyforge.internal; + +import java.util.ArrayList; +import org.gradle.api.artifacts.CacheableRule; +import org.gradle.api.artifacts.ComponentMetadataContext; +import org.gradle.api.artifacts.ComponentMetadataRule; +import org.gradle.api.artifacts.DirectDependenciesMetadata; +import org.gradle.api.artifacts.DirectDependencyMetadata; + +/** + * Normalizes old Forge library metadata that predates today's Maven Central coordinates or only + * exists as jar-only artifacts on Forge's Maven. + */ +@CacheableRule +public class LegacyForgeLibraryMetadataRule implements ComponentMetadataRule { + @Override + public void execute(ComponentMetadataContext context) { + var id = context.getDetails().getId(); + if (!id.getVersion().equals("1.12.2-14.23.5.2860")) { + return; + } + + context.getDetails().allVariants(variant -> variant.withDependencies(dependencies -> { + removeMatching(dependencies, dependency -> { + var group = dependency.getGroup(); + var name = dependency.getName(); + return group.equals("org.scala-lang.plugins") + || group.equals("org.scala-lang") && name.equals("scala-actors-migration_2.11"); + }); + + replaceDependency(dependencies, "org.scala-lang:scala-parser-combinators_2.11:1.0.1", + "org.scala-lang.modules:scala-parser-combinators_2.11:1.0.1"); + replaceDependency(dependencies, "org.scala-lang:scala-swing_2.11:1.0.1", + "org.scala-lang.modules:scala-swing_2.11:1.0.1"); + replaceDependency(dependencies, "org.scala-lang:scala-xml_2.11:1.0.2", + "org.scala-lang.modules:scala-xml_2.11:1.0.2"); + })); + } + + private static void replaceDependency(DirectDependenciesMetadata dependencies, + String oldNotation, + String newNotation) { + var oldParts = oldNotation.split(":"); + removeMatching(dependencies, dependency -> dependency.getGroup().equals(oldParts[0]) + && dependency.getName().equals(oldParts[1]) + && dependency.getVersionConstraint().getRequiredVersion().equals(oldParts[2])); + dependencies.add(newNotation); + } + + private static void removeMatching(DirectDependenciesMetadata dependencies, + java.util.function.Predicate predicate) { + var toRemove = new ArrayList(); + for (var dependency : dependencies) { + if (predicate.test(dependency)) { + toRemove.add(dependency); + } + } + dependencies.removeAll(toRemove); + } +} diff --git a/src/legacy/java/net/neoforged/moddevgradle/legacyforge/tasks/PopulateForgeGradleMcpCache.java b/src/legacy/java/net/neoforged/moddevgradle/legacyforge/tasks/PopulateForgeGradleMcpCache.java new file mode 100644 index 00000000..996853d4 --- /dev/null +++ b/src/legacy/java/net/neoforged/moddevgradle/legacyforge/tasks/PopulateForgeGradleMcpCache.java @@ -0,0 +1,176 @@ +package net.neoforged.moddevgradle.legacyforge.tasks; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.zip.ZipFile; +import org.gradle.api.DefaultTask; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.TaskAction; + +/** + * Populates the ForgeGradle-2.x MCP cache directory with the MCP data ModDevGradle produced, so legacy tooling and + * 1.12.2 mods that hardcode the ForgeGradle-2 cache layout (and read it at runtime via {@code GradleStart} system + * properties) keep finding the mappings. + *

+ * ForgeGradle-2 stores MCP data under {@code ~/.gradle/caches/minecraft/de/oceanlabs/mcp///}: the CSV + * files directly there, and the SRG maps under {@code ...///srgs/}. This task mirrors the + * NFRT-produced artifacts into that layout. The {@code srg-mcp}/{@code mcp-srg}/{@code notch-srg} maps are copied + * verbatim; when a {@code notch}->SRG map is available the remaining FG-2 maps ({@code srg-notch}, {@code notch-mcp}, + * {@code mcp-notch}) are derived by inverting/composing the SRG-format text, so every {@code GradleStart.srg.*} + * property ForgeGradle-2's {@code GradleStartCommon} would have set resolves to a real file. + */ +public abstract class PopulateForgeGradleMcpCache extends DefaultTask { + + /** Gradle dependency notation of the legacy MCP mapping zip, e.g. {@code de.oceanlabs.mcp:mcp_stable:39-1.12@zip}. */ + @Input + public abstract Property getMcpMappings(); + + /** The Minecraft version, e.g. {@code 1.12.2}. */ + @Input + public abstract Property getMinecraftVersion(); + + /** + * The ForgeGradle-2 cache base directory to populate, i.e. + * {@code /caches/minecraft/de/oceanlabs/mcp//}. Resolved at configuration time so + * the task remains configuration-cache compatible. + */ + @Input + public abstract Property getCacheBaseDirectory(); + + /** The MCP CSV mapping zip (methods/fields/params), as produced by NFRT's {@code csvMapping} result. */ + @InputFile + public abstract RegularFileProperty getCsvMappings(); + + /** The SRG->MCP SRG mapping file (NFRT {@code intermediaryToNamedMapping}), mirrors {@code srgs/srg-mcp.srg}. */ + @InputFile + public abstract RegularFileProperty getSrgToMcpMappings(); + + /** The MCP->SRG mapping file (NFRT {@code namedToIntermediaryMapping}), mirrors {@code srgs/mcp-srg.srg}. */ + @InputFile + public abstract RegularFileProperty getMcpToSrgMappings(); + + /** The notch->SRG mapping file (NFRT {@code notchToIntermediaryMapping}); when present, {@code srgs/notch-srg.srg} and the derived maps are written. */ + @InputFile + @Optional + public abstract RegularFileProperty getNotchToSrgMappings(); + + @TaskAction + public void populate() throws IOException { + var minecraftVersion = getMinecraftVersion().get(); + var cacheBase = Path.of(getCacheBaseDirectory().get()); + + extractCsvs(getCsvMappings().get().getAsFile().toPath(), cacheBase); + + var srgsDir = cacheBase.resolve(minecraftVersion).resolve("srgs"); + Files.createDirectories(srgsDir); + + var srgToMcp = getSrgToMcpMappings().get().getAsFile().toPath(); + var mcpToSrg = getMcpToSrgMappings().get().getAsFile().toPath(); + + copyTo(srgToMcp, srgsDir.resolve("srg-mcp.srg")); + copyTo(mcpToSrg, srgsDir.resolve("mcp-srg.srg")); + + if (getNotchToSrgMappings().isPresent()) { + var notchToSrg = getNotchToSrgMappings().get().getAsFile().toPath(); + copyTo(notchToSrg, srgsDir.resolve("notch-srg.srg")); + + var notchSrg = readSrg(notchToSrg); + var srgMcp = readSrg(srgToMcp); + var composed = compose(notchSrg, srgMcp); + + writeSrg(notchSrg.inverse(), srgsDir.resolve("srg-notch.srg")); + writeSrg(composed, srgsDir.resolve("notch-mcp.srg")); + writeSrg(composed.inverse(), srgsDir.resolve("mcp-notch.srg")); + } + + getLogger().lifecycle("Populated ForgeGradle-2 MCP cache at {}", cacheBase); + } + + private static void copyTo(Path src, Path dst) throws IOException { + Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING); + } + + private static void extractCsvs(Path mappingsZip, Path targetDir) throws IOException { + Files.createDirectories(targetDir); + try (var zip = new ZipFile(mappingsZip.toFile())) { + for (String csv : new String[]{"methods.csv", "fields.csv", "params.csv"}) { + var entry = zip.getEntry(csv); + if (entry == null) continue; + try (InputStream in = zip.getInputStream(entry)) { + Files.copy(in, targetDir.resolve(csv), StandardCopyOption.REPLACE_EXISTING); + } + } + } + } + + private static SrgMap readSrg(Path file) throws IOException { + var map = new SrgMap(); + try (var reader = Files.newBufferedReader(file)) { + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) continue; + // CL:/FD: carry one pair (left, right); MD: carries (leftOwner/leftName leftDesc, rightOwner/rightName rightDesc). + // Splitting with a 4-way limit leaves the right side of MD: (name + desc) intact as a single token. + var parts = line.split("\\s+", 4); + switch (parts[0]) { + case "CL:", "FD:" -> { + if (parts.length >= 3) map.put(parts[0], parts[1], parts[2]); + } + case "MD:" -> { + if (parts.length >= 4) map.put("MD:", parts[1] + " " + parts[2], parts[3]); + } + } + } + } + return map; + } + + private static void writeSrg(SrgMap map, Path dst) throws IOException { + Files.createDirectories(dst.getParent()); + try (var writer = Files.newBufferedWriter(dst)) { + for (var entry : map.entries()) { + writer.write(entry.tag() + " " + entry.left() + " " + entry.right() + "\n"); + } + } + } + + /** Compose two maps: for each left→mid in {@code first}, emit left→(mid looked up in {@code second}). */ + private static SrgMap compose(SrgMap first, SrgMap second) { + var result = new SrgMap(); + for (var entry : first.entries()) { + var target = second.get(entry.right()); + result.put(entry.tag(), entry.left(), target != null ? target : entry.right()); + } + return result; + } + + private static final class SrgMap { + private final Map byLeft = new LinkedHashMap<>(); + + void put(String tag, String left, String right) { + byLeft.put(left, new Entry(tag, left, right)); + } + String get(String left) { + var e = byLeft.get(left); + return e != null ? e.right() : null; + } + SrgMap inverse() { + var inv = new SrgMap(); + for (var e : byLeft.values()) inv.put(e.tag(), e.right(), e.left()); + return inv; + } + java.util.Collection entries() { return byLeft.values(); } + + private record Entry(String tag, String left, String right) {} + } +} diff --git a/src/main/java/net/neoforged/moddevgradle/internal/CreateLaunchScriptTask.java b/src/main/java/net/neoforged/moddevgradle/internal/CreateLaunchScriptTask.java index 969fe53d..301fe48e 100644 --- a/src/main/java/net/neoforged/moddevgradle/internal/CreateLaunchScriptTask.java +++ b/src/main/java/net/neoforged/moddevgradle/internal/CreateLaunchScriptTask.java @@ -11,6 +11,7 @@ import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import javax.inject.Inject; import net.neoforged.moddevgradle.dsl.RunModel; @@ -50,6 +51,9 @@ abstract class CreateLaunchScriptTask extends DefaultTask { @InputFile abstract Property getProgramArgsFile(); + @InputFile + abstract Property getEnvironmentFile(); + /** * This argument file is only used by the launch shell-scripts. */ @@ -103,22 +107,44 @@ public void createScripts() throws IOException { return; } - var javaCommand = new ArrayList(); - javaCommand.add(getJavaExecutable().get()); - javaCommand.add("@" + getClasspathArgsFile().get().getAsFile().getAbsolutePath()); - javaCommand.add("@" + getVmArgsFile().get()); - javaCommand.add(getModFolders().get().getArgument()); - javaCommand.add(RunUtils.DEV_LAUNCH_MAIN_CLASS); - javaCommand.add("@" + getProgramArgsFile().get()); + var javaCommand = createJavaCommand( + getJavaExecutable().get(), + getClasspathArgsFile().get().getAsFile(), + new File(getVmArgsFile().get()), + getModFolders().get().getArgument(), + new File(getProgramArgsFile().get())); var os = OperatingSystem.current(); + var environment = getMergedEnvironment(); if (os == OperatingSystem.WINDOWS) { - writeLaunchScriptForWindows(javaCommand); + writeLaunchScriptForWindows(javaCommand, environment); } else { - writeLaunchScriptForUnix(javaCommand); + writeLaunchScriptForUnix(javaCommand, environment); } } + private Map getMergedEnvironment() throws IOException { + var environment = new java.util.LinkedHashMap<>(RunUtils.loadEnvironmentFile(new File(getEnvironmentFile().get()))); + environment.putAll(getEnvironment().get()); + return environment; + } + + private static List createJavaCommand( + String javaExecutable, + File classpathArgsFile, + File vmArgsFile, + String modFoldersArgument, + File programArgsFile) throws IOException { + var javaCommand = new ArrayList(); + javaCommand.add(javaExecutable); + javaCommand.addAll(RunUtils.readArgFile(classpathArgsFile)); + javaCommand.addAll(RunUtils.readArgFile(vmArgsFile)); + javaCommand.add(modFoldersArgument); + javaCommand.add(RunUtils.DEV_LAUNCH_MAIN_CLASS); + javaCommand.addAll(RunUtils.readArgFile(programArgsFile)); + return javaCommand; + } + /** * Writes a JVM argument file that would launch the JVM with the same classpath that * Gradle would launch it with. @@ -143,7 +169,7 @@ private void writeClasspathArguments() throws IOException { StringUtils.getNativeCharset()); } - private void writeLaunchScriptForWindows(List javaCommand) throws IOException { + private void writeLaunchScriptForWindows(List javaCommand, Map environment) throws IOException { var lines = new ArrayList(); Collections.addAll(lines, "@echo off", @@ -154,8 +180,8 @@ private void writeLaunchScriptForWindows(List javaCommand) throws IOExce // Switch encoding to Unicode, otherwise the next "cd" might not work with special chars "chcp 65001>nul"); - for (var entry : getEnvironment().get().entrySet()) { - lines.add("set " + escapeBatchScriptArg(entry.getKey()) + "=" + escapeBatchScriptArg(entry.getValue())); + for (var entry : environment.entrySet()) { + lines.add(writeWindowsEnvironmentVariable(entry)); } Collections.addAll(lines, @@ -184,10 +210,25 @@ private String escapeBatchScriptArg(String text) { return text; } - private void writeLaunchScriptForUnix(List javaCommand) throws IOException { + private static String writeWindowsEnvironmentVariable(Map.Entry entry) { + return "set \"" + escapeBatchEnvironmentValue(entry.getKey()) + "=" + escapeBatchEnvironmentValue(entry.getValue()) + "\""; + } + + private static String escapeBatchEnvironmentValue(String text) { + return text + .replace("^", "^^") + .replace("&", "^&") + .replace("|", "^|") + .replace("<", "^<") + .replace(">", "^>") + .replace("%", "%%") + .replace("\"", "^\""); + } + + private void writeLaunchScriptForUnix(List javaCommand, Map environment) throws IOException { var lines = new ArrayList(); - for (var entry : getEnvironment().get().entrySet()) { + for (var entry : environment.entrySet()) { lines.add("export " + escapeShellArg(entry.getKey()) + "=" + escapeShellArg(entry.getValue())); } diff --git a/src/main/java/net/neoforged/moddevgradle/internal/ExtractNatives.java b/src/main/java/net/neoforged/moddevgradle/internal/ExtractNatives.java new file mode 100644 index 00000000..998854b9 --- /dev/null +++ b/src/main/java/net/neoforged/moddevgradle/internal/ExtractNatives.java @@ -0,0 +1,74 @@ +package net.neoforged.moddevgradle.internal; + +import javax.inject.Inject; +import org.gradle.api.DefaultTask; +import org.gradle.api.file.ArchiveOperations; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.DuplicatesStrategy; +import org.gradle.api.file.FileSystemOperations; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Classpath; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.OutputDirectory; +import org.gradle.api.tasks.SkipWhenEmpty; +import org.gradle.api.tasks.TaskAction; + +public abstract class ExtractNatives extends DefaultTask { + private final ArchiveOperations archiveOperations; + private final FileSystemOperations fileSystemOperations; + + @Classpath + @InputFiles + @SkipWhenEmpty + public abstract ConfigurableFileCollection getNativeLibraries(); + + @OutputDirectory + public abstract DirectoryProperty getOutputDirectory(); + + @Input + public abstract Property getEnabledForRun(); + + @Inject + public ExtractNatives(ArchiveOperations archiveOperations, FileSystemOperations fileSystemOperations) { + this.archiveOperations = archiveOperations; + this.fileSystemOperations = fileSystemOperations; + getEnabledForRun().convention(false); + } + + @TaskAction + public void extract() { + if (!getEnabledForRun().get()) { + return; + } + + fileSystemOperations.delete(spec -> spec.delete(getOutputDirectory())); + fileSystemOperations.copy(spec -> { + spec.into(getOutputDirectory()); + spec.setDuplicatesStrategy(DuplicatesStrategy.EXCLUDE); + for (var nativeLibrary : getNativeLibraries()) { + if (isLwjgl2Arm64MacosNativePatch(nativeLibrary.getName())) { + spec.from(archiveOperations.zipTree(nativeLibrary), copy -> copy.exclude("liblwjgl.dylib")); + } else { + spec.from(archiveOperations.zipTree(nativeLibrary)); + } + } + }); + fileSystemOperations.copy(spec -> { + spec.into(getOutputDirectory()); + for (var nativeLibrary : getNativeLibraries()) { + if (isLwjgl2Arm64MacosNativePatch(nativeLibrary.getName())) { + spec.from(archiveOperations.zipTree(nativeLibrary), copy -> { + copy.include("liblwjgl.dylib", "openal.dylib"); + copy.rename("liblwjgl\\.dylib", "liblwjgl.jnilib"); + }); + } + } + }); + } + + private static boolean isLwjgl2Arm64MacosNativePatch(String fileName) { + return fileName.equals("lwjgl-platform-2.9.4-nightly-20150209-mmachina.2-natives-osx.jar"); + } +} diff --git a/src/main/java/net/neoforged/moddevgradle/internal/McpToolchainHooks.java b/src/main/java/net/neoforged/moddevgradle/internal/McpToolchainHooks.java new file mode 100644 index 00000000..464e7e07 --- /dev/null +++ b/src/main/java/net/neoforged/moddevgradle/internal/McpToolchainHooks.java @@ -0,0 +1,31 @@ +package net.neoforged.moddevgradle.internal; + +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.dsl.DependencyFactory; +import org.jetbrains.annotations.ApiStatus; + +/** + * SPI hooks for MCP-based legacy Minecraft versions (1.12.2 up to 1.16.5). + * + *

The main workflow looks up a registered instance via the {@code __mcpHooks} project extension. The + * {@code mcpforge} plugin registers a real implementation (e.g. LWJGL2 Apple Silicon native replacement); when no + * MCP plugin is active, {@link #NOOP} is used and these calls are no-ops. + */ +@ApiStatus.Internal +public interface McpToolchainHooks { + McpToolchainHooks NOOP = new McpToolchainHooks() {}; + + String EXTENSION_NAME = "__mcpHooks"; + + /** Configures runtime native libraries for legacy versions (e.g. LWJGL2 Apple Silicon replacement). */ + default void configureRuntimeNatives(Configuration configuration, DependencyFactory dependencyFactory, String minecraftVersion) {} + + /** Configures native library dependencies for legacy versions (natives extraction). */ + default void configureNativeLibraries(Configuration nativeLibraries, DependencyFactory dependencyFactory, String minecraftVersion) {} + + /** Retrieves the hooks registered on the project, or {@link #NOOP} if none. */ + static McpToolchainHooks get(org.gradle.api.Project project) { + var hooks = project.getExtensions().findByName(EXTENSION_NAME); + return hooks instanceof McpToolchainHooks mcp ? mcp : NOOP; + } +} diff --git a/src/main/java/net/neoforged/moddevgradle/internal/ModDevArtifactsWorkflow.java b/src/main/java/net/neoforged/moddevgradle/internal/ModDevArtifactsWorkflow.java index 40b1723d..b93921b0 100644 --- a/src/main/java/net/neoforged/moddevgradle/internal/ModDevArtifactsWorkflow.java +++ b/src/main/java/net/neoforged/moddevgradle/internal/ModDevArtifactsWorkflow.java @@ -69,6 +69,31 @@ public static ModDevArtifactsWorkflow create(Project project, Configuration interfaceInjectionData, VersionCapabilitiesInternal versionCapabilities, boolean disableRecompilation) { + return create( + project, + enabledSourceSets, + branding, + extension, + moddingDependencies, + artifactNamingStrategy, + accessTransformers, + interfaceInjectionData, + versionCapabilities, + disableRecompilation, + null); + } + + public static ModDevArtifactsWorkflow create(Project project, + Collection enabledSourceSets, + Branding branding, + ModDevExtension extension, + ModdingDependencies moddingDependencies, + ArtifactNamingStrategy artifactNamingStrategy, + Configuration accessTransformers, + Configuration interfaceInjectionData, + VersionCapabilitiesInternal versionCapabilities, + boolean disableRecompilation, + @Nullable String legacyMcpMappings) { if (project.getExtensions().findByName(EXTENSION_NAME) != null) { throw new InvalidUserCodeException("You cannot enable modding in the same project twice."); } @@ -150,6 +175,18 @@ public static ModDevArtifactsWorkflow create(Project project, task.getParchmentData().from(parchmentData); task.getParchmentEnabled().set(parchment.getEnabled()); task.getParchmentConflictResolutionPrefix().set(parchment.getConflictResolutionPrefix()); + if (legacyMcpMappings != null) { + task.getLegacyMcpMappings().set(legacyMcpMappings); + // Legacy MCP versions (e.g. 1.12.2) depend on libraries that are not available on the NeoForged Maven + // NFRT consults by default: lzma:lzma only lives on Mojang's library repository, vecmath/trove4j on + // Maven Central, and the Scala/JLine/etc. deps on the Forge Maven. Point NFRT at all of them so the + // recompile compile-classpath can be resolved. + task.getAdditionalRepositories().addAll(java.util.List.of( + "https://repo1.maven.org/maven2/", + "https://libraries.minecraft.net/", + "https://maven.minecraftforge.net/" + )); + } Function> artifactPathStrategy = artifact -> artifactsBuildDir.map(dir -> dir.file(artifactNamingStrategy.getFilename(artifact))); @@ -210,6 +247,7 @@ public static ModDevArtifactsWorkflow create(Project project, // Technically, the Minecraft dependencies do not strictly need to be on the classpath because they are pulled from the legacy class path. // However, we do it anyway because this matches production environments, and allows launch proxies such as DevLogin to use Minecraft's libraries. config.getDependencies().add(moddingDependencies.gameLibrariesDependency()); + McpToolchainHooks.get(project).configureRuntimeNatives(config, dependencyFactory, versionCapabilities.minecraftVersion()); }); // Configuration in which we place the required dependencies to develop mods for use in the compile-classpath. @@ -220,6 +258,7 @@ public static ModDevArtifactsWorkflow create(Project project, config.setCanBeConsumed(false); config.getDependencies().addLater(minecraftClassesDependency); config.getDependencies().add(moddingDependencies.gameLibrariesDependency()); + McpToolchainHooks.get(project).configureRuntimeNatives(config, dependencyFactory, versionCapabilities.minecraftVersion()); if (!versionCapabilities.needsNeoForgeInMinecraftJar() && moddingDependencies.neoForgeDependency() != null) { config.getDependencies().add(moddingDependencies.neoForgeDependency()); } diff --git a/src/main/java/net/neoforged/moddevgradle/internal/ModDevRunWorkflow.java b/src/main/java/net/neoforged/moddevgradle/internal/ModDevRunWorkflow.java index d6be0f49..4781bb03 100644 --- a/src/main/java/net/neoforged/moddevgradle/internal/ModDevRunWorkflow.java +++ b/src/main/java/net/neoforged/moddevgradle/internal/ModDevRunWorkflow.java @@ -1,12 +1,15 @@ package net.neoforged.moddevgradle.internal; import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; import java.util.HashMap; import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Consumer; +import javax.inject.Inject; import net.neoforged.minecraftdependencies.MinecraftDistribution; import net.neoforged.moddevgradle.dsl.InternalModelHelper; import net.neoforged.moddevgradle.dsl.ModModel; @@ -20,6 +23,7 @@ import org.gradle.api.Named; import org.gradle.api.Project; import org.gradle.api.Task; +import org.gradle.api.Action; import org.gradle.api.artifacts.Configuration; import org.gradle.api.artifacts.ModuleDependency; import org.gradle.api.attributes.Attribute; @@ -28,6 +32,7 @@ import org.gradle.api.file.ConfigurableFileCollection; import org.gradle.api.file.Directory; import org.gradle.api.file.RegularFile; +import org.gradle.api.file.RegularFileProperty; import org.gradle.api.plugins.JavaPlugin; import org.gradle.api.plugins.JavaPluginExtension; import org.gradle.api.plugins.jvm.JvmTestSuite; @@ -60,6 +65,7 @@ public class ModDevRunWorkflow { private final ModuleDependency testFixturesDependency; private final ModuleDependency gameLibrariesDependency; private final Configuration userDevConfigOnly; + private final Map> runTemplateReplacements; /** * @param gameLibrariesDependency A module dependency that represents the library dependencies of the game. @@ -75,12 +81,14 @@ private ModDevRunWorkflow(Project project, @Nullable ModuleDependency testFixturesDependency, ModuleDependency gameLibrariesDependency, DomainObjectCollection runs, - VersionCapabilitiesInternal versionCapabilities) { + VersionCapabilitiesInternal versionCapabilities, + Map> runTemplateReplacements) { this.project = project; this.branding = branding; this.modulePathDependency = modulePathDependency; this.testFixturesDependency = testFixturesDependency; this.gameLibrariesDependency = gameLibrariesDependency; + this.runTemplateReplacements = runTemplateReplacements; var configurations = project.getConfigurations(); @@ -138,7 +146,8 @@ private ModDevRunWorkflow(Project project, }, configureLegacyClasspath, artifactsWorkflow.downloadAssets().flatMap(DownloadAssets::getAssetPropertiesFile), - versionCapabilities); + versionCapabilities, + runTemplateReplacements); } private static void forbidAdditionalRuntimeDependencies(Configuration configuration, VersionCapabilitiesInternal versionCapabilities) { @@ -154,6 +163,10 @@ private static void forbidAdditionalRuntimeDependencies(Configuration configurat }); } + private static boolean isClientRunType(String runType) { + return runType.equals("client") || runType.equals("data") || runType.equals("clientData"); + } + public static ModDevRunWorkflow get(Project project) { var workflow = ExtensionUtils.findExtension(project, EXTENSION_NAME, ModDevRunWorkflow.class); if (workflow == null) { @@ -166,6 +179,14 @@ public static ModDevRunWorkflow create(Project project, Branding branding, ModDevArtifactsWorkflow artifactsWorkflow, DomainObjectCollection runs) { + return create(project, branding, artifactsWorkflow, runs, Map.of()); + } + + public static ModDevRunWorkflow create(Project project, + Branding branding, + ModDevArtifactsWorkflow artifactsWorkflow, + DomainObjectCollection runs, + Map> runTemplateReplacements) { var dependencies = artifactsWorkflow.dependencies(); var versionCapabilites = artifactsWorkflow.versionCapabilities(); @@ -178,7 +199,8 @@ public static ModDevRunWorkflow create(Project project, dependencies.testFixturesDependency(), dependencies.gameLibrariesDependency(), runs, - versionCapabilites); + versionCapabilites, + runTemplateReplacements); project.getExtensions().add(EXTENSION_NAME, workflow); @@ -228,7 +250,8 @@ public void configureTesting(Provider testedMod, Provider configureModulePath, Consumer configureLegacyClasspath, Provider assetPropertiesFile, - VersionCapabilitiesInternal versionCapabilities) { + VersionCapabilitiesInternal versionCapabilities, + Map> runTemplateReplacements) { var dependencyFactory = project.getDependencyFactory(); var ideIntegration = IdeIntegration.of(project, branding); @@ -285,6 +309,7 @@ public static void setupRuns( assetPropertiesFile, devLaunchConfig, versionCapabilities, + runTemplateReplacements, createLaunchScriptsTask); prepareRunTasks.put(run, prepareRunTask); }); @@ -308,6 +333,7 @@ private static TaskProvider setupRunInGradle( Provider assetPropertiesFile, Configuration devLaunchConfig, VersionCapabilitiesInternal versionCapabilities, + Map> runTemplateReplacements, TaskProvider createLaunchScriptsTask) { var ideIntegration = IdeIntegration.of(project, branding); var configurations = project.getConfigurations(); @@ -341,13 +367,14 @@ private static TaskProvider setupRunInGradle( spec.shouldResolveConsistentlyWith(runtimeClasspathConfig.get()); spec.attributes(attributes -> { attributes.attributeProvider(MinecraftDistribution.ATTRIBUTE, type.map(t -> { - var name = t.equals("client") || t.equals("data") || t.equals("clientData") ? MinecraftDistribution.CLIENT : MinecraftDistribution.SERVER; + var name = isClientRunType(t) ? MinecraftDistribution.CLIENT : MinecraftDistribution.SERVER; return project.getObjects().named(MinecraftDistribution.class, name); })); setNamedAttribute(project, attributes, Usage.USAGE_ATTRIBUTE, Usage.JAVA_RUNTIME); }); configureLegacyClasspath.accept(spec); spec.extendsFrom(run.getAdditionalRuntimeClasspathConfiguration()); + McpToolchainHooks.get(project).configureRuntimeNatives(spec, project.getDependencyFactory(), versionCapabilities.minecraftVersion()); }); var writeLcpTask = tasks.register(InternalModelHelper.nameOfRun(run, "write", "legacyClasspath"), WriteLegacyClasspath.class, writeLcp -> { @@ -363,6 +390,32 @@ private static TaskProvider setupRunInGradle( legacyClasspathFile = null; } + var nativeLibraries = configurations.create(InternalModelHelper.nameOfRun(run, "", "nativeLibraries"), spec -> { + spec.setDescription("Contains native libraries that should be extracted for run " + run.getName() + "."); + spec.setCanBeResolved(true); + spec.setCanBeConsumed(false); + spec.shouldResolveConsistentlyWith(runtimeClasspathConfig.get()); + spec.attributes(attributes -> { + setNamedAttribute(project, attributes, MinecraftDistribution.ATTRIBUTE, MinecraftDistribution.CLIENT); + setNamedAttribute(project, attributes, Usage.USAGE_ATTRIBUTE, Usage.JAVA_RUNTIME); + }); + if (versionCapabilities.legacyClasspath()) { + spec.getDependencies().add(project.getDependencyFactory() + .create("net.neoforged:minecraft-dependencies:" + versionCapabilities.minecraftVersion()) + .capabilities(caps -> caps.requireCapability("net.neoforged:minecraft-dependencies-natives"))); + McpToolchainHooks.get(project).configureNativeLibraries(spec, project.getDependencyFactory(), versionCapabilities.minecraftVersion()); + } + }); + var nativesDirectory = run.getGameDirectory().map(dir -> dir.dir("natives")); + var extractNativesTask = tasks.register(InternalModelHelper.nameOfRun(run, "extract", "natives"), ExtractNatives.class, task -> { + task.setGroup(branding.internalTaskGroup()); + task.setDescription("Extracts native libraries for the " + run.getName() + " Minecraft run."); + task.getEnabledForRun().set(type.map(ModDevRunWorkflow::isClientRunType)); + task.onlyIf(ignored -> task.getEnabledForRun().get()); + task.getNativeLibraries().from(nativeLibraries); + task.getOutputDirectory().set(nativesDirectory); + }); + var prepareRunTask = tasks.register(InternalModelHelper.nameOfRun(run, "prepare", "run"), PrepareRun.class, task -> { task.setGroup(branding.internalTaskGroup()); task.setDescription("Prepares all files needed to launch the " + run.getName() + " Minecraft run."); @@ -370,6 +423,7 @@ private static TaskProvider setupRunInGradle( task.getGameDirectory().set(run.getGameDirectory()); task.getVmArgsFile().set(RunUtils.getArgFile(argFileDir, run, RunUtils.RunArgFile.VMARGS)); task.getProgramArgsFile().set(RunUtils.getArgFile(argFileDir, run, RunUtils.RunArgFile.PROGRAMARGS)); + task.getEnvironmentFile().set(RunUtils.getArgFile(argFileDir, run, RunUtils.RunArgFile.ENVIRONMENT)); task.getLog4jConfigFileOverride().set(run.getLoggingConfigFile()); task.getLog4jConfigFile().set(RunUtils.getArgFile(argFileDir, run, RunUtils.RunArgFile.LOG4J_CONFIG)); task.getRunType().set(run.getType()); @@ -383,12 +437,16 @@ private static TaskProvider setupRunInGradle( props = new HashMap<>(props); return props; })); + task.getUserEnvironment().set(run.getEnvironment()); + task.getRunTemplateReplacements().set(project.provider(() -> runTemplateReplacements.entrySet().stream() + .collect(java.util.stream.Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().get())))); task.getMainClass().set(run.getMainClass()); task.getProgramArguments().set(run.getProgramArguments()); task.getJvmArguments().set(run.getJvmArguments()); task.getGameLogLevel().set(run.getLogLevel()); task.getDevLogin().set(run.getDevLogin()); task.getVersionCapabilities().set(versionCapabilities); + task.dependsOn(extractNativesTask); }); ideIntegration.runTaskOnProjectSync(prepareRunTask); @@ -404,6 +462,7 @@ private static TaskProvider setupRunInGradle( task.getClasspathArgsFile().set(RunUtils.getArgFile(argFileDir, run, RunUtils.RunArgFile.CLASSPATH)); task.getVmArgsFile().set(prepareRunTask.get().getVmArgsFile().map(d -> d.getAsFile().getAbsolutePath())); task.getProgramArgsFile().set(prepareRunTask.get().getProgramArgsFile().map(d -> d.getAsFile().getAbsolutePath())); + task.getEnvironmentFile().set(prepareRunTask.get().getEnvironmentFile().map(d -> d.getAsFile().getAbsolutePath())); task.getEnvironment().set(run.getEnvironment()); task.getModFolders().set(RunUtils.getGradleModFoldersProvider(project, run.getLoadedMods(), null)); }); @@ -413,18 +472,20 @@ private static TaskProvider setupRunInGradle( task.setGroup(branding.publicTaskGroup()); task.setDescription("Runs the " + run.getName() + " Minecraft run configuration."); - // Launch with the Java version used in the project + // Launch with the Java version used in the project (as a convention, so plugins like the 1.12.2 + // mcpforge toolchain — which must run launchwrapper on Java 8 — can override it via set()). var toolchainService = ExtensionUtils.findExtension(project, "javaToolchains", JavaToolchainService.class); - task.getJavaLauncher().set(toolchainService.launcherFor(spec -> spec.getLanguageVersion().set(javaExtension.getToolchain().getLanguageVersion()))); + task.getJavaLauncher().convention(toolchainService.launcherFor(spec -> spec.getLanguageVersion().set(javaExtension.getToolchain().getLanguageVersion()))); // Note: this contains both the runtimeClasspath configuration and the sourceset's outputs. // This records a dependency on compiling and processing the resources of the source set. task.getClasspathProvider().from(run.getSourceSet().map(SourceSet::getRuntimeClasspath)); task.getGameDirectory().set(run.getGameDirectory()); task.getEnvironmentProperty().set(run.getEnvironment()); - task.jvmArgs(RunUtils.getArgFileParameter(prepareRunTask.get().getVmArgsFile().get()).replace("\\", "\\\\")); + task.getEnvironmentFile().set(prepareRunTask.get().getEnvironmentFile()); + task.getVmArgsFile().set(prepareRunTask.get().getVmArgsFile()); + task.getProgramArgsFile().set(prepareRunTask.get().getProgramArgsFile()); task.getMainClass().set(RunUtils.DEV_LAUNCH_MAIN_CLASS); - task.args(RunUtils.getArgFileParameter(prepareRunTask.get().getProgramArgsFile().get()).replace("\\", "\\\\")); // Of course we need the arg files to be up-to-date ;) task.dependsOn(prepareRunTask); task.dependsOn(run.getTasksBefore()); @@ -448,7 +509,8 @@ static void setupTestTask(Project project, Consumer configureModulePath, Consumer configureLegacyClasspath, Provider assetPropertiesFile, - VersionCapabilitiesInternal versionCapabilities) { + VersionCapabilitiesInternal versionCapabilities, + Map> runTemplateReplacements) { var gameDirectory = new File(project.getProjectDir(), JUNIT_GAME_DIR); var ideIntegration = IdeIntegration.of(project, branding); @@ -496,6 +558,7 @@ static void setupTestTask(Project project, var vmArgsFile = runArgsDir.map(dir -> dir.file("vmArgs.txt")); var programArgsFile = runArgsDir.map(dir -> dir.file("programArgs.txt")); + var environmentFile = runArgsDir.map(dir -> dir.file("environment.properties")); var log4j2ConfigFile = runArgsDir.map(dir -> dir.file("log4j2.xml")); var prepareTask = tasks.register("prepareNeoForgeTestFiles", PrepareTest.class, task -> { task.setGroup(branding.internalTaskGroup()); @@ -503,6 +566,7 @@ static void setupTestTask(Project project, task.getGameDirectory().set(gameDirectory); task.getVmArgsFile().set(vmArgsFile); task.getProgramArgsFile().set(programArgsFile); + task.getEnvironmentFile().set(environmentFile); task.getLog4jConfigFile().set(log4j2ConfigFile); task.getRunTypeTemplatesSource().from(runTemplatesSourceFile); task.getModules().from(neoForgeModDevModules); @@ -510,6 +574,8 @@ static void setupTestTask(Project project, task.getLegacyClasspathFile().set(legacyClasspathFile); } task.getAssetProperties().set(assetPropertiesFile); + task.getRunTemplateReplacements().set(project.provider(() -> runTemplateReplacements.entrySet().stream() + .collect(java.util.stream.Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().get())))); task.getGameLogLevel().set(Level.INFO); }); @@ -523,6 +589,9 @@ static void setupTestTask(Project project, // file containing the program arguments needed to launch task.systemProperty("fml.junit.argsfile", programArgsFile.get().getAsFile().getAbsolutePath()); task.jvmArgs(RunUtils.getArgFileParameter(vmArgsFile.get())); + var loadEnvironment = project.getObjects().newInstance(LoadPreparedTestEnvironment.class); + loadEnvironment.getEnvironmentFile().set(environmentFile); + task.doFirst("load prepared Minecraft test environment", loadEnvironment); var modFoldersProvider = RunUtils.getGradleModFoldersProvider(project, loadedMods, testedMod); task.getJvmArgumentProviders().add(modFoldersProvider); @@ -539,4 +608,20 @@ static void setupTestTask(Project project, private static void setNamedAttribute(Project project, AttributeContainer attributes, Attribute attribute, String value) { attributes.attribute(attribute, project.getObjects().named(attribute.getType(), value)); } + + public static abstract class LoadPreparedTestEnvironment implements Action { + @Inject + public LoadPreparedTestEnvironment() {} + + public abstract RegularFileProperty getEnvironmentFile(); + + @Override + public void execute(Task task) { + try { + ((Test) task).environment(RunUtils.loadEnvironmentFile(getEnvironmentFile().get().getAsFile())); + } catch (IOException e) { + throw new UncheckedIOException("Failed to read prepared test environment", e); + } + } + } } diff --git a/src/main/java/net/neoforged/moddevgradle/internal/NeoDevFacade.java b/src/main/java/net/neoforged/moddevgradle/internal/NeoDevFacade.java index 9192640d..cb4ccad2 100644 --- a/src/main/java/net/neoforged/moddevgradle/internal/NeoDevFacade.java +++ b/src/main/java/net/neoforged/moddevgradle/internal/NeoDevFacade.java @@ -1,5 +1,6 @@ package net.neoforged.moddevgradle.internal; +import java.util.Map; import java.util.Set; import java.util.function.Consumer; import net.neoforged.moddevgradle.dsl.ModModel; @@ -41,7 +42,8 @@ public static void setupRuns(Project project, configureModulePath, configureAdditionalClasspath, assetPropertiesFile, - neoFormVersion.map(VersionCapabilitiesInternal::ofNeoFormVersion).getOrElse(VersionCapabilitiesInternal.latest())); + neoFormVersion.map(VersionCapabilitiesInternal::ofNeoFormVersion).getOrElse(VersionCapabilitiesInternal.latest()), + Map.of()); } public static void setupTestTask(Project project, @@ -65,7 +67,8 @@ public static void setupTestTask(Project project, configureModulePath, configureAdditionalClasspath, assetPropertiesFile, - neoFormVersion.map(VersionCapabilitiesInternal::ofNeoFormVersion).getOrElse(VersionCapabilitiesInternal.latest())); + neoFormVersion.map(VersionCapabilitiesInternal::ofNeoFormVersion).getOrElse(VersionCapabilitiesInternal.latest()), + Map.of()); } public static void runTaskOnProjectSync(Project project, Object task) { diff --git a/src/main/java/net/neoforged/moddevgradle/internal/PrepareRunOrTest.java b/src/main/java/net/neoforged/moddevgradle/internal/PrepareRunOrTest.java index 116bb72f..1977a621 100644 --- a/src/main/java/net/neoforged/moddevgradle/internal/PrepareRunOrTest.java +++ b/src/main/java/net/neoforged/moddevgradle/internal/PrepareRunOrTest.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Properties; import java.util.stream.Collectors; import java.util.zip.ZipFile; import net.neoforged.moddevgradle.internal.utils.FileUtils; @@ -56,6 +57,9 @@ abstract class PrepareRunOrTest extends DefaultTask { @OutputFile public abstract RegularFileProperty getProgramArgsFile(); + @OutputFile + public abstract RegularFileProperty getEnvironmentFile(); + /** * A file to use for the {@code log4j2.xml} config file that will be written. * If absent, the standard log4j2.xml file produced by {@link RunUtils#writeLog4j2Configuration} will be used. @@ -99,6 +103,12 @@ abstract class PrepareRunOrTest extends DefaultTask { @Input public abstract MapProperty getSystemProperties(); + @Input + public abstract MapProperty getUserEnvironment(); + + @Input + public abstract MapProperty getRunTemplateReplacements(); + @Input public abstract ListProperty getJvmArguments(); @@ -128,8 +138,11 @@ abstract class PrepareRunOrTest extends DefaultTask { protected PrepareRunOrTest(ProgramArgsFormat programArgsFormat) { this.programArgsFormat = programArgsFormat; + getInputs().property("gameDirectoryPath", getGameDirectory().map(directory -> directory.getAsFile().getAbsolutePath())); getVersionCapabilities().convention(VersionCapabilitiesInternal.latest()); getDevLogin().convention(false); + getUserEnvironment().convention(Map.of()); + getRunTemplateReplacements().convention(Map.of()); } protected abstract UserDevRunType resolveRunType(UserDevConfig userDevConfig); @@ -151,13 +164,20 @@ private List getInterpolatedJvmArgs(UserDevRunType runConfig) { } result.add(RunUtils.escapeJvmArg(arg)); } - if (isClientDistribution() && OperatingSystem.current() == OperatingSystem.MACOS) { + if (isClientDistribution() && OperatingSystem.current() == OperatingSystem.MACOS && !usesLegacyAppleLwjgl2()) { // TODO: it might be more future-proof to source this from the platform args in the MC version json result.add("-XstartOnFirstThread"); } return result; } + private boolean usesLegacyAppleLwjgl2() { + var capabilities = getVersionCapabilities().get(); + var arch = System.getProperty("os.arch"); + return "1.12.2".equals(capabilities.minecraftVersion()) + && ("aarch64".equals(arch) || "arm64".equals(arch)); + } + @TaskAction public void prepareRun() throws IOException { // Make sure the run directory exists @@ -187,6 +207,31 @@ public void prepareRun() throws IOException { writeJvmArguments(runConfig, sysProps); writeProgramArguments(runConfig, mainClass); + writeEnvironment(runConfig); + configureLegacyForgeSplash(runDir); + } + + private void configureLegacyForgeSplash(File runDir) throws IOException { + if (!isClientDistribution() + || OperatingSystem.current() != OperatingSystem.MACOS + || !"1.12.2".equals(getVersionCapabilities().get().minecraftVersion())) { + return; + } + + var splashConfig = runDir.toPath().resolve("config/splash.properties"); + Files.createDirectories(splashConfig.getParent()); + + var properties = new Properties(); + if (Files.isRegularFile(splashConfig)) { + try (var reader = Files.newBufferedReader(splashConfig, StandardCharsets.UTF_8)) { + properties.load(reader); + } + } + properties.setProperty("enabled", "false"); + + try (var writer = Files.newBufferedWriter(splashConfig, StandardCharsets.UTF_8)) { + properties.store(writer, "Splash screen properties"); + } } private UserDevConfig loadUserDevConfig(File userDevFile) { @@ -292,13 +337,9 @@ private void writeProgramArguments(UserDevRunType runConfig, @Nullable String ma } lines.add("# NeoForge Run-Type Program Arguments"); - var assetProperties = DownloadedAssetsReference.loadProperties(getAssetProperties().get().getAsFile()); List args = runConfig.args(); for (String arg : args) { - switch (arg) { - case "{assets_root}" -> arg = Objects.requireNonNull(assetProperties.assetsRoot(), "assets_root"); - case "{asset_index}" -> arg = Objects.requireNonNull(assetProperties.assetIndex(), "asset_index"); - } + arg = interpolateRunTemplateValue(arg); // FML JUnit simply expects one line per argument if (programArgsFormat == ProgramArgsFormat.FML_JUNIT) { @@ -334,6 +375,37 @@ private void writeProgramArguments(UserDevRunType runConfig, @Nullable String ma StandardCharsets.UTF_8); } + private void writeEnvironment(UserDevRunType runConfig) throws IOException { + var environment = new LinkedHashMap(); + for (var entry : runConfig.env().entrySet()) { + environment.put(entry.getKey(), interpolateRunTemplateValue(entry.getValue())); + } + environment.putAll(getUserEnvironment().get()); + + var properties = new Properties(); + properties.putAll(environment); + var destination = getEnvironmentFile().get().getAsFile().toPath(); + Files.createDirectories(destination.getParent()); + try (var out = FileUtils.newSafeFileOutputStream(destination)) { + properties.store(out, "Minecraft run environment"); + } + } + + private String interpolateRunTemplateValue(String value) { + var assetProperties = DownloadedAssetsReference.loadProperties(getAssetProperties().get().getAsFile()); + var replacements = new LinkedHashMap<>(getRunTemplateReplacements().get()); + replacements.put("assets_root", Objects.requireNonNull(assetProperties.assetsRoot(), "assets_root")); + replacements.put("asset_index", Objects.requireNonNull(assetProperties.assetIndex(), "asset_index")); + replacements.put("natives", getGameDirectory().get().dir("natives").getAsFile().getAbsolutePath()); + replacements.put("MC_VERSION", getVersionCapabilities().get().minecraftVersion()); + + for (var entry : replacements.entrySet()) { + value = value.replace("${" + entry.getKey() + "}", entry.getValue()); + value = value.replace("{" + entry.getKey() + "}", entry.getValue()); + } + return value; + } + private static void addSystemProp(String name, String value, List lines) { lines.add(RunUtils.escapeJvmArg("-D" + name + "=" + value)); } diff --git a/src/main/java/net/neoforged/moddevgradle/internal/RunGameTask.java b/src/main/java/net/neoforged/moddevgradle/internal/RunGameTask.java index 79928fb9..1bc83ba6 100644 --- a/src/main/java/net/neoforged/moddevgradle/internal/RunGameTask.java +++ b/src/main/java/net/neoforged/moddevgradle/internal/RunGameTask.java @@ -6,9 +6,11 @@ import javax.inject.Inject; import org.gradle.api.file.ConfigurableFileCollection; import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.RegularFileProperty; import org.gradle.api.provider.MapProperty; import org.gradle.api.tasks.Classpath; import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFile; import org.gradle.api.tasks.InputFiles; import org.gradle.api.tasks.Internal; import org.gradle.api.tasks.JavaExec; @@ -28,6 +30,15 @@ public abstract class RunGameTask extends JavaExec { @Input public abstract MapProperty getEnvironmentProperty(); + @InputFile + public abstract RegularFileProperty getEnvironmentFile(); + + @InputFile + public abstract RegularFileProperty getVmArgsFile(); + + @InputFile + public abstract RegularFileProperty getProgramArgsFile(); + @Internal public abstract DirectoryProperty getGameDirectory(); @@ -44,10 +55,21 @@ public void exec() { throw new UncheckedIOException("Failed to create run directory", e); } + try { + getEnvironment().putAll(RunUtils.loadEnvironmentFile(getEnvironmentFile().get().getAsFile())); + } catch (IOException e) { + throw new UncheckedIOException("Failed to read prepared run environment", e); + } getEnvironment().putAll(getEnvironmentProperty().get()); classpath(getClasspathProvider()); setWorkingDir(runDir); + try { + setJvmArgs(RunUtils.readArgFile(getVmArgsFile().get().getAsFile())); + setArgs(RunUtils.readArgFile(getProgramArgsFile().get().getAsFile())); + } catch (IOException e) { + throw new UncheckedIOException("Failed to read prepared run argument files", e); + } super.exec(); } } diff --git a/src/main/java/net/neoforged/moddevgradle/internal/RunUtils.java b/src/main/java/net/neoforged/moddevgradle/internal/RunUtils.java index c913ce82..c2d9c215 100644 --- a/src/main/java/net/neoforged/moddevgradle/internal/RunUtils.java +++ b/src/main/java/net/neoforged/moddevgradle/internal/RunUtils.java @@ -1,13 +1,17 @@ package net.neoforged.moddevgradle.internal; import java.io.File; +import java.io.FileInputStream; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Properties; import java.util.Set; import java.util.function.BiConsumer; import java.util.stream.Collectors; @@ -157,6 +161,7 @@ public static Provider getLaunchScript(Provider modDevFo public enum RunArgFile { VMARGS("runVmArgs.txt"), PROGRAMARGS("runProgramArgs.txt"), + ENVIRONMENT("runEnvironment.properties"), CLASSPATH("runClasspath.txt"), LOG4J_CONFIG("log4j2.xml"); @@ -171,6 +176,54 @@ public static String getArgFileParameter(RegularFile argFile) { return "@" + argFile.getAsFile().getAbsolutePath(); } + public static Map loadEnvironmentFile(File file) throws IOException { + var properties = new Properties(); + try (var input = new FileInputStream(file)) { + properties.load(input); + } + + var environment = new LinkedHashMap(); + for (var name : properties.stringPropertyNames()) { + environment.put(name, properties.getProperty(name)); + } + return environment; + } + + public static List readArgFile(File file) throws IOException { + var result = new ArrayList(); + for (var line : Files.readAllLines(file.toPath())) { + line = line.strip(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + result.add(unescapeArgFileLine(line)); + } + return result; + } + + private static String unescapeArgFileLine(String line) { + if (line.length() >= 2 && line.startsWith("\"") && line.endsWith("\"")) { + line = line.substring(1, line.length() - 1); + } + var result = new StringBuilder(line.length()); + boolean escaped = false; + for (int i = 0; i < line.length(); i++) { + var c = line.charAt(i); + if (escaped) { + result.append(c); + escaped = false; + } else if (c == '\\') { + escaped = true; + } else { + result.append(c); + } + } + if (escaped) { + result.append('\\'); + } + return result.toString(); + } + public static ModFoldersProvider getGradleModFoldersProvider(Project project, Provider> modsProvider, Provider testedMod) { var modFoldersProvider = project.getObjects().newInstance(ModFoldersProvider.class); modFoldersProvider.getModFolders().set(getModFoldersForGradle(project, modsProvider, testedMod)); @@ -199,9 +252,7 @@ public static Project findSourceSetProject(Project someProject, SourceSet source /** * In the run model, the environment variable "MOD_CLASSES" is set to the gradle output folders by the legacy plugin, - * since MDG itself completely ignores run-type specific environment variables. - * To ensure that in IDE runs, the IDE output folders are used, we replace the MOD_CLASSES environment variable - * explicitly. + * and we replace it with the IDE output folders for IDE runs. */ public static Map replaceModClassesEnv(RunModel model, ModFoldersProvider modFoldersProvider) { var vars = model.getEnvironment().get(); diff --git a/src/main/java/net/neoforged/moddevgradle/internal/UserDevRunType.java b/src/main/java/net/neoforged/moddevgradle/internal/UserDevRunType.java index bb6e54de..8eb67797 100644 --- a/src/main/java/net/neoforged/moddevgradle/internal/UserDevRunType.java +++ b/src/main/java/net/neoforged/moddevgradle/internal/UserDevRunType.java @@ -4,4 +4,11 @@ import java.util.Map; public record UserDevRunType(boolean singleInstance, String main, List args, List jvmArgs, - Map env, Map props) {} + Map env, Map props) { + public UserDevRunType { + args = args == null ? List.of() : args; + jvmArgs = jvmArgs == null ? List.of() : jvmArgs; + env = env == null ? Map.of() : env; + props = props == null ? Map.of() : props; + } +} diff --git a/src/main/java/net/neoforged/moddevgradle/internal/utils/FileUtils.java b/src/main/java/net/neoforged/moddevgradle/internal/utils/FileUtils.java index 9f32a71c..77353172 100644 --- a/src/main/java/net/neoforged/moddevgradle/internal/utils/FileUtils.java +++ b/src/main/java/net/neoforged/moddevgradle/internal/utils/FileUtils.java @@ -14,10 +14,12 @@ import java.nio.file.StandardCopyOption; import java.security.DigestInputStream; import java.security.MessageDigest; -import java.util.HexFormat; -import java.util.List; -import java.util.Optional; +import java.util.*; +import java.util.jar.JarEntry; import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import org.gradle.api.GradleException; import org.jetbrains.annotations.ApiStatus; @@ -28,6 +30,8 @@ public final class FileUtils { * The maximum number of tries that the system will try to atomically move a file. */ private static final int MAX_TRIES = 2; + /** Package prefix for SpongePowered Mixin/ASM classes that bundler jars (e.g. VoxelMap) ship but shouldn't expose. */ + private static final String SPONGE_PREFIX = "org/spongepowered/asm/"; private FileUtils() {} @@ -71,6 +75,71 @@ public static String hashFile(File file, String algorithm) { } } + public static void stripJarSignatures(Path jar) throws IOException { + rewriteJar(jar, entry -> !entry.getName().equalsIgnoreCase(JarFile.MANIFEST_NAME) && !isJarSignatureFile(entry.getName()), unsignedManifest(readManifest(jar))); + } + + public static void removeJarEntries(Path jar, Set entryNames) throws IOException { + rewriteJar(jar, entry -> !entryNames.contains(entry.getName()), readManifest(jar)); + } + + private static Manifest readManifest(Path jar) throws IOException { + try (var input = new JarFile(jar.toFile(), false)) { + return input.getManifest(); + } + } + + private static void rewriteJar(Path jar, java.util.function.Predicate keepEntry, Manifest manifest) throws IOException { + var tempFile = jar.resolveSibling(jar.getFileName().toString() + ".unsigned.tmp"); + try { + try (var input = new JarFile(jar.toFile(), false); + var output = manifest == null + ? new JarOutputStream(Files.newOutputStream(tempFile)) + : new JarOutputStream(Files.newOutputStream(tempFile), manifest)) { + var entries = input.entries(); + while (entries.hasMoreElements()) { + var entry = entries.nextElement(); + if (entry.getName().equalsIgnoreCase(JarFile.MANIFEST_NAME) || !keepEntry.test(entry)) { + continue; + } + + var newEntry = new JarEntry(entry.getName()); + newEntry.setTime(entry.getTime()); + output.putNextEntry(newEntry); + if (!entry.isDirectory()) { + try (var entryInput = input.getInputStream(entry)) { + entryInput.transferTo(output); + } + } + output.closeEntry(); + } + } + atomicMove(tempFile, jar); + } finally { + Files.deleteIfExists(tempFile); + } + } + + private static Manifest unsignedManifest(Manifest manifest) { + if (manifest == null) { + manifest = new Manifest(); + manifest.getMainAttributes().putValue("Manifest-Version", "1.0"); + return manifest; + } + + var result = new Manifest(); + result.getMainAttributes().putAll(manifest.getMainAttributes()); + return result; + } + + private static boolean isJarSignatureFile(String name) { + var upperName = name.toUpperCase(java.util.Locale.ROOT); + if (!upperName.startsWith("META-INF/") || upperName.indexOf('/', "META-INF/".length()) != -1) { + return false; + } + return upperName.endsWith(".SF") || upperName.endsWith(".DSA") || upperName.endsWith(".RSA") || upperName.endsWith(".EC"); + } + public static void writeStringSafe(Path destination, String content, Charset charset) throws IOException { if (!charset.newEncoder().canEncode(content)) { throw new IllegalArgumentException("The given character set " + charset @@ -162,4 +231,33 @@ private static void atomicMoveIfPossible(final Path source, final Path destinati Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING); } } + + /** Rewrites the jar in place, removing every entry under org/spongepowered/asm/. */ + public static void stripSpongePowered(File jar) throws IOException { + var tmp = Path.of(jar.getAbsolutePath() + ".stripped.tmp"); + try (var input = new JarFile(jar); var output = new JarOutputStream(Files.newOutputStream(tmp))) { + Enumeration entries = input.entries(); + while (entries.hasMoreElements()) { + var entry = entries.nextElement(); + if (entry.getName().startsWith(SPONGE_PREFIX)) continue; + var copy = new JarEntry(entry.getName()); + copy.setTime(entry.getTime()); + output.putNextEntry(copy); + if (!entry.isDirectory()) { + try (var in = input.getInputStream(entry)) { + in.transferTo(output); + } + } + output.closeEntry(); + } + } + Files.move(tmp, jar.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + + /** True if the jar contains any org/spongepowered/asm/ entry. */ + public static boolean containsSpongePowered(File jar) throws IOException { + try (var jf = new JarFile(jar)) { + return jf.stream().map(ZipEntry::getName).anyMatch(n -> n.startsWith(SPONGE_PREFIX)); + } + } } diff --git a/src/main/java/net/neoforged/nfrtgradle/CreateMinecraftArtifacts.java b/src/main/java/net/neoforged/nfrtgradle/CreateMinecraftArtifacts.java index d9f9eb54..fa5bea07 100644 --- a/src/main/java/net/neoforged/nfrtgradle/CreateMinecraftArtifacts.java +++ b/src/main/java/net/neoforged/nfrtgradle/CreateMinecraftArtifacts.java @@ -15,6 +15,7 @@ import org.gradle.api.file.ConfigurableFileCollection; import org.gradle.api.file.RegularFileProperty; import org.gradle.api.problems.Problems; +import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.MapProperty; import org.gradle.api.provider.Property; import org.gradle.api.tasks.Input; @@ -115,6 +116,25 @@ public CreateMinecraftArtifacts() { @Optional public abstract Property getParchmentConflictResolutionPrefix(); + /** + * Path or Maven coordinates of a legacy MCP CSV mapping zip. + *

+ * This is only used by NFRT processes for Minecraft versions before Mojang official mappings existed. + */ + @Input + @Optional + public abstract Property getLegacyMcpMappings(); + + /** + * Additional Maven repository URLs that NFRT should consult when resolving artifacts it needs to download + * itself (notably the Minecraft and Forge libraries used as the compile classpath during recompilation). + * NFRT's built-in defaults only cover the NeoForged Maven and the local Maven cache, which is insufficient for + * legacy versions whose libraries are spread across Maven Central, Mojang's library repository and the Forge Maven. + * Each entry is passed to NFRT using the {@code --repository} command line option. + */ + @Input + public abstract ListProperty getAdditionalRepositories(); + /** * This property can be used to access additional results of the NeoForm process being run by NFRT. * The map key is the ID of the result while the value is the output file where that result should be written. @@ -312,6 +332,19 @@ public void createArtifacts() { } } + if (getLegacyMcpMappings().isPresent()) { + var legacyMcpMappings = getLegacyMcpMappings().get(); + if (!legacyMcpMappings.isBlank()) { + args.add("--mcp-mappings"); + args.add(legacyMcpMappings); + } + } + + for (var repository : getAdditionalRepositories().get()) { + args.add("--repository"); + args.add(repository); + } + if (!getEnableCache().get()) { args.add("--disable-cache"); } diff --git a/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/dsl/McpForgeExtension.java b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/dsl/McpForgeExtension.java new file mode 100644 index 00000000..14c3f86b --- /dev/null +++ b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/dsl/McpForgeExtension.java @@ -0,0 +1,64 @@ +package net.neoforged.moddevgradle.mcpforge.dsl; + +import javax.inject.Inject; +import net.neoforged.moddevgradle.dsl.DataFileCollection; +import net.neoforged.moddevgradle.dsl.ModDevExtension; +import net.neoforged.moddevgradle.internal.ModDevArtifactsWorkflow; +import net.neoforged.moddevgradle.mcpforge.dsl.McpForgeModdingSettings; +import net.neoforged.moddevgradle.mcpforge.internal.McpForgeModDevPlugin; +import org.gradle.api.Action; +import org.gradle.api.InvalidUserCodeException; +import org.gradle.api.Project; +import org.gradle.api.provider.ListProperty; + +/** + * The {@code mcpForge} extension for the mcpforge plugin, owning its {@link #enable(Action)} wiring. + * Settings are provided by {@link McpForgeModdingSettings}. + */ +public abstract class McpForgeExtension extends ModDevExtension { + private final Project project; + + @Inject + public McpForgeExtension(Project project, + DataFileCollection accessTransformers, + DataFileCollection interfaceInjectionData) { + super(project, accessTransformers, interfaceInjectionData); + this.project = project; + } + + /** Coordinates of jars that ARE the Mixin provider (exempt from bundled-spongepowered stripping). */ + public abstract ListProperty getMixinProviders(); + + /** Declares a jar as the Mixin provider (e.g. {@code mixinProvider 'zone.rong:mixinbooter:10.7'}). */ + public void mixinProvider(String notation) { + getMixinProviders().add(notation); + } + + /** Shorthand for {@code enable { forgeVersion = '...' }. */ + public void setVersion(String version) { + enable(settings -> settings.setForgeVersion(version)); + } + + /** Shorthand for {@code enable { mcpVersion = '...' }. */ + public void setMcpVersion(String version) { + enable(settings -> settings.setMcpVersion(version)); + } + + /** After enabling, the MCP version picked. Throws if not enabled. */ + public String getMcpVersion() { + var dependencies = ModDevArtifactsWorkflow.get(project).dependencies(); + if (dependencies.neoFormDependency() == null) { + throw new InvalidUserCodeException("You cannot retrieve the MCP version without setting it first."); + } + return dependencies.neoFormDependency().getVersion(); + } + + public void enable(Action customizer) { + var plugin = project.getPlugins().getPlugin(McpForgeModDevPlugin.class); + + var settings = project.getObjects().newInstance(McpForgeModdingSettings.class); + customizer.execute(settings); + + plugin.enable(project, settings, this); + } +} diff --git a/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/dsl/McpForgeModdingSettings.java b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/dsl/McpForgeModdingSettings.java new file mode 100644 index 00000000..72097eb0 --- /dev/null +++ b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/dsl/McpForgeModdingSettings.java @@ -0,0 +1,23 @@ +package net.neoforged.moddevgradle.mcpforge.dsl; + +import javax.inject.Inject; +import net.neoforged.moddevgradle.legacyforge.dsl.LegacyForgeModdingSettings; +import org.gradle.api.Project; +import org.jetbrains.annotations.Nullable; + +public abstract class McpForgeModdingSettings extends LegacyForgeModdingSettings { + private String mcpMappings; + + @Inject + public McpForgeModdingSettings(Project project) { + super(project); + } + + public @Nullable String getMcpMappings() { + return mcpMappings; + } + + public void setMcpMappings(String mcpMappings) { + this.mcpMappings = mcpMappings; + } +} diff --git a/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/ExtractDependencyAccessTransformers.java b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/ExtractDependencyAccessTransformers.java new file mode 100644 index 00000000..f094d8a4 --- /dev/null +++ b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/ExtractDependencyAccessTransformers.java @@ -0,0 +1,92 @@ +package net.neoforged.moddevgradle.mcpforge.internal; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.jar.Attributes; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import org.gradle.api.DefaultTask; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.OutputDirectory; +import org.gradle.api.tasks.TaskAction; + +/** + * Extracts Access Transformer {@code .cfg} files declared via the {@code FMLAT} manifest attribute in dependency jars. + * + *

FML's contract: a mod/coremod jar may carry {@code FMLAT: }, each at + * {@code META-INF/} inside the jar. MDG has no runtime AT class-transformer (it runs the recompiled MC jar + * directly), so dependency ATs must be applied at build time — the extracted cfgs are fed into the + * {@code accessTransformers} DataFileCollection so NFRT bakes them in via {@code --access-transformer}. + * + *

AT files use SRG member names and are extracted verbatim (only CRLF→LF normalization); NFRT renames + * SRG→MCP during recompilation. + */ +public abstract class ExtractDependencyAccessTransformers extends DefaultTask { + + private static final Attributes.Name FMLAT = new Attributes.Name("FMLAT"); + + /** Dependency jars to scan for {@code FMLAT} declarations (typically compileClasspath + runtimeClasspath). */ + @InputFiles + public abstract ConfigurableFileCollection getDependencies(); + + /** Directory where extracted AT cfgs are written: one file per (jar, at) pair. */ + @OutputDirectory + public abstract DirectoryProperty getOutputDirectory(); + + @TaskAction + public void extract() throws IOException { + var outDir = getOutputDirectory().get().getAsFile().toPath(); + Files.createDirectories(outDir); + + for (var file : getDependencies().getFiles()) { + if (file == null || !file.isFile() || !file.getName().toLowerCase().endsWith(".jar")) { + continue; + } + try (var jar = new JarFile(file, false)) { + var manifest = jar.getManifest(); + if (manifest == null) { + continue; + } + var atNames = manifest.getMainAttributes().getValue(FMLAT); + if (atNames == null || atNames.isBlank()) { + continue; + } + var jarBase = stripExtension(file.getName()); + for (var rawName : atNames.split(" ")) { + var atName = rawName.trim(); + if (atName.isEmpty()) { + continue; + } + var entry = jar.getEntry("META-INF/" + atName); + if (entry == null) { + getLogger().warn("Dependency AT '{}!META-INF/{}' not found, skipping.", file.getName(), atName); + continue; + } + String content; + try (InputStream in = jar.getInputStream(entry)) { + content = new String(in.readAllBytes(), StandardCharsets.UTF_8).replaceAll("\r\n", "\n"); + } + var outFile = outDir.resolve(jarBase + "-" + sanitize(atName) + ".cfg"); + Files.writeString(outFile, content, StandardCharsets.UTF_8); + getLogger().lifecycle("Extracted dependency AT '{}' from {}", atName, file.getName()); + } + } catch (IOException ignored) { + // Not a readable jar — skip. + } + } + } + + private static String stripExtension(String name) { + var i = name.lastIndexOf('.'); + return i > 0 ? name.substring(0, i) : name; + } + + private static String sanitize(String s) { + return s.replaceAll("[^A-Za-z0-9._-]", "_"); + } +} diff --git a/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/LegacyForgeArtifacts.java b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/LegacyForgeArtifacts.java new file mode 100644 index 00000000..e58472ff --- /dev/null +++ b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/LegacyForgeArtifacts.java @@ -0,0 +1,22 @@ +package net.neoforged.moddevgradle.mcpforge.internal; + +import net.neoforged.moddevgradle.internal.utils.VersionCapabilitiesInternal; + +public final class LegacyForgeArtifacts { + private LegacyForgeArtifacts() {} + + static String userdevClassifier(VersionCapabilitiesInternal versionCapabilities) { + if ("1.12.2".equals(versionCapabilities.minecraftVersion())) { + return "userdev3"; + } + return "userdev"; + } + + static String userdevNotation(String groupId, String version, VersionCapabilitiesInternal versionCapabilities) { + return groupId + ":forge:" + version + ":" + userdevClassifier(versionCapabilities); + } + + static String userdevJarName(String moduleName, String version, VersionCapabilitiesInternal versionCapabilities) { + return moduleName + "-" + version + "-" + userdevClassifier(versionCapabilities) + ".jar"; + } +} diff --git a/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/LegacyForgeMetadataTransform.java b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/LegacyForgeMetadataTransform.java new file mode 100644 index 00000000..d4686fa5 --- /dev/null +++ b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/LegacyForgeMetadataTransform.java @@ -0,0 +1,127 @@ +package net.neoforged.moddevgradle.mcpforge.internal; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import javax.inject.Inject; +import net.neoforged.moddevgradle.internal.utils.VersionCapabilitiesInternal; +import org.gradle.api.Action; +import org.gradle.api.artifacts.CacheableRule; +import org.gradle.api.artifacts.ComponentMetadataContext; +import org.gradle.api.artifacts.DirectDependenciesMetadata; +import org.gradle.api.artifacts.MutableVariantFilesMetadata; +import org.gradle.api.artifacts.repositories.RepositoryResourceAccessor; +import org.gradle.api.attributes.Bundling; +import org.gradle.api.attributes.Category; +import org.gradle.api.attributes.LibraryElements; +import org.gradle.api.attributes.Usage; +import org.gradle.api.model.ObjectFactory; + +@CacheableRule +public class LegacyForgeMetadataTransform extends LegacyMetadataTransform { + @Inject + public LegacyForgeMetadataTransform(ObjectFactory objects, RepositoryResourceAccessor repositoryResourceAccessor) { + super(objects, repositoryResourceAccessor); + } + + @Override + public void execute(ComponentMetadataContext context) { + var versionCapabilities = VersionCapabilitiesInternal.ofForgeVersion(context.getDetails().getId().getVersion()); + executeWithConfig(context, createPath(context, LegacyForgeArtifacts.userdevClassifier(versionCapabilities), "jar")); + } + + @Override + public void adaptWithConfig(ComponentMetadataContext context, JsonObject config) { + var details = context.getDetails(); + var id = details.getId(); + var versionCapabilities = VersionCapabilitiesInternal.ofForgeVersion(id.getVersion()); + + var userdevJarName = LegacyForgeArtifacts.userdevJarName(id.getName(), id.getVersion(), versionCapabilities); + var universalJarName = id.getName() + "-" + id.getVersion() + "-universal.jar"; + + Action vanillaDependencies = deps -> { + deps.add("de.oceanlabs.mcp:mcp_config:" + id.getVersion().split("-")[0]); + deps.add("net.neoforged:minecraft-dependencies:" + id.getVersion().split("-")[0]); + }; + + details.addVariant("modDevConfig", variantMetadata -> { + variantMetadata.withFiles(metadata -> metadata.addFile(userdevJarName, userdevJarName)); + variantMetadata.withCapabilities(capabilities -> { + capabilities.addCapability("net.neoforged", "neoforge-moddev-config", id.getVersion()); + }); + }); + details.addVariant("modDevBundle", variantMetadata -> { + variantMetadata.withFiles(metadata -> metadata.addFile(userdevJarName, userdevJarName)); + variantMetadata.withDependencies(vanillaDependencies); + variantMetadata.withCapabilities(capabilities -> { + capabilities.addCapability("net.neoforged", "neoforge-moddev-bundle", id.getVersion()); + }); + }); + details.addVariant("modDevModulePath", variantMetadata -> { + variantMetadata.withDependencies(dependencies -> { + // Support versions that do not declare modules + if (config.has("modules")) { + var modules = config.getAsJsonArray("modules"); + for (JsonElement module : modules) { + dependencies.add(module.getAsString()); + } + } + }); + variantMetadata.withCapabilities(capabilities -> { + capabilities.addCapability("net.neoforged", "neoforge-moddev-module-path", id.getVersion()); + }); + }); + details.addVariant("modDevApiElements", variantMetadata -> { + variantMetadata.attributes(attributes -> { + attributes.attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.class, Category.LIBRARY)); + attributes.attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.class, Bundling.EXTERNAL)); + attributes.attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.class, Usage.JAVA_API)); + }); + variantMetadata.withDependencies(dependencies -> { + var libraries = config.getAsJsonArray("libraries"); + for (JsonElement library : libraries) { + dependencies.add(library.getAsString()); + } + }); + variantMetadata.withDependencies(vanillaDependencies); + variantMetadata.withCapabilities(capabilities -> { + capabilities.addCapability("net.neoforged", "neoforge-dependencies", id.getVersion()); + }); + }); + details.addVariant("modDevRuntimeElements", variantMetadata -> { + variantMetadata.attributes(attributes -> { + attributes.attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.class, Category.LIBRARY)); + attributes.attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.class, Bundling.EXTERNAL)); + attributes.attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.class, Usage.JAVA_RUNTIME)); + }); + variantMetadata.withCapabilities(capabilities -> { + capabilities.removeCapability(id.getGroup(), id.getName()); + capabilities.addCapability("net.neoforged", "neoforge-dependencies", id.getVersion()); + }); + variantMetadata.withDependencies(vanillaDependencies); + variantMetadata.withFiles(MutableVariantFilesMetadata::removeAllFiles); + variantMetadata.withDependencies(dependencies -> { + // Support versions that do not declare modules + if (config.has("modules")) { + var modules = config.getAsJsonArray("modules"); + for (JsonElement module : modules) { + dependencies.add(module.getAsString()); + } + } + var libraries = config.getAsJsonArray("libraries"); + for (JsonElement library : libraries) { + dependencies.add(library.getAsString()); + } + }); + }); + + details.addVariant("universalJar", variantMetadata -> { + variantMetadata.attributes(attributes -> { + attributes.attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.class, Category.LIBRARY)); + attributes.attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.class, Bundling.EXTERNAL)); + attributes.attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.class, Usage.JAVA_RUNTIME)); + attributes.attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements.class, LibraryElements.JAR)); + }); + variantMetadata.withFiles(metadata -> metadata.addFile(universalJarName, universalJarName)); + }); + } +} diff --git a/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/LegacyMetadataTransform.java b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/LegacyMetadataTransform.java new file mode 100644 index 00000000..05f77133 --- /dev/null +++ b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/LegacyMetadataTransform.java @@ -0,0 +1,63 @@ +package net.neoforged.moddevgradle.mcpforge.internal; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import java.io.BufferedInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.jar.JarInputStream; +import org.gradle.api.GradleException; +import org.gradle.api.artifacts.ComponentMetadataContext; +import org.gradle.api.artifacts.ComponentMetadataRule; +import org.gradle.api.artifacts.repositories.RepositoryResourceAccessor; +import org.gradle.api.model.ObjectFactory; + +public abstract class LegacyMetadataTransform implements ComponentMetadataRule { + protected final ObjectFactory objects; + private final RepositoryResourceAccessor repositoryResourceAccessor; + + LegacyMetadataTransform(ObjectFactory objects, RepositoryResourceAccessor repositoryResourceAccessor) { + this.objects = objects; + this.repositoryResourceAccessor = repositoryResourceAccessor; + } + + protected final void executeWithConfig(ComponentMetadataContext context, String path) { + JsonObject[] configRootHolder = new JsonObject[1]; + repositoryResourceAccessor.withResource(path, inputStream -> { + try (var zin = new JarInputStream(new BufferedInputStream(inputStream))) { + for (var entry = zin.getNextJarEntry(); entry != null; entry = zin.getNextJarEntry()) { + if (entry.getName().equals("config.json")) { + var configJson = new String(zin.readAllBytes(), StandardCharsets.UTF_8); + configRootHolder[0] = new Gson().fromJson(configJson, JsonObject.class); + } + } + } catch (IOException e) { + throw new GradleException("Failed to read " + path); + } + }); + + if (configRootHolder[0] == null) { + throw new GradleException("Couldn't find config.json in " + path); + } + adaptWithConfig(context, configRootHolder[0]); + + // Use a fake capability to make it impossible for the implicit variants to be selected + for (var implicitVariantName : List.of("compile", "runtime")) { + var details = context.getDetails(); + details.withVariant(implicitVariantName, variant -> { + variant.withCapabilities(caps -> { + caps.removeCapability(details.getId().getGroup(), details.getId().getName()); + caps.addCapability("___dummy___", "___dummy___", "___dummy___"); + }); + }); + } + } + + protected abstract void adaptWithConfig(ComponentMetadataContext context, JsonObject config); + + protected final String createPath(ComponentMetadataContext context, String classifier, String extension) { + var id = context.getDetails().getId(); + return id.getGroup().replace('.', '/') + "/" + id.getName() + "/" + id.getVersion() + "/" + (id.getName() + "-" + id.getVersion() + (classifier.isBlank() ? "" : "-" + classifier) + "." + extension); + } +} diff --git a/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/Lwjgl2Natives.java b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/Lwjgl2Natives.java new file mode 100644 index 00000000..404ab781 --- /dev/null +++ b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/Lwjgl2Natives.java @@ -0,0 +1,67 @@ +package net.neoforged.moddevgradle.mcpforge.internal; + +import java.util.List; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.ExternalModuleDependency; +import org.gradle.api.artifacts.ModuleDependency; +import org.gradle.api.artifacts.dsl.DependencyFactory; + +final class Lwjgl2Natives { + static final List APPLE_NATIVE_REPLACEMENT_DEPENDENCIES = List.of( + "org.lwjgl.lwjgl:lwjgl:2.9.4-nightly-20150209", + "org.lwjgl.lwjgl:lwjgl_util:2.9.4-nightly-20150209", + "org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209-mmachina.2:natives-osx@jar", + "ca.weblite:java-objc-bridge:1.1.0-mmachina.1", + "com.mojang:text2speech:1.11.3"); + + private Lwjgl2Natives() {} + + static void configureRuntime(Configuration configuration, DependencyFactory dependencyFactory, String minecraftVersion) { + configureRuntime(configuration, dependencyFactory, minecraftVersion, System.getProperty("os.name"), System.getProperty("os.arch")); + } + + static void configureRuntime(Configuration configuration, DependencyFactory dependencyFactory, String minecraftVersion, String osName, String osArch) { + if (!shouldUseAppleNativeReplacement(minecraftVersion, osName, osArch)) { + return; + } + + replaceMojangLwjgl2Dependencies(configuration); + for (var notation : appleNativeReplacementDependencies()) { + var dependency = dependencyFactory.create(notation); + ((ExternalModuleDependency) dependency).setTransitive(false); + configuration.getDependencies().add(dependency); + } + } + + static void configure(Configuration nativeLibraries, DependencyFactory dependencyFactory, String minecraftVersion) { + configure(nativeLibraries, dependencyFactory, minecraftVersion, System.getProperty("os.name"), System.getProperty("os.arch")); + } + + static void configure(Configuration nativeLibraries, DependencyFactory dependencyFactory, String minecraftVersion, String osName, String osArch) { + if (shouldUseAppleNativeReplacement(minecraftVersion, osName, osArch)) { + nativeLibraries.getDependencies().add(dependencyFactory.create("org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209-mmachina.2:natives-osx@jar")); + } + } + + static List appleNativeReplacementDependencies() { + return APPLE_NATIVE_REPLACEMENT_DEPENDENCIES; + } + + static boolean shouldUseAppleNativeReplacement(String minecraftVersion, String osName, String osArch) { + return "1.12.2".equals(minecraftVersion) + && osName.startsWith("Mac OS X") + && ("aarch64".equals(osArch) || "arm64".equals(osArch)); + } + + private static void replaceMojangLwjgl2Dependencies(Configuration configuration) { + configuration.getDependencies().configureEach(dependency -> { + if (dependency instanceof ModuleDependency moduleDependency) { + moduleDependency.exclude(java.util.Map.of("group", "org.lwjgl.lwjgl", "module", "lwjgl")); + moduleDependency.exclude(java.util.Map.of("group", "org.lwjgl.lwjgl", "module", "lwjgl_util")); + moduleDependency.exclude(java.util.Map.of("group", "org.lwjgl.lwjgl", "module", "lwjgl-platform")); + moduleDependency.exclude(java.util.Map.of("group", "ca.weblite", "module", "java-objc-bridge")); + moduleDependency.exclude(java.util.Map.of("group", "com.mojang", "module", "text2speech")); + } + }); + } +} diff --git a/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/MappingsDisambiguationRule.java b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/MappingsDisambiguationRule.java new file mode 100644 index 00000000..18b6fcf8 --- /dev/null +++ b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/MappingsDisambiguationRule.java @@ -0,0 +1,28 @@ +package net.neoforged.moddevgradle.mcpforge.internal; + +import javax.inject.Inject; +import net.neoforged.moddevgradle.legacyforge.internal.MinecraftMappings; +import org.gradle.api.attributes.AttributeDisambiguationRule; +import org.gradle.api.attributes.MultipleCandidatesDetails; + +/** + * This disambiguation rule will prefer NAMED over SRG when both are present. + */ +public class MappingsDisambiguationRule implements AttributeDisambiguationRule { + private final MinecraftMappings named; + + @Inject + public MappingsDisambiguationRule(MinecraftMappings named) { + this.named = named; + } + + @Override + public void execute(MultipleCandidatesDetails details) { + var consumerValue = details.getConsumerValue(); + if (consumerValue == null) { + if (details.getCandidateValues().contains(named)) { + details.closestMatch(named); + } + } + } +} diff --git a/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/McpForgeModDevPlugin.java b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/McpForgeModDevPlugin.java new file mode 100644 index 00000000..2f4a2940 --- /dev/null +++ b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/McpForgeModDevPlugin.java @@ -0,0 +1,625 @@ +package net.neoforged.moddevgradle.mcpforge.internal; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.stream.Stream; +import javax.inject.Inject; +import net.neoforged.minecraftdependencies.MinecraftDependenciesPlugin; +import net.neoforged.moddevgradle.internal.ArtifactNamingStrategy; +import net.neoforged.moddevgradle.internal.Branding; +import net.neoforged.moddevgradle.internal.DataFileCollections; +import net.neoforged.moddevgradle.internal.McpToolchainHooks; +import net.neoforged.moddevgradle.internal.ModDevArtifactsWorkflow; +import net.neoforged.moddevgradle.internal.ModDevRunWorkflow; +import net.neoforged.moddevgradle.internal.ModdingDependencies; +import net.neoforged.moddevgradle.internal.RunGameTask; +import net.neoforged.moddevgradle.internal.jarjar.JarJarPlugin; +import net.neoforged.moddevgradle.internal.utils.ExtensionUtils; +import net.neoforged.moddevgradle.internal.utils.StringUtils; +import net.neoforged.moddevgradle.internal.utils.VersionCapabilitiesInternal; +import net.neoforged.moddevgradle.legacyforge.tasks.PopulateForgeGradleMcpCache; +import net.neoforged.moddevgradle.mcpforge.dsl.McpForgeModdingSettings; +import net.neoforged.moddevgradle.legacyforge.dsl.ObfuscationExtension; +import net.neoforged.moddevgradle.legacyforge.internal.LegacyForgeLibraryMetadataRule; +import net.neoforged.moddevgradle.legacyforge.internal.LegacyRepositoriesPlugin; +import net.neoforged.moddevgradle.legacyforge.internal.MinecraftMappings; +import net.neoforged.moddevgradle.legacyforge.internal.NonStrictDependencyTransform; +import net.neoforged.moddevgradle.mcpforge.dsl.McpForgeExtension; +import net.neoforged.nfrtgradle.CreateMinecraftArtifacts; +import net.neoforged.nfrtgradle.NeoFormRuntimeExtension; +import net.neoforged.nfrtgradle.NeoFormRuntimePlugin; +import org.gradle.api.InvalidUserCodeException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.ProjectDependency; +import org.gradle.api.artifacts.dsl.DependencyFactory; +import org.gradle.api.artifacts.type.ArtifactTypeDefinition; +import org.gradle.api.model.ObjectFactory; +import org.gradle.api.plugins.JavaLibraryPlugin; +import org.gradle.api.plugins.JavaPlugin; +import org.gradle.api.plugins.JavaPluginExtension; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.SourceSetContainer; +import org.gradle.jvm.toolchain.JavaLanguageVersion; +import org.gradle.jvm.toolchain.JavaToolchainService; +import org.gradle.jvm.tasks.Jar; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The MCP-based Forge toolchain for 1.12.2 and other legacy versions (plugin id {@code net.neoforged.moddev.mcpforge}). + * + *

This plugin owns its pipeline independently (it does NOT apply the {@code legacyforge} plugin), reusing legacy's + * public classes and a set of MCP-specific SPI hooks (LWJGL2 Apple Silicon natives, Forge deobf-data remapping, + * coremod discovery, launchwrapper Java 8 runtime, pre-1.13 resource merge, SRG refmap). + */ +public class McpForgeModDevPlugin implements Plugin { + private static final Logger LOG = LoggerFactory.getLogger(McpForgeModDevPlugin.class); + + public static final String MIXIN_EXTENSION = "mixin"; + public static final String OBFUSCATION_EXTENSION = "obfuscation"; + public static final String MCPFORGE_EXTENSION = "mcpForge"; + + public static final String CONFIGURATION_TOOL_ART = "autoRenamingToolRuntime"; + public static final String CONFIGURATION_TOOL_INSTALLERTOOLS = "installerToolsRuntime"; + private static final String LEGACY_MCP_NFRT_EXAMPLE = "2.0.19-legacy"; + + private final MinecraftMappings namedMappings; + private final MinecraftMappings srgMappings; + + @Inject + public McpForgeModDevPlugin(ObjectFactory objectFactory) { + namedMappings = objectFactory.named(MinecraftMappings.class, MinecraftMappings.NAMED); + srgMappings = objectFactory.named(MinecraftMappings.class, MinecraftMappings.SRG); + } + + @Override + public void apply(Project project) { + // Base plugins + project.getPlugins().apply(JavaLibraryPlugin.class); + project.getPlugins().apply(NeoFormRuntimePlugin.class); + project.getPlugins().apply(MinecraftDependenciesPlugin.class); + project.getPlugins().apply(JarJarPlugin.class); + + // Skip applying repositories at the project level if they were already applied at settings level. + if (!project.getGradle().getPlugins().hasPlugin(LegacyRepositoriesPlugin.class)) { + project.getPlugins().apply(LegacyRepositoriesPlugin.class); + } else { + LOG.info("Not enabling legacy repositories since they were applied at the settings level"); + } + + // Apple Silicon LWJGL2 native repos (GitHub releases by MinecraftMachina). Needed for 1.12.2 on ARM64 macOS. + var repos = project.getRepositories(); + repos.ivy(repo -> { + repo.setName("MinecraftMachina Apple Silicon LWJGL2"); + repo.setUrl(URI.create("https://github.com/MinecraftMachina/lwjgl/releases/download/2.9.4-20150209-mmachina.2/")); + repo.patternLayout(layout -> layout.artifact("[module]-2.9.4-nightly-20150209-[classifier].[ext]")); + repo.metadataSources(sources -> sources.artifact()); + repo.content(content -> { + content.includeModule("org.lwjgl.lwjgl", "lwjgl"); + content.includeModule("org.lwjgl.lwjgl", "lwjgl-platform"); + content.includeModule("org.lwjgl.lwjgl", "lwjgl_util"); + }); + }); + repos.ivy(repo -> { + repo.setName("MinecraftMachina Java Objective-C Bridge"); + repo.setUrl(URI.create("https://github.com/MinecraftMachina/Java-Objective-C-Bridge/releases/download/1.1.0-mmachina.1/")); + repo.patternLayout(layout -> layout.artifact("[module]-1.1.[ext]")); + repo.metadataSources(sources -> sources.artifact()); + repo.content(content -> content.includeModule("ca.weblite", "java-objc-bridge")); + }); + + // Metadata transforms + project.getDependencies().getComponents().withModule("net.minecraftforge:forge", LegacyForgeMetadataTransform.class); + project.getDependencies().getComponents().withModule("net.minecraftforge:forge", LegacyForgeLibraryMetadataRule.class); + project.getDependencies().getComponents().withModule("de.oceanlabs.mcp:mcp_config", McpMetadataTransform.class); + // Legacy Forge upgrades some deps (e.g. log4j2); relax the strict version requirements we can't otherwise fix. + project.getDependencies().getComponents().withModule("net.neoforged:minecraft-dependencies", NonStrictDependencyTransform.class); + + // Tool configurations + var depFactory = project.getDependencyFactory(); + var autoRenamingToolRuntime = project.getConfigurations().create(CONFIGURATION_TOOL_ART, spec -> { + spec.setDescription("The AutoRenamingTool CLI tool"); + spec.setCanBeConsumed(false); + spec.setCanBeResolved(true); + spec.setTransitive(false); + spec.getDependencies().add(depFactory.create("net.neoforged:AutoRenamingTool:2.0.4:all")); + }); + var installerToolsRuntime = project.getConfigurations().create(CONFIGURATION_TOOL_INSTALLERTOOLS, spec -> { + spec.setDescription("The InstallerTools CLI tool"); + spec.setCanBeConsumed(false); + spec.setCanBeResolved(true); + spec.setTransitive(false); + spec.getDependencies().add(depFactory.create("net.neoforged.installertools:installertools:3.0.4:fatjar")); + }); + + // Extensions. extraMixinMappings shares mixin-generated mappings with the obfuscation extension. + var extraMixinMappings = project.files(); + var obf = project.getExtensions().create(OBFUSCATION_EXTENSION, ObfuscationExtension.class, project, autoRenamingToolRuntime, installerToolsRuntime, extraMixinMappings); + var mixin = project.getExtensions().create(MIXIN_EXTENSION, MixinExtension.class, project, obf.getNamedToSrgMappings(), extraMixinMappings); + // Defaults matching MixinGradle. + mixin.getDefaultObfuscationEnv().convention("searge"); + mixin.getQuiet().convention(false); + mixin.getShowMessageTypes().convention(false); + mixin.getDisableTargetValidator().convention(false); + mixin.getDisableTargetExport().convention(false); + mixin.getDisableOverwriteChecker().convention(false); + + configureDependencyRemapping(project, obf); + + var dataFileCollections = DataFileCollections.create(project); + project.getExtensions().create( + MCPFORGE_EXTENSION, + McpForgeExtension.class, + project, + dataFileCollections.accessTransformers().extension(), + dataFileCollections.interfaceInjectionData().extension()); + + + // Register MCP hooks so the main workflow picks up LWJGL2 natives. + project.getExtensions().add(McpToolchainHooks.EXTENSION_NAME, new McpHooks()); + + // Collect Access Transformers declared by dependency jars via their FMLAT manifest (FG2/RFG parity). + configureDependencyAccessTransformers(project); + + // Configure RunGameTask tasks (lazy — matches whenever they are created). + configureRunTasks(project); + } + + + public void enable(Project project, McpForgeModdingSettings settings, McpForgeExtension extension) { + var depFactory = project.getDependencyFactory(); + + var forgeVersion = settings.getForgeVersion(); + var neoForgeVersion = settings.getNeoForgeVersion(); + var mcpVersion = settings.getMcpVersion(); + + ModdingDependencies dependencies; + ArtifactNamingStrategy artifactNamingStrategy; + VersionCapabilitiesInternal versionCapabilities; + if (forgeVersion != null || neoForgeVersion != null) { + // All settings are mutually exclusive + if (forgeVersion != null && neoForgeVersion != null || mcpVersion != null) { + throw new InvalidUserCodeException("Specifying a Forge version is mutually exclusive with NeoForge or MCP"); + } + + var version = forgeVersion != null ? forgeVersion : neoForgeVersion; + versionCapabilities = VersionCapabilitiesInternal.ofForgeVersion(version); + validateNeoFormRuntimeSupport(project, versionCapabilities); + artifactNamingStrategy = ArtifactNamingStrategy.createNeoForge(versionCapabilities, "forge", version); + + String groupId = forgeVersion != null ? "net.minecraftforge" : "net.neoforged"; + var neoForge = depFactory.create(groupId + ":forge:" + version); + var neoForgeNotation = LegacyForgeArtifacts.userdevNotation(groupId, version, versionCapabilities); + dependencies = ModdingDependencies.create(neoForge, neoForgeNotation, null, null, versionCapabilities); + } else if (mcpVersion != null) { + versionCapabilities = VersionCapabilitiesInternal.ofMinecraftVersion(mcpVersion); + artifactNamingStrategy = ArtifactNamingStrategy.createVanilla(mcpVersion); + + var neoForm = depFactory.create("de.oceanlabs.mcp:mcp_config:" + mcpVersion); + var neoFormNotation = "de.oceanlabs.mcp:mcp_config:" + mcpVersion + "@zip"; + dependencies = ModdingDependencies.createVanillaOnly(neoForm, neoFormNotation); + } else { + throw new InvalidUserCodeException("You must specify a Forge, NeoForge or MCP version"); + } + + var configurations = project.getConfigurations(); + + var artifacts = ModDevArtifactsWorkflow.create( + project, + settings.getEnabledSourceSets(), + Branding.MDG, + extension, + dependencies, + artifactNamingStrategy, + configurations.getByName(DataFileCollections.CONFIGURATION_ACCESS_TRANSFORMERS), + configurations.getByName(DataFileCollections.CONFIGURATION_INTERFACE_INJECTION_DATA), + versionCapabilities, + settings.isDisableRecompilation(), + settings.getMcpMappings()); + + // Configure the mixin and obfuscation extensions. + var mixin = ExtensionUtils.getExtension(project, MIXIN_EXTENSION, MixinExtension.class); + var obf = ExtensionUtils.getExtension(project, OBFUSCATION_EXTENSION, ObfuscationExtension.class); + + var namedToIntermediate = artifacts.requestAdditionalMinecraftArtifact("namedToIntermediaryMapping", "namedToIntermediate.tsrg"); + obf.getNamedToSrgMappings().set(namedToIntermediate); + var intermediateToNamed = artifacts.requestAdditionalMinecraftArtifact("intermediaryToNamedMapping", "intermediateToNamed.srg"); + var mappingsCsv = artifacts.requestAdditionalMinecraftArtifact("csvMapping", "intermediateToNamed.zip"); + obf.getSrgToNamedMappings().set(mappingsCsv); + var notchToIntermediate = artifacts.requestAdditionalMinecraftArtifact("notchToIntermediaryMapping", "notchToIntermediate.srg"); + + // ForgeGradle-2 compatibility: mirror the MCP data into the FG-2 cache layout + // (~/.gradle/caches/minecraft/de/oceanlabs/mcp///) so legacy tooling/scripts that hardcode the + // FG-2 path keep working. Only done for legacy MCP versions (e.g. 1.12.2). + if (settings.getMcpMappings() != null) { + // Resolve the FG-2 cache base dir at configuration time so the task stays config-cache compatible. + var mcpCoordinate = settings.getMcpMappings(); + var gradleUserHome = project.getGradle().getGradleUserHomeDir().toPath().toAbsolutePath().toString(); + var fg2CacheBase = project.getProviders().provider(() -> { + var withoutExt = mcpCoordinate.indexOf('@') >= 0 ? mcpCoordinate.substring(0, mcpCoordinate.indexOf('@')) : mcpCoordinate; + var parts = withoutExt.split(":"); + var name = parts.length > 1 ? parts[1] : "mcp"; + var rawVersion = parts.length > 2 ? parts[2] : "unknown"; + var version = rawVersion.indexOf('-') >= 0 ? rawVersion.substring(0, rawVersion.indexOf('-')) : rawVersion; + return Path.of(gradleUserHome, "caches", "minecraft", "de", "oceanlabs", "mcp", name, version).toString(); + }); + project.getTasks().register("populateForgeGradleMcpCache", PopulateForgeGradleMcpCache.class, task -> { + task.setGroup(Branding.MDG.internalTaskGroup()); + task.setDescription("Populates the ForgeGradle-2 MCP cache directory with ModDevGradle's MCP data for legacy-tool compatibility."); + task.getMcpMappings().set(mcpCoordinate); + task.getMinecraftVersion().set(versionCapabilities.minecraftVersion()); + task.getCacheBaseDirectory().set(fg2CacheBase); + task.getCsvMappings().set(mappingsCsv); + task.getSrgToMcpMappings().set(intermediateToNamed); + task.getMcpToSrgMappings().set(namedToIntermediate); + task.getNotchToSrgMappings().set(notchToIntermediate); + }); + project.getTasks().named("createMinecraftArtifacts", task -> task.finalizedBy("populateForgeGradleMcpCache")); + } + + var runs = ModDevRunWorkflow.create( + project, + Branding.MDG, + artifacts, + extension.getRuns(), + Map.of( + "mcp_to_srg", intermediateToNamed.map(file -> file.getAsFile().getAbsolutePath()), + "mcp_mappings", mappingsCsv.map(file -> file.getAsFile().getAbsolutePath()))); + + extension.getRuns().configureEach(run -> { + // Old BSL versions before 2022 did not export any packages, blocking DevLaunch from the main method. + if (versionCapabilities.javaVersion() > 8) { + run.getJvmArguments().addAll("--add-exports", "cpw.mods.bootstraplauncher/cpw.mods.bootstraplauncher=ALL-UNNAMED"); + } + if ("1.12.2".equals(versionCapabilities.minecraftVersion())) { + run.getSystemProperties().put("fml.ignorePatchDiscrepancies", "true"); + run.getSystemProperties().put("fml.ignoreInvalidMinecraftCertificates", "true"); + } + + if (!versionCapabilities.modLocatorRework()) { + // Pre-1.13 FML only loads a mod's resources from the @Mod class' source directory, so Gradle's split + // output (classes vs resources) leaves assets/mcmod.info/lang missing at runtime. Colocate them. + var modSourceSet = run.getSourceSet().get(); + modSourceSet.getOutput().setResourcesDir(modSourceSet.getJava().getDestinationDirectory().get().getAsFile()); + } + + // Mixin needs the SRG->named mapping in SRG (not TSRG) format to ignore dependency refmaps. + run.getSystemProperties().put("mixin.env.remapRefMap", "true"); + run.getSystemProperties().put("mixin.env.refMapRemappingFile", intermediateToNamed.map(f -> f.getAsFile().getAbsolutePath())); + + run.getProgramArguments().addAll(mixin.getConfigs().map(cfgs -> cfgs.stream().flatMap(config -> Stream.of("--mixin.config", config)).toList())); + }); + + if (settings.isObfuscateJar()) { + var reobfJar = obf.reobfuscate( + project.getTasks().named(JavaPlugin.JAR_TASK_NAME, Jar.class), + project.getExtensions().getByType(SourceSetContainer.class).getByName(SourceSet.MAIN_SOURCE_SET_NAME)); + + project.getTasks().named("assemble", assemble -> assemble.dependsOn(reobfJar)); + } + + // Forge expects the mapping csv files on the root classpath. + artifacts.runtimeDependencies() + .getDependencies().add(project.getDependencyFactory().create(project.files(mappingsCsv))); + + var remapDeps = project.getConfigurations().create("remappingDependencies", spec -> { + spec.setDescription("An internal configuration that contains the Minecraft dependencies, used for remapping mods"); + spec.setCanBeConsumed(false); + spec.setCanBeDeclared(false); + spec.setCanBeResolved(true); + spec.extendsFrom(artifacts.runtimeDependencies()); + }); + + // Resolve the declared Mixin provider coordinates (mixinProvider DSL) to their raw SRG jars so the + // RemappingTransform can exempt them from the bundled-spongepowered strip. Resolved lazily. + var mixinProviderConfig = project.getConfigurations().create("mcpMixinProviders", spec -> { + spec.setDescription("Mixin provider jars — exempt from bundled-spongepowered stripping"); + spec.setCanBeConsumed(false); + spec.setCanBeResolved(true); + spec.setTransitive(false); + spec.getAttributes().attribute(MinecraftMappings.ATTRIBUTE, srgMappings); + }); + // Defer reading the mixinProvider list to resolution time so the build script can call mixinProvider() before + // OR after enable(). + mixinProviderConfig.withDependencies(deps -> + extension.getMixinProviders().get().forEach(notation -> + deps.add(project.getDependencyFactory().create(notation)))); + + // The RemappingTransform strips bundled org.spongepowered.asm.* from non-provider jars (post-remap) so an old + // bundled Mixin can't shadow the declared provider. + project.getDependencies().registerTransform(RemappingTransform.class, params -> { + params.parameters(parameters -> { + obf.configureSrgToNamedOperation(parameters.getRemapOperation()); + parameters.getMinecraftDependencies().from(remapDeps); + parameters.getMixinProviders().from(mixinProviderConfig); + }); + params.getFrom() + .attribute(MinecraftMappings.ATTRIBUTE, srgMappings) + .attribute(ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE, ArtifactTypeDefinition.JAR_TYPE); + params.getTo() + .attribute(MinecraftMappings.ATTRIBUTE, namedMappings) + .attribute(ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE, ArtifactTypeDefinition.JAR_TYPE); + }); + } + + private static void validateNeoFormRuntimeSupport(Project project, VersionCapabilitiesInternal versionCapabilities) { + if (!"1.12.2".equals(versionCapabilities.minecraftVersion())) { + return; + } + + var neoFormRuntime = ExtensionUtils.getExtension(project, NeoFormRuntimeExtension.NAME, NeoFormRuntimeExtension.class); + var nfrtVersion = neoFormRuntime.getVersion().get(); + if (!isKnownIncompatibleWithForge1122(nfrtVersion)) { + return; + } + + throw new InvalidUserCodeException(""" + Forge 1.12.2 requires NeoFormRuntime with legacy MCP support. The selected NeoFormRuntime version '%s' does not provide the --mcp-mappings CLI option used for Forge userdev3. + Set the Gradle property 'neoForge.neoFormRuntime.version' or the neoFormRuntime.version extension property to a compatible NFRT build, such as '%s', until that support is available in the default NFRT release.""".formatted(nfrtVersion, LEGACY_MCP_NFRT_EXAMPLE)); + } + + private static boolean isKnownIncompatibleWithForge1122(String nfrtVersion) { + return "2.0.18".equals(nfrtVersion) || "2.0.19".equals(nfrtVersion); + } + + private void configureDependencyRemapping(Project project, ObfuscationExtension obf) { + // JarJar cross-project deps must be remapped to SRG without affecting external deps (already in the right + // namespace). Requesting the srg attribute on cross-project deps excludes the named variant from selection. + var sourceSets = ExtensionUtils.getSourceSets(project); + sourceSets.all(sourceSet -> { + var configurationName = sourceSet.getTaskName(null, "jarJar"); + project.getConfigurations().getByName(configurationName).withDependencies(dependencies -> { + dependencies.forEach(dep -> { + if (dep instanceof ProjectDependency projectDependency) { + projectDependency.attributes(a -> { + a.attribute(MinecraftMappings.ATTRIBUTE, srgMappings); + }); + } + }); + }); + }); + + project.getDependencies().attributesSchema(schema -> { + var attr = schema.attribute(MinecraftMappings.ATTRIBUTE); + // Prefer named variants for cross-project deps where both named and obfuscated variants are available. + attr.getDisambiguationRules().add(MappingsDisambiguationRule.class, config -> { + config.params(namedMappings); + }); + }); + // Give every jar the srg attribute so it can be force-remapped by requesting named. + project.getDependencies().getArtifactTypes().named(ArtifactTypeDefinition.JAR_TYPE, type -> { + type.getAttributes().attribute(MinecraftMappings.ATTRIBUTE, srgMappings); + }); + + // Loom-style transitive mod* configurations: a modImplementation dep pulls its own transitives (e.g. + // CraftTweaker2-Main -> API -> ZenScript). Transitives resolve to the default SRG variant and are remapped to + // named by the SRG->named transform, triggered because the classpath configurations request named (below). + createTransitiveRemappingConfiguration(project, project.getConfigurations().getByName(JavaPlugin.IMPLEMENTATION_CONFIGURATION_NAME)); + createTransitiveRemappingConfiguration(project, project.getConfigurations().getByName(JavaPlugin.RUNTIME_ONLY_CONFIGURATION_NAME)); + createTransitiveRemappingConfiguration(project, project.getConfigurations().getByName(JavaPlugin.COMPILE_ONLY_CONFIGURATION_NAME)); + createTransitiveRemappingConfiguration(project, project.getConfigurations().getByName(JavaPlugin.API_CONFIGURATION_NAME)); + createTransitiveRemappingConfiguration(project, project.getConfigurations().getByName(JavaPlugin.COMPILE_ONLY_API_CONFIGURATION_NAME)); + + // Request the named variant on the classpath configurations so the SRG->named transform fires on every jar + // in the graph. Non-MC libs (guava, etc.) have no SRG names, so the transform is a no-op/cache-hit for them. + for (var configName : new String[]{ + JavaPlugin.COMPILE_CLASSPATH_CONFIGURATION_NAME, + JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME, + "testCompileClasspath", + "testRuntimeClasspath"}) { + project.getConfigurations().named(configName, c -> + c.getAttributes().attribute(MinecraftMappings.ATTRIBUTE, namedMappings)); + } + } + + /** + * Creates a transitive {@code mod} configuration (e.g. {@code modImplementation}) that the parent extends + * from. Remapping is driven by the classpath configurations requesting the named attribute. + */ + private static void createTransitiveRemappingConfiguration(Project project, Configuration parent) { + var modConfig = project.getConfigurations().create( + "mod" + StringUtils.capitalize(parent.getName()), spec -> { + spec.setDescription("Mod dependencies of " + parent.getName() + " (transitive, remapped SRG->named)"); + spec.setCanBeConsumed(false); + spec.setCanBeResolved(false); + spec.setTransitive(true); + }); + parent.extendsFrom(modConfig); + } + + /** + * Extracts Access Transformers declared via the {@code FMLAT} manifest attribute in dependency jars and feeds + * them into the {@code accessTransformers} DataFileCollection so NFRT bakes them into the recompiled MC source. + */ + private void configureDependencyAccessTransformers(Project project) { + var atScan = project.getConfigurations().create("mcpAccessTransformerScan", c -> { + c.setDescription("Mod dependencies scanned for FMLAT access transformers (raw SRG variant, no remapping)"); + c.setCanBeConsumed(false); + c.setCanBeResolved(true); + c.getAttributes().attribute( + MinecraftMappings.ATTRIBUTE, + project.getObjects().named(MinecraftMappings.class, MinecraftMappings.SRG)); + }); + for (var name : new String[]{"modImplementation", "modCompileOnly", "modRuntimeOnly"}) { + var bucket = project.getConfigurations().findByName(name); + if (bucket != null) { + atScan.extendsFrom(bucket); + } + } + + var extractDepAts = project.getTasks().register( + "extractDependencyAccessTransformers", ExtractDependencyAccessTransformers.class, task -> { + task.setGroup(Branding.MDG.internalTaskGroup()); + task.setDescription("Extracts Access Transformers declared via FMLAT manifests in dependency jars (FG2/RFG parity)."); + task.getOutputDirectory().set(project.getLayout().getBuildDirectory().dir("moddev/dependencyATs")); + task.getDependencies().from(atScan); + }); + + var lfExt = project.getExtensions().getByType(McpForgeExtension.class); + lfExt.getAccessTransformers().from(extractDepAts.map(t -> t.getOutputDirectory().getAsFileTree())); + project.getTasks().withType(CreateMinecraftArtifacts.class).configureEach(t -> t.dependsOn(extractDepAts)); + } + + private void configureRunTasks(Project project) { + project.getTasks().withType(RunGameTask.class).configureEach(task -> { + // launchwrapper requires Java 8; force a Java 8 launcher when the project toolchain is > 8 (e.g. Jabel). + var javaExt = project.getExtensions().findByType(JavaPluginExtension.class); + if (javaExt != null && javaExt.getToolchain() != null) { + var tv = javaExt.getToolchain().getLanguageVersion(); + if (tv.isPresent() && tv.get().asInt() > 8) { + var tsObj = project.getExtensions().findByName("javaToolchains"); + if (tsObj instanceof JavaToolchainService ts) { + task.getJavaLauncher().set(ts.launcherFor(spec -> + spec.getLanguageVersion().set(JavaLanguageVersion.of(8)))); + } + } + } + + + // Coremod discovery + FG2 cache properties: scan classpathProvider at doFirst time. + task.doFirst(t -> { + var cp = task.getClasspathProvider().getFiles(); + + // The cache task only exists when mcpMappings is configured (legacy MCP builds). + var cacheTask = (PopulateForgeGradleMcpCache) project.getTasks().findByName("populateForgeGradleMcpCache"); + if (cacheTask != null) { + var cacheBase = cacheTask.getCacheBaseDirectory().get(); + var srgsDir = Path.of(cacheBase, cacheTask.getMinecraftVersion().get(), "srgs"); + if (Files.isDirectory(srgsDir)) { + // Mirror FG-2's GradleStartCommon: expose every SRG map it would have generated plus the + // CSV dir, so 1.12.2 mods (e.g. CodeChickenLib) that read these at runtime resolve correctly. + task.systemProperty("net.minecraftforge.gradle.GradleStart.srgDir", srgsDir.toString()); + task.systemProperty("net.minecraftforge.gradle.GradleStart.csvDir", cacheBase); + putSrgProperty(task, srgsDir, "notch-srg", "net.minecraftforge.gradle.GradleStart.srg.notch-srg"); + putSrgProperty(task, srgsDir, "notch-mcp", "net.minecraftforge.gradle.GradleStart.srg.notch-mcp"); + putSrgProperty(task, srgsDir, "srg-mcp", "net.minecraftforge.gradle.GradleStart.srg.srg-mcp"); + putSrgProperty(task, srgsDir, "mcp-srg", "net.minecraftforge.gradle.GradleStart.srg.mcp-srg"); + putSrgProperty(task, srgsDir, "mcp-notch", "net.minecraftforge.gradle.GradleStart.srg.mcp-notch"); + } + } + + var coremodClasses = discoverCoremods(project, cp); + var existing = task.getSystemProperties().get("fml.coreMods.load"); + if (existing instanceof String s && !s.isBlank()) { + for (var c : s.split(",")) { + if (!c.isBlank()) coremodClasses.add(c.trim()); + } + } + if (!coremodClasses.isEmpty()) { + var joined = String.join(",", coremodClasses); + project.getLogger().lifecycle("MCP coremod discovery: fml.coreMods.load={}", joined); + task.systemProperty("fml.coreMods.load", joined); + } + }); + }); + } + + private static void putSrgProperty(RunGameTask task, Path srgsDir, String fileName, String key) { + var file = srgsDir.resolve(fileName + ".srg"); + if (Files.exists(file)) { + task.systemProperty(key, file.toString()); + } + } + + private static Set discoverCoremods(Project project, Set files) { + var coremodClasses = new LinkedHashSet(); + coremodClasses.addAll(scanManifests(files)); + var fromProperty = resolveCoreModClassProperty(project); + if (fromProperty != null) { + coremodClasses.add(fromProperty); + } + return coremodClasses; + } + + private static @Nullable String resolveCoreModClassProperty(Project project) { + var coreModPluginPath = project.findProperty("coreModPluginPath"); + if (coreModPluginPath != null) { + var path = coreModPluginPath.toString().trim(); + if (!path.isEmpty()) { + return path; + } + } + var coreModClass = project.findProperty("coreModClass"); + if (coreModClass == null) { + return null; + } + var value = coreModClass.toString().trim(); + if (value.isEmpty()) { + return null; + } + var modGroup = project.findProperty("modGroup"); + var prefix = modGroup != null && !modGroup.toString().isBlank() + ? modGroup.toString().trim() + : project.getGroup().toString(); + if (prefix.isEmpty()) { + project.getLogger().warn( + "coreModClass '{}' declared but neither 'modGroup' nor a project group is set; using as-is", + value); + return value; + } + return prefix + "." + value; + } + + private static Set scanManifests(Set files) { + var coremodClasses = new LinkedHashSet(); + var seen = new HashSet(); + for (var file : files) { + if (!seen.add(file.toPath())) { + continue; + } + var manifest = readManifest(file); + if (manifest == null) { + continue; + } + var corePlugin = manifest.getMainAttributes().getValue("FMLCorePlugin"); + if (corePlugin != null && !corePlugin.isBlank()) { + coremodClasses.add(corePlugin.trim()); + } + } + return coremodClasses; + } + + private static Manifest readManifest(File file) { + try { + if (file.isDirectory()) { + var mf = file.toPath().resolve("META-INF").resolve("MANIFEST.MF"); + if (!Files.exists(mf)) { + return null; + } + try (var in = Files.newInputStream(mf)) { + return new Manifest(in); + } + } else if (file.getName().endsWith(".jar")) { + try (var jar = new JarFile(file)) { + return jar.getManifest(); + } + } + } catch (IOException ignored) { + } + return null; + } + + static class McpHooks implements McpToolchainHooks { + @Override + public void configureRuntimeNatives(Configuration configuration, DependencyFactory dependencyFactory, String minecraftVersion) { + Lwjgl2Natives.configureRuntime(configuration, dependencyFactory, minecraftVersion); + } + + @Override + public void configureNativeLibraries(Configuration nativeLibraries, DependencyFactory dependencyFactory, String minecraftVersion) { + Lwjgl2Natives.configure(nativeLibraries, dependencyFactory, minecraftVersion); + } + } +} diff --git a/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/McpMetadataTransform.java b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/McpMetadataTransform.java new file mode 100644 index 00000000..7322ac24 --- /dev/null +++ b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/McpMetadataTransform.java @@ -0,0 +1,91 @@ +package net.neoforged.moddevgradle.mcpforge.internal; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import javax.inject.Inject; +import org.gradle.api.Action; +import org.gradle.api.artifacts.CacheableRule; +import org.gradle.api.artifacts.ComponentMetadataContext; +import org.gradle.api.artifacts.DirectDependenciesMetadata; +import org.gradle.api.artifacts.repositories.RepositoryResourceAccessor; +import org.gradle.api.attributes.Usage; +import org.gradle.api.attributes.java.TargetJvmVersion; +import org.gradle.api.model.ObjectFactory; + +/** + * Given an implicit Metadata object by Gradle (which results from reading in a pom.xml from Maven for MCP data, + * which is basically empty), we build metadata that is equivalent to NeoForms Gradle module metadata. + *

+ * Example for NeoForm: + * https://maven.neoforged.net/releases/net/neoforged/neoform/1.21-20240613.152323/neoform-1.21-20240613.152323.module + */ +@CacheableRule +public class McpMetadataTransform extends LegacyMetadataTransform { + @Inject + public McpMetadataTransform(ObjectFactory objects, RepositoryResourceAccessor repositoryResourceAccessor) { + super(objects, repositoryResourceAccessor); + } + + @Override + public void execute(ComponentMetadataContext context) { + executeWithConfig(context, createPath(context, "", "zip")); + } + + @Override + protected void adaptWithConfig(ComponentMetadataContext context, JsonObject config) { + var details = context.getDetails(); + var id = details.getId(); + + var zipDataName = id.getName() + "-" + id.getVersion() + ".zip"; + + // Very old versions did not specify this. Default to 8 in those cases. + var javaTarget = config.has("java_target") + ? config.getAsJsonPrimitive("java_target").getAsInt() + : 8; + + // a.k.a. "neoFormData" + // Primarily pulled to use for NFRT manifest + details.addVariant("mcpData", variantMetadata -> { + variantMetadata.withFiles(files -> files.addFile(zipDataName)); + variantMetadata.attributes(attributes -> { + attributes.attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, javaTarget); + }); + // Add tools required by this version of MCP as dependencies of this variant + variantMetadata.withDependencies(dependencies -> { + var functions = config.getAsJsonObject("functions"); + for (var function : functions.entrySet()) { + var toolCoordinate = ((JsonObject) function.getValue()).getAsJsonPrimitive("version").getAsString(); + dependencies.add(toolCoordinate); + } + }); + variantMetadata.withCapabilities(capabilities -> { + capabilities.addCapability("net.neoforged", "neoform", id.getVersion()); + }); + }); + + dependencies(context, "mcpRuntimeElements", javaTarget, Usage.JAVA_RUNTIME, deps -> {}); + + dependencies(context, "mcpApiElements", javaTarget, Usage.JAVA_API, dependencies -> { + var libraries = config.getAsJsonObject("libraries").getAsJsonArray("joined"); + for (JsonElement library : libraries) { + dependencies.add(library.getAsString()); + } + }); + } + + private void dependencies(ComponentMetadataContext context, String name, int javaTarget, String usage, Action deps) { + context.getDetails().addVariant(name, variantMetadata -> { + variantMetadata.attributes(attributes -> { + attributes.attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, javaTarget); + attributes.attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.class, usage)); + }); + variantMetadata.withDependencies(dependencies -> { + deps.execute(dependencies); + dependencies.add("net.neoforged:minecraft-dependencies:" + context.getDetails().getId().getVersion()); + }); + variantMetadata.withCapabilities(capabilities -> { + capabilities.addCapability("net.neoforged", "neoform-dependencies", context.getDetails().getId().getVersion()); + }); + }); + } +} diff --git a/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/MixinCompilerArgs.java b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/MixinCompilerArgs.java new file mode 100644 index 00000000..c2748c46 --- /dev/null +++ b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/MixinCompilerArgs.java @@ -0,0 +1,115 @@ +package net.neoforged.moddevgradle.mcpforge.internal; + +import java.util.ArrayList; +import java.util.List; +import javax.inject.Inject; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.process.CommandLineArgumentProvider; + +abstract class MixinCompilerArgs implements CommandLineArgumentProvider { + @Inject + public MixinCompilerArgs() {} + + @OutputFile + protected abstract RegularFileProperty getOutMappings(); + + @OutputFile + protected abstract RegularFileProperty getRefmap(); + + @InputFile + @PathSensitive(PathSensitivity.NAME_ONLY) + protected abstract RegularFileProperty getInMappings(); + + @InputFiles + @PathSensitive(PathSensitivity.NONE) + protected abstract ConfigurableFileCollection getExtraMappings(); + + @Input @Optional + protected abstract Property getDefaultObfuscationEnv(); + + @Input @Optional + protected abstract Property getQuiet(); + + @Input @Optional + protected abstract Property getShowMessageTypes(); + + @Input @Optional + protected abstract Property getDisableTargetValidator(); + + @Input @Optional + protected abstract Property getDisableTargetExport(); + + @Input @Optional + protected abstract Property getDisableOverwriteChecker(); + + @Input @Optional + protected abstract Property getOverwriteErrorLevel(); + + @Input @Optional + protected abstract MapProperty getTokens(); + + @Input @Optional + protected abstract MapProperty getMessages(); + + @Override + public Iterable asArguments() { + var args = new ArrayList(); + args.add("-AreobfSrgFile=" + getInMappings().get().getAsFile().getAbsolutePath()); + args.add("-AoutRefMapFile=" + getRefmap().get().getAsFile().getAbsolutePath()); + args.add("-AdefaultObfuscationEnv=" + getDefaultObfuscationEnv().getOrElse("searge")); + + addFlag(args, getQuiet(), "-Aquiet=true"); + addFlag(args, getShowMessageTypes(), "-AshowMessageTypes=true"); + addFlag(args, getDisableTargetValidator(), "-AdisableTargetValidator=true"); + addFlag(args, getDisableTargetExport(), "-AdisableTargetExport=true"); + addFlag(args, getDisableOverwriteChecker(), "-AdisableOverwriteChecker=true"); + + if (getOverwriteErrorLevel().isPresent()) { + args.add("-AoverwriteErrorLevel=" + getOverwriteErrorLevel().get()); + } + + if (!getExtraMappings().getFiles().isEmpty()) { + var sb = new StringBuilder(); + for (var file : getExtraMappings().getFiles()) { + if (sb.length() > 0) sb.append(","); + sb.append(file.getAbsolutePath()); + } + args.add("-AreobfTsrgFiles=" + sb); + } + + if (getTokens().isPresent() && !getTokens().get().isEmpty()) { + var sb = new StringBuilder(); + for (var entry : getTokens().get().entrySet()) { + if (sb.length() > 0) sb.append(";"); + sb.append(entry.getKey()).append("=").append(entry.getValue()); + } + args.add("-Atokens=" + sb); + } + + if (getMessages().isPresent()) { + for (var entry : getMessages().get().entrySet()) { + if (entry.getKey().matches("^[A-Z]+[A-Z_]+$") && entry.getValue().matches("^(note|warning|error|disabled)$")) { + args.add("-AMSG_" + entry.getKey() + "=" + entry.getValue()); + } + } + } + + return args; + } + + private static void addFlag(List args, Property flag, String arg) { + if (flag.isPresent() && flag.get()) { + args.add(arg); + } + } +} diff --git a/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/MixinExtension.java b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/MixinExtension.java new file mode 100644 index 00000000..d605b407 --- /dev/null +++ b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/MixinExtension.java @@ -0,0 +1,122 @@ +package net.neoforged.moddevgradle.mcpforge.internal; + +import java.util.Map; +import javax.inject.Inject; +import org.gradle.api.Project; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.RegularFile; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.compile.JavaCompile; +import org.gradle.jvm.tasks.Jar; + +public abstract class MixinExtension { + private final Project project; + private final Provider officialToSrg; + private final ConfigurableFileCollection extraMappingFiles; + + @Inject + public MixinExtension(Project project, + Provider officialToSrg, + ConfigurableFileCollection extraMappingFiles) { + this.project = project; + this.officialToSrg = officialToSrg; + this.extraMappingFiles = extraMappingFiles; + } + + public abstract Property getDefaultObfuscationEnv(); + public abstract Property getQuiet(); + public abstract Property getShowMessageTypes(); + public abstract Property getDisableTargetValidator(); + public abstract Property getDisableTargetExport(); + public abstract Property getDisableOverwriteChecker(); + public abstract Property getOverwriteErrorLevel(); + public abstract ConfigurableFileCollection getExtraMappings(); + public abstract MapProperty getTokens(); + public abstract MapProperty getMessages(); + public abstract ListProperty getConfigs(); + + public abstract MapProperty getDebugProperties(); + public abstract MapProperty getEnvProperties(); + public abstract MapProperty getChecksProperties(); + public abstract Property getHotSwap(); + public abstract Property getDumpTargetOnFailure(); + public abstract Property getIgnoreConstraints(); + public abstract Property getInitialiserInjectionMode(); + + public void config(String name) { getConfigs().add(name); } + public void extraMapping(Object file) { getExtraMappings().from(file); } + public void token(String name) { getTokens().put(name, "true"); } + public void token(String name, String value) { getTokens().put(name, value); } + public void tokens(Map map) { getTokens().putAll(map); } + + public void quiet() { getQuiet().set(true); } + public void showMessageTypes() { getShowMessageTypes().set(true); } + public void disableTargetValidator() { getDisableTargetValidator().set(true); } + public void disableTargetExport() { getDisableTargetExport().set(true); } + public void disableOverwriteChecker() { getDisableOverwriteChecker().set(true); } + public void overwriteErrorLevel(String level) { getOverwriteErrorLevel().set(level); } + public void messages(Map map) { getMessages().putAll(map); } + + public Provider add(String refmap) { + return add(getMainSourceSet(), refmap); + } + + public Provider add(String sourceSetName, String refmap) { + return add(project.getExtensions().getByType(org.gradle.api.plugins.JavaPluginExtension.class) + .getSourceSets().getByName(sourceSetName), refmap); + } + + public Provider add(SourceSet sourceSet, String refmap) { + var mappingFile = project.getLayout().getBuildDirectory().dir("mixin") + .map(d -> d.file(refmap + ".mappings.tsrg")); + var refMapFile = project.getLayout().getBuildDirectory().dir("mixin") + .map(d -> d.file(refmap)); + + project.getTasks().named(sourceSet.getCompileJavaTaskName(), JavaCompile.class).configure(compile -> { + var compilerArgs = project.getObjects().newInstance(MixinCompilerArgs.class); + compilerArgs.getRefmap().set(refMapFile); + compilerArgs.getOutMappings().set(mappingFile); + compilerArgs.getInMappings().set(officialToSrg); + compilerArgs.getExtraMappings().from(getExtraMappings()); + compilerArgs.getDefaultObfuscationEnv().set(getDefaultObfuscationEnv()); + compilerArgs.getQuiet().set(getQuiet()); + compilerArgs.getShowMessageTypes().set(getShowMessageTypes()); + compilerArgs.getDisableTargetValidator().set(getDisableTargetValidator()); + compilerArgs.getDisableTargetExport().set(getDisableTargetExport()); + compilerArgs.getDisableOverwriteChecker().set(getDisableOverwriteChecker()); + compilerArgs.getOverwriteErrorLevel().set(getOverwriteErrorLevel()); + compilerArgs.getTokens().set(getTokens()); + compilerArgs.getMessages().set(getMessages()); + compile.getOptions().getCompilerArgumentProviders().add(compilerArgs); + }); + + extraMappingFiles.from(mappingFile); + + project.getTasks().withType(Jar.class) + .matching(jar -> jar.getName().equals(sourceSet.getJarTaskName())) + .configureEach(jar -> jar.from(refMapFile)); + + autoAddAnnotationProcessorDeps(sourceSet); + + return refMapFile; + } + + private SourceSet getMainSourceSet() { + return project.getExtensions().getByType(org.gradle.api.plugins.JavaPluginExtension.class) + .getSourceSets().getByName(SourceSet.MAIN_SOURCE_SET_NAME); + } + + private void autoAddAnnotationProcessorDeps(SourceSet sourceSet) { + var apConfig = project.getConfigurations().named(sourceSet.getAnnotationProcessorConfigurationName()); + var depFactory = project.getDependencyFactory(); + apConfig.configure(c -> { + c.getDependencies().add(depFactory.create("org.ow2.asm:asm-debug-all:5.2")); + c.getDependencies().add(depFactory.create("com.google.guava:guava:32.1.2-jre")); + c.getDependencies().add(depFactory.create("com.google.code.gson:gson:2.8.9")); + }); + } +} diff --git a/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/RemappingTransform.java b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/RemappingTransform.java new file mode 100644 index 00000000..1724024a --- /dev/null +++ b/src/mcpforge/java/net/neoforged/moddevgradle/mcpforge/internal/RemappingTransform.java @@ -0,0 +1,81 @@ +package net.neoforged.moddevgradle.mcpforge.internal; + +import java.io.IOException; +import javax.inject.Inject; + +import net.neoforged.moddevgradle.internal.utils.FileUtils; +import net.neoforged.moddevgradle.legacyforge.tasks.RemapOperation; +import org.gradle.api.artifacts.transform.InputArtifact; +import org.gradle.api.artifacts.transform.InputArtifactDependencies; +import org.gradle.api.artifacts.transform.TransformAction; +import org.gradle.api.artifacts.transform.TransformOutputs; +import org.gradle.api.artifacts.transform.TransformParameters; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.FileCollection; +import org.gradle.api.file.FileSystemLocation; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.CompileClasspath; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Nested; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.process.ExecOperations; + +public abstract class RemappingTransform implements TransformAction { + + @InputArtifact + @PathSensitive(PathSensitivity.NONE) + public abstract Provider getInputArtifact(); + + @CompileClasspath + @InputArtifactDependencies + public abstract FileCollection getDependencies(); + + @Inject + protected abstract ExecOperations getExecOperations(); + + @Inject + public RemappingTransform() {} + + @Override + public void transform(TransformOutputs outputs) { + var inputFile = getInputArtifact().get().getAsFile(); + // The file may not yet exist if IntelliJ requests it during indexing. + if (!inputFile.exists()) return; + + var mappedFile = outputs.file(inputFile.getName()); + try { + getParameters().getRemapOperation() + .execute( + getExecOperations(), + inputFile, + mappedFile, + getDependencies().plus(getParameters().getMinecraftDependencies())); + // Strip bundled org.spongepowered.asm.* from non-provider jars. A bundler like VoxelMap ships a FULL old + // Mixin (incl. tweaker, so content detection can't tell it from a real provider); only the user-declared + // provider (mixinProvider DSL) is exempt. Without stripping, the old @Inject (no `order` member) shadows + // MixinBooter 10.7's on the compile classpath. + if (mappedFile.isFile() && FileUtils.containsSpongePowered(mappedFile) + && !getParameters().getMixinProviders().getFiles().contains(inputFile)) { + FileUtils.stripSpongePowered(mappedFile); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public interface Parameters extends TransformParameters { + @Nested + RemapOperation getRemapOperation(); + + @InputFiles + @PathSensitive(PathSensitivity.NONE) + ConfigurableFileCollection getMinecraftDependencies(); + + /** Jars that ARE the declared Mixin provider (via the {@code mixinProvider} DSL) — exempt from sponge-strip. */ + @InputFiles + @PathSensitive(PathSensitivity.NONE) + ConfigurableFileCollection getMixinProviders(); + } +} + diff --git a/src/test/java/net/neoforged/moddevgradle/internal/CreateLaunchScriptTaskTest.java b/src/test/java/net/neoforged/moddevgradle/internal/CreateLaunchScriptTaskTest.java new file mode 100644 index 00000000..d1e58d5e --- /dev/null +++ b/src/test/java/net/neoforged/moddevgradle/internal/CreateLaunchScriptTaskTest.java @@ -0,0 +1,66 @@ +package net.neoforged.moddevgradle.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.lang.reflect.Method; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class CreateLaunchScriptTaskTest { + @TempDir + Path tempDir; + + @Test + void writesWindowsEnvironmentVariablesWithCmdSafeSetSyntax() throws Exception { + Method method = CreateLaunchScriptTask.class.getDeclaredMethod("writeWindowsEnvironmentVariable", Map.Entry.class); + method.setAccessible(true); + + var escaped = (String) method.invoke(null, Map.entry("MCP_MAPPINGS", "C:\\Users\\A B\\mappings & data.zip")); + + assertThat(escaped) + .isEqualTo("set \"MCP_MAPPINGS=C:\\Users\\A B\\mappings ^& data.zip\""); + } + + @Test + void expandsJvmArgFilesForJava8CompatibleStandaloneLaunchScripts() throws Exception { + var classpathArgs = tempDir.resolve("classpath.txt"); + var vmArgs = tempDir.resolve("vmargs.txt"); + var programArgs = tempDir.resolve("programargs.txt"); + Files.writeString(classpathArgs, "-classpath\n\"/tmp/libs/a.jar:/tmp/libs/b jar.jar\"\n"); + Files.writeString(vmArgs, "-XstartOnFirstThread\n-Dexample=value\n"); + Files.writeString(programArgs, "# Main Class\nnet.minecraftforge.legacydev.MainClient\n"); + + var method = CreateLaunchScriptTask.class.getDeclaredMethod( + "createJavaCommand", + String.class, + java.io.File.class, + java.io.File.class, + String.class, + java.io.File.class); + method.setAccessible(true); + + @SuppressWarnings("unchecked") + var command = (java.util.List) method.invoke( + null, + "/usr/bin/java", + classpathArgs.toFile(), + vmArgs.toFile(), + "-Dfml.modFolders=mymod%%classes", + programArgs.toFile()); + + assertThat(command) + .containsExactly( + "/usr/bin/java", + "-classpath", + "/tmp/libs/a.jar:/tmp/libs/b jar.jar", + "-XstartOnFirstThread", + "-Dexample=value", + "-Dfml.modFolders=mymod%%classes", + RunUtils.DEV_LAUNCH_MAIN_CLASS, + "net.minecraftforge.legacydev.MainClient") + .noneMatch(argument -> argument.startsWith("@")); + } +} diff --git a/src/test/java/net/neoforged/moddevgradle/internal/ExtractNativesTest.java b/src/test/java/net/neoforged/moddevgradle/internal/ExtractNativesTest.java new file mode 100644 index 00000000..fec9a268 --- /dev/null +++ b/src/test/java/net/neoforged/moddevgradle/internal/ExtractNativesTest.java @@ -0,0 +1,92 @@ +package net.neoforged.moddevgradle.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import org.gradle.testfixtures.ProjectBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ExtractNativesTest { + @TempDir + Path tempDir; + + @Test + void extractsNativeJarsWithDuplicateMetadataEntries() throws IOException { + var firstJar = createJar("first.jar", Map.of( + "META-INF/MANIFEST.MF", "Manifest-Version: 1.0\n", + "linux/libfirst.so", "first")); + var secondJar = createJar("second.jar", Map.of( + "META-INF/MANIFEST.MF", "Manifest-Version: 1.0\n", + "linux/libsecond.so", "second")); + + var project = ProjectBuilder.builder().build(); + var task = project.getTasks().register("extractNatives", ExtractNatives.class).get(); + task.getEnabledForRun().set(true); + task.getNativeLibraries().from(firstJar, secondJar); + task.getOutputDirectory().set(tempDir.resolve("natives").toFile()); + + task.extract(); + + assertThat(tempDir.resolve("natives/linux/libfirst.so")).hasContent("first"); + assertThat(tempDir.resolve("natives/linux/libsecond.so")).hasContent("second"); + } + + @Test + void renamesLwjgl2Arm64MacosNativeToLegacyJniLibName() throws IOException { + var lwjgl2Arm64Natives = createJar("lwjgl-platform-2.9.4-nightly-20150209-mmachina.2-natives-osx.jar", Map.of( + "liblwjgl.dylib", "arm64 lwjgl", + "openal.dylib", "arm64 openal")); + + var project = ProjectBuilder.builder().build(); + var task = project.getTasks().register("extractNatives", ExtractNatives.class).get(); + task.getEnabledForRun().set(true); + task.getNativeLibraries().from(lwjgl2Arm64Natives); + task.getOutputDirectory().set(tempDir.resolve("natives").toFile()); + + task.extract(); + + assertThat(tempDir.resolve("natives/liblwjgl.jnilib")).hasContent("arm64 lwjgl"); + assertThat(tempDir.resolve("natives/liblwjgl.dylib")).doesNotExist(); + assertThat(tempDir.resolve("natives/openal.dylib")).hasContent("arm64 openal"); + } + + @Test + void letsLwjgl2Arm64MacosNativeOverrideLegacyOsxNative() throws IOException { + var legacyOsxNatives = createJar("lwjgl-platform-2.9.1-natives-osx.jar", Map.of( + "liblwjgl.jnilib", "x64 lwjgl", + "openal.dylib", "x64 openal")); + var lwjgl2Arm64Natives = createJar("lwjgl-platform-2.9.4-nightly-20150209-mmachina.2-natives-osx.jar", Map.of( + "liblwjgl.dylib", "arm64 lwjgl", + "openal.dylib", "arm64 openal")); + + var project = ProjectBuilder.builder().build(); + var task = project.getTasks().register("extractNatives", ExtractNatives.class).get(); + task.getEnabledForRun().set(true); + task.getNativeLibraries().from(legacyOsxNatives, lwjgl2Arm64Natives); + task.getOutputDirectory().set(tempDir.resolve("natives").toFile()); + + task.extract(); + + assertThat(tempDir.resolve("natives/liblwjgl.jnilib")).hasContent("arm64 lwjgl"); + assertThat(tempDir.resolve("natives/openal.dylib")).hasContent("arm64 openal"); + } + + private Path createJar(String fileName, Map entries) throws IOException { + var jar = tempDir.resolve(fileName); + try (var output = new JarOutputStream(Files.newOutputStream(jar))) { + for (var entry : entries.entrySet()) { + output.putNextEntry(new JarEntry(entry.getKey())); + output.write(entry.getValue().getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + } + } + return jar; + } +} diff --git a/src/test/java/net/neoforged/moddevgradle/internal/PrepareRunTest.java b/src/test/java/net/neoforged/moddevgradle/internal/PrepareRunTest.java new file mode 100644 index 00000000..cd45c3b4 --- /dev/null +++ b/src/test/java/net/neoforged/moddevgradle/internal/PrepareRunTest.java @@ -0,0 +1,206 @@ +package net.neoforged.moddevgradle.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Properties; +import net.neoforged.moddevgradle.internal.utils.VersionCapabilitiesInternal; +import org.gradle.testfixtures.ProjectBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.event.Level; + +class PrepareRunTest { + @TempDir + Path tempDir; + + @Test + void writesInterpolatedUserdevEnvironment() throws Exception { + var project = ProjectBuilder.builder().withProjectDir(tempDir.toFile()).build(); + Files.createDirectories(tempDir.resolve("build")); + var configJson = tempDir.resolve("config.json"); + Files.writeString(configJson, """ + { + "runs": { + "client": { + "main": "net.minecraftforge.legacydev.MainClient", + "args": [], + "jvmArgs": [], + "props": {}, + "env": { + "MCP_TO_SRG": "{mcp_to_srg}", + "mainClass": "net.minecraft.launchwrapper.Launch", + "MCP_MAPPINGS": "{mcp_mappings}", + "assetIndex": "{asset_index}", + "assetDirectory": "{assets_root}", + "nativesDirectory": "{natives}", + "MC_VERSION": "${MC_VERSION}" + } + } + } + } + """, StandardCharsets.UTF_8); + + var assets = tempDir.resolve("assets"); + var assetProperties = tempDir.resolve("assets.properties"); + Files.writeString(assetProperties, """ + asset_index=1.12 + assets_root=%s + """.formatted(assets), StandardCharsets.ISO_8859_1); + + var task = project.getTasks().register("prepareClientRun", PrepareRun.class).get(); + task.getGameDirectory().set(project.getLayout().getProjectDirectory().dir("run")); + task.getVmArgsFile().set(project.getLayout().getBuildDirectory().file("runVmArgs.txt")); + task.getProgramArgsFile().set(project.getLayout().getBuildDirectory().file("runProgramArgs.txt")); + task.getEnvironmentFile().set(project.getLayout().getBuildDirectory().file("runEnvironment.properties")); + task.getAssetProperties().set(assetProperties.toFile()); + task.getRunTypeTemplatesSource().from(configJson.toFile()); + task.getRunType().set("client"); + task.getSystemProperties().set(Map.of()); + task.getJvmArguments().set(java.util.List.of()); + task.getProgramArguments().set(java.util.List.of()); + task.getUserEnvironment().set(Map.of("MC_VERSION", "override")); + task.getRunTemplateReplacements().set(Map.of( + "mcp_to_srg", tempDir.resolve("named-to-intermediary.srg").toString(), + "mcp_mappings", tempDir.resolve("mcp-csv.zip").toString())); + task.getGameLogLevel().set(Level.INFO); + task.getVersionCapabilities().set(VersionCapabilitiesInternal.ofMinecraftVersion("1.12.2")); + + task.prepareRun(); + + assertThat(RunUtils.loadEnvironmentFile(task.getEnvironmentFile().get().getAsFile())) + .containsEntry("MCP_TO_SRG", tempDir.resolve("named-to-intermediary.srg").toString()) + .containsEntry("MCP_MAPPINGS", tempDir.resolve("mcp-csv.zip").toString()) + .containsEntry("assetIndex", "1.12") + .containsEntry("assetDirectory", assets.toString()) + .containsEntry("nativesDirectory", task.getGameDirectory().get().dir("natives").getAsFile().getAbsolutePath()) + .containsEntry("mainClass", "net.minecraft.launchwrapper.Launch") + .containsEntry("MC_VERSION", "override"); + } + + @Test + void treatsMissingLegacyRunTemplateCollectionsAsEmpty() throws Exception { + var project = ProjectBuilder.builder().withProjectDir(tempDir.toFile()).build(); + Files.createDirectories(tempDir.resolve("build")); + var configJson = tempDir.resolve("config.json"); + Files.writeString(configJson, """ + { + "runs": { + "client": { + "main": "net.minecraftforge.legacydev.MainClient", + "env": { + "assetIndex": "{asset_index}", + "MC_VERSION": "${MC_VERSION}" + } + } + } + } + """, StandardCharsets.UTF_8); + + var assetProperties = tempDir.resolve("assets.properties"); + Files.writeString(assetProperties, """ + asset_index=1.12 + assets_root=%s + """.formatted(tempDir.resolve("assets")), StandardCharsets.ISO_8859_1); + + var task = project.getTasks().register("prepareClientRun", PrepareRun.class).get(); + task.getGameDirectory().set(project.getLayout().getProjectDirectory().dir("run")); + task.getVmArgsFile().set(project.getLayout().getBuildDirectory().file("runVmArgs.txt")); + task.getProgramArgsFile().set(project.getLayout().getBuildDirectory().file("runProgramArgs.txt")); + task.getEnvironmentFile().set(project.getLayout().getBuildDirectory().file("runEnvironment.properties")); + task.getAssetProperties().set(assetProperties.toFile()); + task.getRunTypeTemplatesSource().from(configJson.toFile()); + task.getRunType().set("client"); + task.getSystemProperties().set(Map.of()); + task.getJvmArguments().set(java.util.List.of()); + task.getProgramArguments().set(java.util.List.of()); + task.getUserEnvironment().set(Map.of()); + task.getRunTemplateReplacements().set(Map.of()); + task.getGameLogLevel().set(Level.INFO); + task.getVersionCapabilities().set(VersionCapabilitiesInternal.ofMinecraftVersion("1.12.2")); + + task.prepareRun(); + + assertThat(Files.readAllLines(task.getVmArgsFile().get().getAsFile().toPath())) + .doesNotContainNull(); + assertThat(Files.readString(task.getProgramArgsFile().get().getAsFile().toPath())) + .contains("net.minecraftforge.legacydev.MainClient"); + assertThat(RunUtils.loadEnvironmentFile(task.getEnvironmentFile().get().getAsFile())) + .containsEntry("assetIndex", "1.12") + .containsEntry("MC_VERSION", "1.12.2"); + } + + @Test + void disablesLegacyForgeSplashOnMacOsForForge1122ClientRuns() throws Exception { + var previousOsName = System.getProperty("os.name"); + System.setProperty("os.name", "Mac OS X"); + try { + var project = ProjectBuilder.builder().withProjectDir(tempDir.toFile()).build(); + Files.createDirectories(tempDir.resolve("build")); + var configJson = tempDir.resolve("config.json"); + Files.writeString(configJson, """ + { + "runs": { + "client": { + "main": "net.minecraftforge.legacydev.MainClient", + "env": { + "assetIndex": "{asset_index}", + "assetDirectory": "{assets_root}" + } + } + } + } + """, StandardCharsets.UTF_8); + + var assetProperties = tempDir.resolve("assets.properties"); + Files.writeString(assetProperties, """ + asset_index=1.12 + assets_root=%s + """.formatted(tempDir.resolve("assets")), StandardCharsets.ISO_8859_1); + + var task = project.getTasks().register("prepareClientRun", PrepareRun.class).get(); + task.getGameDirectory().set(project.getLayout().getProjectDirectory().dir("run")); + task.getVmArgsFile().set(project.getLayout().getBuildDirectory().file("runVmArgs.txt")); + task.getProgramArgsFile().set(project.getLayout().getBuildDirectory().file("runProgramArgs.txt")); + task.getEnvironmentFile().set(project.getLayout().getBuildDirectory().file("runEnvironment.properties")); + task.getAssetProperties().set(assetProperties.toFile()); + task.getRunTypeTemplatesSource().from(configJson.toFile()); + task.getRunType().set("client"); + task.getSystemProperties().set(Map.of()); + task.getJvmArguments().set(java.util.List.of()); + task.getProgramArguments().set(java.util.List.of()); + task.getUserEnvironment().set(Map.of()); + task.getRunTemplateReplacements().set(Map.of()); + task.getGameLogLevel().set(Level.INFO); + task.getVersionCapabilities().set(VersionCapabilitiesInternal.ofMinecraftVersion("1.12.2")); + + task.prepareRun(); + + var splashProperties = new Properties(); + try (var reader = Files.newBufferedReader(tempDir.resolve("run/config/splash.properties"), StandardCharsets.UTF_8)) { + splashProperties.load(reader); + } + assertThat(splashProperties) + .containsEntry("enabled", "false"); + } finally { + System.setProperty("os.name", previousOsName); + } + } + + @Test + void treatsGameDirectoryAsAnInputBecauseItIsWrittenToEnvironmentFile() throws Exception { + var project = ProjectBuilder.builder().withProjectDir(tempDir.toFile()).build(); + var task = project.getTasks().register("prepareClientRun", PrepareRun.class).get(); + task.getGameDirectory().set(project.getLayout().getProjectDirectory().dir("run")); + + var inputProperties = task.getInputs().getProperties(); + + assertThat(inputProperties) + .containsKey("gameDirectoryPath"); + assertThat(inputProperties.get("gameDirectoryPath")) + .isNotNull(); + } +} diff --git a/src/test/java/net/neoforged/moddevgradle/internal/RunUtilsTest.java b/src/test/java/net/neoforged/moddevgradle/internal/RunUtilsTest.java index f71a5608..38a27836 100644 --- a/src/test/java/net/neoforged/moddevgradle/internal/RunUtilsTest.java +++ b/src/test/java/net/neoforged/moddevgradle/internal/RunUtilsTest.java @@ -2,8 +2,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import java.nio.file.Path; +import java.util.List; class RunUtilsTest { @ParameterizedTest @@ -20,4 +26,18 @@ public void testEscape(String unescaped, String escaped) { assertEquals(escaped, RunUtils.escapeJvmArg(unescaped)); } + + @Test + void readsPreparedArgumentFile(@TempDir Path tempDir) throws Exception { + var argFile = tempDir.resolve("args.txt"); + Files.writeString(argFile, """ + # comment + -Done=1 + "two words" + escaped\\\\path + + """, StandardCharsets.UTF_8); + + assertEquals(List.of("-Done=1", "two words", "escaped\\path"), RunUtils.readArgFile(argFile.toFile())); + } } diff --git a/src/test/java/net/neoforged/moddevgradle/internal/utils/FileUtilsTest.java b/src/test/java/net/neoforged/moddevgradle/internal/utils/FileUtilsTest.java new file mode 100644 index 00000000..2378fc91 --- /dev/null +++ b/src/test/java/net/neoforged/moddevgradle/internal/utils/FileUtilsTest.java @@ -0,0 +1,74 @@ +package net.neoforged.moddevgradle.internal.utils; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class FileUtilsTest { + @TempDir + Path tempDir; + + @Test + void stripsJarSignatureMetadataAndKeepsManifestMainAttributes() throws Exception { + var jar = tempDir.resolve("signed.jar"); + var manifest = new Manifest(); + manifest.getMainAttributes().putValue("Manifest-Version", "1.0"); + manifest.getMainAttributes().putValue("Tweak-Class", "net.minecraftforge.fml.common.launcher.FMLTweaker"); + manifest.getEntries().computeIfAbsent("net/minecraftforge/fml/relauncher/libraries/LibraryManager.class", ignored -> new Attributes()) + .putValue("SHA-256-Digest", "invalid"); + + try (var output = new JarOutputStream(Files.newOutputStream(jar), manifest)) { + writeEntry(output, "META-INF/FORGE.SF", "signature file"); + writeEntry(output, "META-INF/FORGE.DSA", "signature block"); + writeEntry(output, "net/minecraftforge/fml/relauncher/libraries/LibraryManager.class", "patched class"); + } + + FileUtils.stripJarSignatures(jar); + + try (var result = new JarFile(jar.toFile())) { + assertThat(result.getEntry("META-INF/FORGE.SF")).isNull(); + assertThat(result.getEntry("META-INF/FORGE.DSA")).isNull(); + assertThat(result.getEntry("net/minecraftforge/fml/relauncher/libraries/LibraryManager.class")).isNotNull(); + assertThat(result.getManifest().getMainAttributes().getValue("Tweak-Class")) + .isEqualTo("net.minecraftforge.fml.common.launcher.FMLTweaker"); + assertThat(result.getManifest().getEntries()).isEmpty(); + } + } + + @Test + void removesSelectedJarEntriesAndKeepsManifest() throws Exception { + var jar = tempDir.resolve("patched.jar"); + var manifest = new Manifest(); + manifest.getMainAttributes().putValue("Manifest-Version", "1.0"); + manifest.getMainAttributes().putValue("Tweak-Class", "net.minecraftforge.fml.common.launcher.FMLTweaker"); + + try (var output = new JarOutputStream(Files.newOutputStream(jar), manifest)) { + writeEntry(output, "binpatches.pack.lzma", "runtime patches"); + writeEntry(output, "net/minecraft/client/Minecraft.class", "patched class"); + } + + FileUtils.removeJarEntries(jar, java.util.Set.of("binpatches.pack.lzma")); + + try (var result = new JarFile(jar.toFile())) { + assertThat(result.getEntry("binpatches.pack.lzma")).isNull(); + assertThat(result.getEntry("net/minecraft/client/Minecraft.class")).isNotNull(); + assertThat(result.getManifest().getMainAttributes().getValue("Tweak-Class")) + .isEqualTo("net.minecraftforge.fml.common.launcher.FMLTweaker"); + } + } + + private static void writeEntry(JarOutputStream output, String name, String content) throws Exception { + output.putNextEntry(new JarEntry(name)); + output.write(content.getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + } +} diff --git a/src/test/java/net/neoforged/moddevgradle/legacyforge/LegacyModDevPluginTest.java b/src/test/java/net/neoforged/moddevgradle/legacyforge/LegacyModDevPluginTest.java index d1e0ee6f..92c67ee9 100644 --- a/src/test/java/net/neoforged/moddevgradle/legacyforge/LegacyModDevPluginTest.java +++ b/src/test/java/net/neoforged/moddevgradle/legacyforge/LegacyModDevPluginTest.java @@ -5,13 +5,21 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import java.lang.reflect.InvocationTargetException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Map; import java.util.Set; import net.neoforged.moddevgradle.AbstractProjectBuilderTest; +import net.neoforged.moddevgradle.internal.ExtractNatives; +import net.neoforged.moddevgradle.internal.ModDevRunWorkflow; import net.neoforged.moddevgradle.internal.utils.ExtensionUtils; import net.neoforged.moddevgradle.legacyforge.dsl.LegacyForgeExtension; import net.neoforged.moddevgradle.legacyforge.internal.LegacyForgeModDevPlugin; +import net.neoforged.nfrtgradle.CreateMinecraftArtifacts; import org.gradle.api.InvalidUserCodeException; import org.gradle.api.Task; +import org.gradle.api.artifacts.repositories.MavenArtifactRepository; import org.gradle.api.plugins.JavaPluginExtension; import org.gradle.api.tasks.SourceSet; import org.gradle.jvm.toolchain.JavaLanguageVersion; @@ -81,6 +89,139 @@ void testGetMcpVersionThrowsBeforeEnabling() { assertThrows(InvalidUserCodeException.class, extension::getMcpVersion); } + @Test + void testForge1122UsesUserdev3ForNeoFormRuntime() { + project.getExtensions().getByName("neoFormRuntime"); + project.getExtensions().configure(net.neoforged.nfrtgradle.NeoFormRuntimeExtension.class, extension -> extension.getVersion().set("2.0.19-legacy")); + + extension.setVersion("1.12.2-14.23.5.2860"); + + var createArtifacts = project.getTasks().named("createMinecraftArtifacts", CreateMinecraftArtifacts.class).get(); + assertEquals("net.minecraftforge:forge:1.12.2-14.23.5.2860:userdev3", createArtifacts.getNeoForgeArtifact().get()); + assertThat(createArtifacts.getLegacyMcpMappings().isPresent()).isFalse(); + assertEquals("1.12.2", extension.getMinecraftVersion()); + } + + @Test + void testLegacyRunExtractsNatives() { + extension.getRuns().create("client").client(); + project.getExtensions().configure(net.neoforged.nfrtgradle.NeoFormRuntimeExtension.class, nfrt -> nfrt.getVersion().set("2.0.19-legacy")); + + extension.setVersion("1.12.2-14.23.5.2860"); + + var extractNatives = project.getTasks().named("extractClientNatives", ExtractNatives.class); + assertThat(project.getTasks().getNames()).contains("extractClientNatives"); + assertThat(project.getTasks().named("prepareClientRun").get().getDependsOn()) + .contains(extractNatives); + + var task = extractNatives.get(); + assertThat(task.getEnabledForRun().get()).isTrue(); + assertThat(task.getNativeLibraries().getFrom()).contains(project.getConfigurations().getByName("clientNativeLibraries")); + } + + @Test + void testForge1122DoesNotAddBootstrapLauncherExportsToJava8Run() { + var run = extension.getRuns().create("client"); + run.client(); + project.getExtensions().configure(net.neoforged.nfrtgradle.NeoFormRuntimeExtension.class, nfrt -> nfrt.getVersion().set("2.0.19-legacy")); + + extension.setVersion("1.12.2-14.23.5.2860"); + + assertThat(run.getJvmArguments().get()) + .doesNotContain("--add-exports", "cpw.mods.bootstraplauncher/cpw.mods.bootstraplauncher=ALL-UNNAMED"); + } + + @Test + void testForge1122IgnoresRuntimePatchDiscrepanciesForPreparedDevJar() { + var run = extension.getRuns().create("client"); + run.client(); + project.getExtensions().configure(net.neoforged.nfrtgradle.NeoFormRuntimeExtension.class, nfrt -> nfrt.getVersion().set("2.0.19-legacy")); + + extension.setVersion("1.12.2-14.23.5.2860"); + + assertThat(run.getSystemProperties().get()) + .containsEntry("fml.ignorePatchDiscrepancies", "true"); + } + + @Test + void testForge1122PassesSrgToMcpMappingsToLegacyDevLauncher() throws Exception { + extension.getRuns().create("client").client(); + project.getExtensions().configure(net.neoforged.nfrtgradle.NeoFormRuntimeExtension.class, nfrt -> nfrt.getVersion().set("2.0.19-legacy")); + + extension.setVersion("1.12.2-14.23.5.2860"); + + var prepareRun = project.getTasks().named("prepareClientRun").get(); + assertThat(getRunTemplateReplacements(prepareRun)) + .containsEntry("mcp_to_srg", project.file("build/moddev/artifacts/intermediateToNamed.srg").getAbsolutePath()); + } + + @Test + void testForgeRepositorySupportsJarOnlyLegacyArtifacts() { + var forgeRepository = (MavenArtifactRepository) project.getRepositories().getByName("MinecraftForge"); + + assertThat(forgeRepository.getMetadataSources().isMavenPomEnabled()).isTrue(); + assertThat(forgeRepository.getMetadataSources().isArtifactEnabled()).isTrue(); + } + + @Test + void testForge1171UsesUserdevForNeoFormRuntime() { + extension.setVersion("1.17.1-37.1.1"); + + var createArtifacts = project.getTasks().named("createMinecraftArtifacts", CreateMinecraftArtifacts.class).get(); + assertEquals("net.minecraftforge:forge:1.17.1-37.1.1:userdev", createArtifacts.getNeoForgeArtifact().get()); + assertThat(createArtifacts.getLegacyMcpMappings().isPresent()).isFalse(); + assertEquals("1.17.1", extension.getMinecraftVersion()); + } + + @Test + void testLegacyMcpMappingsCanBeConfiguredForNeoFormRuntime() { + project.getExtensions().configure(net.neoforged.nfrtgradle.NeoFormRuntimeExtension.class, nfrt -> nfrt.getVersion().set("2.0.19-legacy")); + + extension.enable(settings -> { + settings.setForgeVersion("1.12.2-14.23.5.2860"); + settings.setMcpMappings("de.oceanlabs.mcp:mcp_stable:39-1.12@zip"); + }); + + var createArtifacts = project.getTasks().named("createMinecraftArtifacts", CreateMinecraftArtifacts.class).get(); + assertEquals("de.oceanlabs.mcp:mcp_stable:39-1.12@zip", createArtifacts.getLegacyMcpMappings().get()); + } + + @Test + void testGradleTestTaskLoadsPreparedEnvironmentFile() throws Exception { + project.getExtensions().configure(net.neoforged.nfrtgradle.NeoFormRuntimeExtension.class, nfrt -> nfrt.getVersion().set("2.0.19-legacy")); + extension.setVersion("1.12.2-14.23.5.2860"); + var testTask = project.getTasks().named("test", org.gradle.api.tasks.testing.Test.class).get(); + var initialActionCount = testTask.getActions().size(); + + ModDevRunWorkflow.get(project).configureTesting(project.provider(() -> null), project.provider(() -> Set.of())); + + assertThat(testTask.getActions()).hasSizeGreaterThan(initialActionCount); + + var environmentFile = project.getLayout().getBuildDirectory() + .file("moddev/junit/environment.properties") + .get() + .getAsFile() + .toPath(); + Files.createDirectories(environmentFile.getParent()); + Files.writeString(environmentFile, "MCP_TO_SRG=prepared.srg\n", StandardCharsets.ISO_8859_1); + + var loadEnvironment = project.getObjects().newInstance(ModDevRunWorkflow.LoadPreparedTestEnvironment.class); + loadEnvironment.getEnvironmentFile().set(project.getLayout().getBuildDirectory().file("moddev/junit/environment.properties")); + loadEnvironment.execute(testTask); + + assertThat(testTask.getEnvironment()) + .containsEntry("MCP_TO_SRG", "prepared.srg"); + } + + @Test + void testForge1122RequiresLegacyNeoFormRuntimeSupport() { + var e = assertThrows(InvalidUserCodeException.class, () -> extension.setVersion("1.12.2-14.23.5.2860")); + + assertThat(e).hasMessageContaining("Forge 1.12.2 requires NeoFormRuntime with legacy MCP support"); + assertThat(e).hasMessageContaining("neoForge.neoFormRuntime.version"); + assertThat(e).hasMessageContaining("2.0.19-legacy"); + } + @Test void testEnableForTestSourceSetOnly() { extension.enable(settings -> { @@ -145,4 +286,17 @@ private void assertContainsModdingRuntimeDependencies(String configurationName) assertThatDependencies(configurationName).contains(MODDING_COMPILE_DEPENDENCIES); assertThatDependencies(configurationName).contains(MODDING_RUNTIME_ONLY_DEPENDENCIES); } + + @SuppressWarnings("unchecked") + private static Map getRunTemplateReplacements(Task task) throws Exception { + try { + var replacementsProperty = task.getClass().getMethod("getRunTemplateReplacements").invoke(task); + return (Map) replacementsProperty.getClass().getMethod("get").invoke(replacementsProperty); + } catch (InvocationTargetException e) { + if (e.getCause() instanceof Exception exception) { + throw exception; + } + throw e; + } + } }