From 98b7b6026a6c6738da3b1bdd625811b371d0770b Mon Sep 17 00:00:00 2001 From: mezz Date: Tue, 7 Jul 2026 16:19:37 +0900 Subject: [PATCH] Add launch metadata for runs ## Context JEI needs client tests that can start external dedicated servers. Today, doing that from another project means reading MDG generated classpath, VM args, and program args files directly. That works, but it depends on internal `build/moddev` file names and requires downstream code to know how MDG assembles a launch command. ## Changes This change adds a public `CreateLaunchMetadata` task for each configured run. For a run named `server`, MDG registers: ```groovy createServerLaunchMetadata ``` The task writes: ```text build/moddev/serverLaunchMetadata.properties ``` The properties file contains: - `workingDirectory` - `javaExecutable` - `classpathArgsFile` - `vmArgsFile` - `programArgsFile` - `mainClass` - `modFolders` - `environment.` entries for run environment variables There is also a `createLaunchMetadata` aggregate task. The metadata writer uses the JDK Properties format so consumers can load it with standard properties readers, while MDG keeps the output sorted and comment-free for stable task output. Launchers should treat `workingDirectory` as part of the metadata contract. If they need disposable or test-specific state, they should configure the run `gameDirectory` instead of replacing the metadata working directory at launch time. This also adds Gradle daemon JVM criteria for Java 17 so local wrapper invocations use a supported JVM by default. ## Why This gives consumers like JEI a typed Gradle task output to depend on: ```groovy tasks.named("createServerLaunchMetadata", CreateLaunchMetadata).flatMap { it.metadataFile } ``` Downstream builds can use the metadata without reconstructing MDG launch details from internal file naming conventions. ## Validation - `./gradlew test --tests net.neoforged.moddevgradle.tasks.CreateLaunchMetadataTest` - `./gradlew test --tests net.neoforged.moddevgradle.functional.LaunchMetadataFunctionalTest` - `./gradlew javaImmaculateCheck` JEI validated this branch with `--include-build ../ModDevGradle`: - `:NeoForge:runClientRecipeSyncTest` - NeoForge external server with JEI passed - NeoForge external server without JEI passed - vanilla external server without JEI passed JEI configures each external server run with a JEI-owned build directory as `gameDirectory`, consumes `CreateLaunchMetadata`, and launches using the metadata `workingDirectory` unchanged. Coverage includes: - per-run metadata - the `createLaunchMetadata` aggregate task - generated arg-file paths - properties escaping and ordering --- README.md | 24 +++++ gradle/gradle-daemon-jvm.properties | 2 + .../internal/ModDevRunWorkflow.java | 34 +++++- .../moddevgradle/internal/RunUtils.java | 10 +- .../tasks/CreateLaunchMetadata.java | 100 +++++++++++++++++ .../LaunchMetadataFunctionalTest.java | 101 ++++++++++++++++++ .../tasks/CreateLaunchMetadataTest.java | 83 ++++++++++++++ 7 files changed, 350 insertions(+), 4 deletions(-) create mode 100644 gradle/gradle-daemon-jvm.properties create mode 100644 src/main/java/net/neoforged/moddevgradle/tasks/CreateLaunchMetadata.java create mode 100644 src/test/java/net/neoforged/moddevgradle/functional/LaunchMetadataFunctionalTest.java create mode 100644 src/test/java/net/neoforged/moddevgradle/tasks/CreateLaunchMetadataTest.java diff --git a/README.md b/README.md index ae299905..0f4816fb 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,30 @@ neoForge { } ``` +### Launch Metadata + +If you need to launch a configured run from another Gradle task or an external tool, you can use the launch metadata +file generated for each run. This avoids depending on ModDevGradle's internal argument-file names. + +For each run, ModDevGradle registers a `createLaunchMetadata` task of type +`CreateLaunchMetadata`, which exposes the metadata file as an output: + +```groovy +import net.neoforged.moddevgradle.tasks.CreateLaunchMetadata + +def serverLaunchMetadata = tasks.named("createServerLaunchMetadata", CreateLaunchMetadata).flatMap { + it.metadataFile +} +``` + +The file is written to `build/moddev/LaunchMetadata.properties` and contains the Java executable, working +directory, DevLaunch main class, argument-file paths, mod-folder argument, and `environment.` entries for run +environment variables. + +Launchers should use the `workingDirectory` from the metadata. If a launcher needs disposable or test-specific state, +configure the run's `gameDirectory` to point at that directory instead of replacing the metadata working directory at +launch time. + ### Jar-in-Jar To embed external Jar-files into your mod file, you can use the `jarJar` configuration added by the plugin. diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 00000000..858feb7e --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,2 @@ +#This file is generated by updateDaemonJvm +toolchainVersion=17 diff --git a/src/main/java/net/neoforged/moddevgradle/internal/ModDevRunWorkflow.java b/src/main/java/net/neoforged/moddevgradle/internal/ModDevRunWorkflow.java index d6be0f49..39ece0d5 100644 --- a/src/main/java/net/neoforged/moddevgradle/internal/ModDevRunWorkflow.java +++ b/src/main/java/net/neoforged/moddevgradle/internal/ModDevRunWorkflow.java @@ -13,6 +13,7 @@ import net.neoforged.moddevgradle.dsl.RunModel; import net.neoforged.moddevgradle.internal.utils.ExtensionUtils; import net.neoforged.moddevgradle.internal.utils.VersionCapabilitiesInternal; +import net.neoforged.moddevgradle.tasks.CreateLaunchMetadata; import net.neoforged.nfrtgradle.CreateMinecraftArtifacts; import net.neoforged.nfrtgradle.DownloadAssets; import org.gradle.api.DomainObjectCollection; @@ -266,6 +267,10 @@ public static void setupRuns( task.setGroup(branding.publicTaskGroup()); task.setDescription("Creates batch files/shell scripts to launch the game from outside of Gradle (i.e. Renderdoc, NVidia Nsight, etc.)"); }); + var createLaunchMetadataTask = project.getTasks().register("createLaunchMetadata", Task.class, task -> { + task.setGroup(branding.publicTaskGroup()); + task.setDescription("Creates metadata files to launch the game from outside of Gradle or your IDE."); + }); Map> prepareRunTasks = new IdentityHashMap<>(); runs.all(run -> { @@ -285,7 +290,8 @@ public static void setupRuns( assetPropertiesFile, devLaunchConfig, versionCapabilities, - createLaunchScriptsTask); + createLaunchScriptsTask, + createLaunchMetadataTask); prepareRunTasks.put(run, prepareRunTask); }); ideIntegration.configureRuns(prepareRunTasks, runs); @@ -308,7 +314,8 @@ private static TaskProvider setupRunInGradle( Provider assetPropertiesFile, Configuration devLaunchConfig, VersionCapabilitiesInternal versionCapabilities, - TaskProvider createLaunchScriptsTask) { + TaskProvider createLaunchScriptsTask, + TaskProvider createLaunchMetadataTask) { var ideIntegration = IdeIntegration.of(project, branding); var configurations = project.getConfigurations(); var javaExtension = ExtensionUtils.getExtension(project, "java", JavaPluginExtension.class); @@ -392,6 +399,8 @@ private static TaskProvider setupRunInGradle( }); ideIntegration.runTaskOnProjectSync(prepareRunTask); + var modFoldersProvider = RunUtils.getGradleModFoldersProvider(project, run.getLoadedMods(), null); + var launchScriptTask = tasks.register(InternalModelHelper.nameOfRun(run, "create", "launchScript"), CreateLaunchScriptTask.class, task -> { task.setGroup(branding.internalTaskGroup()); task.setDescription("Creates a bash/shell-script to launch the " + run.getName() + " Minecraft run from outside Gradle or your IDE."); @@ -405,10 +414,29 @@ private static TaskProvider setupRunInGradle( task.getVmArgsFile().set(prepareRunTask.get().getVmArgsFile().map(d -> d.getAsFile().getAbsolutePath())); task.getProgramArgsFile().set(prepareRunTask.get().getProgramArgsFile().map(d -> d.getAsFile().getAbsolutePath())); task.getEnvironment().set(run.getEnvironment()); - task.getModFolders().set(RunUtils.getGradleModFoldersProvider(project, run.getLoadedMods(), null)); + task.getModFolders().set(modFoldersProvider); }); createLaunchScriptsTask.configure(task -> task.dependsOn(launchScriptTask)); + var launchMetadataTask = tasks.register(InternalModelHelper.nameOfRun(run, "create", "launchMetadata"), CreateLaunchMetadata.class, task -> { + task.setGroup(branding.internalTaskGroup()); + task.setDescription("Creates launch metadata for the " + run.getName() + " Minecraft run."); + + task.getWorkingDirectory().set(run.getGameDirectory().map(d -> d.getAsFile().getAbsolutePath())); + task.getJavaExecutable().set(launchScriptTask.flatMap(CreateLaunchScriptTask::getJavaExecutable)); + task.getClasspathArgsFile().set(launchScriptTask.flatMap(CreateLaunchScriptTask::getClasspathArgsFile)); + task.getVmArgsFile().set(prepareRunTask.flatMap(PrepareRun::getVmArgsFile)); + task.getProgramArgsFile().set(prepareRunTask.flatMap(PrepareRun::getProgramArgsFile)); + task.getMainClass().set(RunUtils.DEV_LAUNCH_MAIN_CLASS); + task.getModFoldersArgument().set(modFoldersProvider + .getClassesArgument() + .map(RunUtils::getModFoldersArgument)); + task.getEnvironment().set(run.getEnvironment()); + task.getMetadataFile().set(RunUtils.getLaunchMetadata(argFileDir, run)); + task.dependsOn(launchScriptTask); + }); + createLaunchMetadataTask.configure(task -> task.dependsOn(launchMetadataTask)); + tasks.register(InternalModelHelper.nameOfRun(run, "run", ""), RunGameTask.class, task -> { task.setGroup(branding.publicTaskGroup()); task.setDescription("Runs the " + run.getName() + " Minecraft run configuration."); diff --git a/src/main/java/net/neoforged/moddevgradle/internal/RunUtils.java b/src/main/java/net/neoforged/moddevgradle/internal/RunUtils.java index c913ce82..bfb69543 100644 --- a/src/main/java/net/neoforged/moddevgradle/internal/RunUtils.java +++ b/src/main/java/net/neoforged/moddevgradle/internal/RunUtils.java @@ -154,6 +154,10 @@ public static Provider getLaunchScript(Provider modDevFo return modDevFolder.map(dir -> dir.file(InternalModelHelper.nameOfRun(run, "run", scriptExtension))); } + public static Provider getLaunchMetadata(Provider modDevFolder, RunModel run) { + return modDevFolder.map(dir -> dir.file(InternalModelHelper.nameOfRun(run, "", "launchMetadata.properties"))); + } + public enum RunArgFile { VMARGS("runVmArgs.txt"), PROGRAMARGS("runProgramArgs.txt"), @@ -177,6 +181,10 @@ public static ModFoldersProvider getGradleModFoldersProvider(Project project, Pr return modFoldersProvider; } + static String getModFoldersArgument(String classesArgument) { + return "-Dfml.modFolders=%s".formatted(classesArgument); + } + public static Project findSourceSetProject(Project someProject, SourceSet sourceSet) { for (var s : ExtensionUtils.getSourceSets(someProject)) { if (s == sourceSet) { @@ -289,7 +297,7 @@ public ModFoldersProvider() { @Internal public String getArgument() { - return "-Dfml.modFolders=%s".formatted(getClassesArgument().get()); + return RunUtils.getModFoldersArgument(getClassesArgument().get()); } @Override diff --git a/src/main/java/net/neoforged/moddevgradle/tasks/CreateLaunchMetadata.java b/src/main/java/net/neoforged/moddevgradle/tasks/CreateLaunchMetadata.java new file mode 100644 index 00000000..8f4832df --- /dev/null +++ b/src/main/java/net/neoforged/moddevgradle/tasks/CreateLaunchMetadata.java @@ -0,0 +1,100 @@ +package net.neoforged.moddevgradle.tasks; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Map; +import java.util.Properties; +import java.util.TreeMap; +import net.neoforged.moddevgradle.internal.utils.FileUtils; +import org.gradle.api.DefaultTask; +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.OutputFile; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.api.tasks.TaskAction; + +/** + * Writes stable, machine-readable metadata for launching a configured Minecraft run. + */ +public abstract class CreateLaunchMetadata extends DefaultTask { + @Input + public abstract Property getWorkingDirectory(); + + @Input + public abstract Property getJavaExecutable(); + + @InputFile + @PathSensitive(PathSensitivity.ABSOLUTE) + public abstract RegularFileProperty getClasspathArgsFile(); + + @InputFile + @PathSensitive(PathSensitivity.ABSOLUTE) + public abstract RegularFileProperty getVmArgsFile(); + + @InputFile + @PathSensitive(PathSensitivity.ABSOLUTE) + public abstract RegularFileProperty getProgramArgsFile(); + + @Input + public abstract Property getMainClass(); + + @Input + public abstract Property getModFoldersArgument(); + + @Input + public abstract MapProperty getEnvironment(); + + @OutputFile + public abstract RegularFileProperty getMetadataFile(); + + @TaskAction + public void createMetadata() throws IOException { + var metadata = new TreeMap(); + metadata.put("workingDirectory", getWorkingDirectory().get()); + metadata.put("javaExecutable", getJavaExecutable().get()); + metadata.put("classpathArgsFile", getClasspathArgsFile().get().getAsFile().getAbsolutePath()); + metadata.put("vmArgsFile", getVmArgsFile().get().getAsFile().getAbsolutePath()); + metadata.put("programArgsFile", getProgramArgsFile().get().getAsFile().getAbsolutePath()); + metadata.put("mainClass", getMainClass().get()); + metadata.put("modFolders", getModFoldersArgument().get()); + + for (var entry : getEnvironment().get().entrySet()) { + metadata.put("environment." + entry.getKey(), entry.getValue()); + } + + var lines = new ArrayList(metadata.size()); + for (Map.Entry entry : metadata.entrySet()) { + lines.add(formatProperty(entry)); + } + + var destination = getMetadataFile().get().getAsFile().toPath(); + Files.createDirectories(destination.getParent()); + FileUtils.writeLinesSafe(destination, lines, StandardCharsets.ISO_8859_1); + } + + private static String formatProperty(Map.Entry entry) { + var properties = new Properties(); + properties.setProperty(entry.getKey(), entry.getValue()); + + var output = new ByteArrayOutputStream(); + try { + properties.store(output, null); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + + return output.toString(StandardCharsets.ISO_8859_1) + .lines() + .filter(line -> !line.startsWith("#")) + .findFirst() + .orElseThrow(); + } +} diff --git a/src/test/java/net/neoforged/moddevgradle/functional/LaunchMetadataFunctionalTest.java b/src/test/java/net/neoforged/moddevgradle/functional/LaunchMetadataFunctionalTest.java new file mode 100644 index 00000000..df0b2671 --- /dev/null +++ b/src/test/java/net/neoforged/moddevgradle/functional/LaunchMetadataFunctionalTest.java @@ -0,0 +1,101 @@ +package net.neoforged.moddevgradle.functional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; +import org.gradle.testkit.runner.GradleRunner; +import org.gradle.testkit.runner.TaskOutcome; +import org.junit.jupiter.api.Test; + +public class LaunchMetadataFunctionalTest extends AbstractFunctionalTest { + @Test + public void testCreateServerLaunchMetadata() throws IOException { + writeFile(settingsFile, "rootProject.name = 'launch-metadata'"); + writeGroovyBuildScript(""" + plugins { + id "net.neoforged.moddev" + } + + neoForge { + version = "{DEFAULT_NEOFORGE_VERSION}" + runs { + server { + server() + environment("MDG_TEST_ENV", "present") + } + } + } + """); + + var result = GradleRunner.create() + .withPluginClasspath() + .withProjectDir(testProjectDir) + .withArguments("createServerLaunchMetadata", "--stacktrace") + .build(); + + assertEquals(TaskOutcome.SUCCESS, result.task(":createServerLaunchMetadata").getOutcome()); + assertEquals(TaskOutcome.SUCCESS, result.task(":createServerLaunchScript").getOutcome()); + + var metadataFile = testProjectDir.toPath().resolve("build/moddev/serverLaunchMetadata.properties"); + assertThat(metadataFile).exists(); + + var properties = loadProperties(metadataFile); + + assertThat(properties.getProperty("workingDirectory")).endsWith("run"); + assertThat(properties.getProperty("javaExecutable")).isNotBlank(); + assertThat(properties.getProperty("mainClass")).isEqualTo("net.neoforged.devlaunch.Main"); + assertThat(properties.getProperty("classpathArgsFile")).endsWith("serverRunClasspath.txt"); + assertThat(properties.getProperty("vmArgsFile")).endsWith("serverRunVmArgs.txt"); + assertThat(properties.getProperty("programArgsFile")).endsWith("serverRunProgramArgs.txt"); + assertThat(properties.getProperty("modFolders")).startsWith("-Dfml.modFolders="); + assertThat(properties.getProperty("environment.MDG_TEST_ENV")).isEqualTo("present"); + + assertThat(Path.of(properties.getProperty("classpathArgsFile"))).exists(); + assertThat(Path.of(properties.getProperty("vmArgsFile"))).exists(); + assertThat(Path.of(properties.getProperty("programArgsFile"))).exists(); + } + + @Test + public void testCreateLaunchMetadataAggregate() throws IOException { + writeFile(settingsFile, "rootProject.name = 'launch-metadata-aggregate'"); + writeGroovyBuildScript(""" + plugins { + id "net.neoforged.moddev" + } + + neoForge { + version = "{DEFAULT_NEOFORGE_VERSION}" + runs { + client { + client() + } + server { + server() + } + } + } + """); + + var result = GradleRunner.create() + .withPluginClasspath() + .withProjectDir(testProjectDir) + .withArguments("createLaunchMetadata", "--stacktrace") + .build(); + + assertEquals(TaskOutcome.SUCCESS, result.task(":createLaunchMetadata").getOutcome()); + assertThat(testProjectDir.toPath().resolve("build/moddev/clientLaunchMetadata.properties")).exists(); + assertThat(testProjectDir.toPath().resolve("build/moddev/serverLaunchMetadata.properties")).exists(); + } + + private static Properties loadProperties(Path path) throws IOException { + var properties = new Properties(); + try (var input = Files.newInputStream(path)) { + properties.load(input); + } + return properties; + } +} diff --git a/src/test/java/net/neoforged/moddevgradle/tasks/CreateLaunchMetadataTest.java b/src/test/java/net/neoforged/moddevgradle/tasks/CreateLaunchMetadataTest.java new file mode 100644 index 00000000..50c13a6d --- /dev/null +++ b/src/test/java/net/neoforged/moddevgradle/tasks/CreateLaunchMetadataTest.java @@ -0,0 +1,83 @@ +package net.neoforged.moddevgradle.tasks; + +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.Properties; +import org.gradle.testfixtures.ProjectBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class CreateLaunchMetadataTest { + @TempDir + Path tempDir; + + @Test + void testWritesStableLoadableProperties() throws IOException { + var task = ProjectBuilder.builder() + .withProjectDir(tempDir.toFile()) + .build() + .getTasks() + .create("createLaunchMetadata", CreateLaunchMetadata.class); + + var classpathArgsFile = writeInputFile("classpath args.txt"); + var vmArgsFile = writeInputFile("vm args.txt"); + var programArgsFile = writeInputFile("program args.txt"); + var metadataFile = tempDir.resolve("launch metadata.properties"); + + task.getWorkingDirectory().set(tempDir.resolve("game dir").toString()); + task.getJavaExecutable().set(tempDir.resolve("java/bin/java").toString()); + task.getClasspathArgsFile().fileValue(classpathArgsFile.toFile()); + task.getVmArgsFile().fileValue(vmArgsFile.toFile()); + task.getProgramArgsFile().fileValue(programArgsFile.toFile()); + task.getMainClass().set("com.example.Main"); + task.getModFoldersArgument().set("-Dfml.modFolders=example%%" + tempDir.resolve("mods dir")); + task.getEnvironment().put("ALPHA", "one"); + task.getEnvironment().put("SPECIAL KEY", " leading space = value: unicode \u2603\nnext line"); + task.getMetadataFile().fileValue(metadataFile.toFile()); + + task.createMetadata(); + + var properties = new Properties(); + try (var input = Files.newInputStream(metadataFile)) { + properties.load(input); + } + + assertThat(properties) + .containsEntry("workingDirectory", tempDir.resolve("game dir").toString()) + .containsEntry("javaExecutable", tempDir.resolve("java/bin/java").toString()) + .containsEntry("classpathArgsFile", classpathArgsFile.toAbsolutePath().toString()) + .containsEntry("vmArgsFile", vmArgsFile.toAbsolutePath().toString()) + .containsEntry("programArgsFile", programArgsFile.toAbsolutePath().toString()) + .containsEntry("mainClass", "com.example.Main") + .containsEntry("modFolders", "-Dfml.modFolders=example%%" + tempDir.resolve("mods dir")) + .containsEntry("environment.ALPHA", "one") + .containsEntry("environment.SPECIAL KEY", " leading space = value: unicode \u2603\nnext line"); + + assertThat(Files.readAllLines(metadataFile, StandardCharsets.ISO_8859_1)) + .extracting(CreateLaunchMetadataTest::propertyKey) + .containsExactly( + "classpathArgsFile", + "environment.ALPHA", + "environment.SPECIAL\\ KEY", + "javaExecutable", + "mainClass", + "modFolders", + "programArgsFile", + "vmArgsFile", + "workingDirectory"); + } + + private Path writeInputFile(String name) throws IOException { + var file = tempDir.resolve(name); + Files.writeString(file, name); + return file; + } + + private static String propertyKey(String line) { + return line.substring(0, line.indexOf('=')); + } +}