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('=')); + } +}