diff --git a/README.md b/README.md index d90c74cfc..06e23d93c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,28 @@ The mod loader used by [NeoForge](https://github.com/neoforged/NeoForge). +## Testing + +The `tests` subproject provides several tasks to test FML in various usage scenarios without having to include +it in a NeoForge working directory. + +The Gradle property `test_neoforge_version` controls, which NeoForge version is used for these tests. + +### Production Client + +Run the `:tests:runProductionClient` task to start FML in an environment resembling a client launched through the +Vanilla launcher. + +### Production Server + +Run the `:tests:runProductionServer` task to start FML in an environment resembling a server launched through one of +the NeoForge provided startup scripts. + +### Mod-Development + +Run the `:tests:runClient` or `:tests:runServer` tasks to start FML in an environment resembling a mod development +environment. + ## Extension Points ### Mod File Candidate Locators diff --git a/build.gradle b/build.gradle index 6a93e76da..2b43b9339 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,5 @@ plugins { id 'com.github.ben-manes.versions' version '0.39.0' - id 'org.javamodularity.moduleplugin' version '1.8.7' id 'net.neoforged.licenser' version '0.7.2' apply false id 'com.diffplug.spotless' version '6.25.0' apply false id 'net.neoforged.gradleutils' version "${gradleutils_version}" @@ -26,7 +25,6 @@ allprojects { apply plugin: 'signing' apply plugin: 'com.github.ben-manes.versions' - apply plugin: 'org.javamodularity.moduleplugin' apply plugin: 'net.neoforged.licenser' apply plugin: 'com.diffplug.spotless' apply plugin: 'net.neoforged.gradleutils' @@ -41,20 +39,6 @@ allprojects { spotlessUtils.configure(spotless) - repositories { - mavenCentral() - - maven { - name = 'NeoForged' - url = 'https://maven.neoforged.net/releases' - } - - maven { - name = 'Minecraft' - url = 'https://libraries.minecraft.net' - } - } - dependencyUpdates.rejectVersionIf { isNonStable(it.candidate.version) } java { diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle new file mode 100644 index 000000000..8c2f4f5b1 --- /dev/null +++ b/buildSrc/build.gradle @@ -0,0 +1,17 @@ +plugins { + id 'java' +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +dependencies { + implementation "com.google.code.gson:gson:2.10.1" +} + +repositories { + gradlePluginPortal() +} diff --git a/buildSrc/src/main/java/fmlbuild/AttributesPlugin.java b/buildSrc/src/main/java/fmlbuild/AttributesPlugin.java new file mode 100644 index 000000000..9ebd723e7 --- /dev/null +++ b/buildSrc/src/main/java/fmlbuild/AttributesPlugin.java @@ -0,0 +1,45 @@ +package fmlbuild; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.attributes.Attribute; +import org.gradle.api.attributes.AttributeDisambiguationRule; +import org.gradle.api.attributes.MultipleCandidatesDetails; + +/** + * Adds the attributes used by NeoForge, NeoForm and Minecraft Gradle module metadata. + */ +public class AttributesPlugin implements Plugin { + private static final Attribute ATTRIBUTE_DISTRIBUTION = Attribute.of("net.neoforged.distribution", String.class); + private static final Attribute ATTRIBUTE_OPERATING_SYSTEM = Attribute.of("net.neoforged.operatingsystem", String.class); + + @Override + public void apply(Project project) { + project.getDependencies().attributesSchema(attributesSchema -> { + attributesSchema.attribute(ATTRIBUTE_DISTRIBUTION).getDisambiguationRules().add(DistributionDisambiguation.class); + attributesSchema.attribute(ATTRIBUTE_OPERATING_SYSTEM).getDisambiguationRules().add(OperatingSystemDisambiguation.class); + }); + } +} + +// The production server configuration has to be given the attribute to get server dependencies +abstract class DistributionDisambiguation implements AttributeDisambiguationRule { + @Override + public void execute(MultipleCandidatesDetails details) { + details.closestMatch("client"); + } +} + +/** + * This disambiguation rule will select native dependencies based on the operating system Gradle is currently running on. + */ +abstract class OperatingSystemDisambiguation implements AttributeDisambiguationRule { + @Override + public void execute(MultipleCandidatesDetails details) { + details.closestMatch(switch (OperatingSystem.current()) { + case LINUX -> "linux"; + case MACOS -> "osx"; + case WINDOWS -> "windows"; + }); + } +} diff --git a/buildSrc/src/main/java/fmlbuild/InstallProductionClientTask.java b/buildSrc/src/main/java/fmlbuild/InstallProductionClientTask.java new file mode 100644 index 000000000..54e16d3cb --- /dev/null +++ b/buildSrc/src/main/java/fmlbuild/InstallProductionClientTask.java @@ -0,0 +1,325 @@ +package fmlbuild; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import org.gradle.api.DefaultTask; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.OutputDirectory; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; +import org.gradle.process.ExecOperations; + +import javax.inject.Inject; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Properties; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Downloads and installs a production NeoForge client. + */ +public abstract class InstallProductionClientTask extends DefaultTask { + + private final ExecOperations execOperations; + + /** + * This file collection should contain exactly one file: + * The NeoForge Installer Jar-File. + */ + @InputFiles + public abstract ConfigurableFileCollection getInstaller(); + + /** + * This file collection should only contain the NeoForm Runtime + * executable jar file. + */ + @InputFiles + public abstract ConfigurableFileCollection getNfrt(); + + /** + * The Minecraft version matching the NeoForge version to install. + */ + @Input + public abstract Property getMinecraftVersion(); + + /** + * The NeoForge version, used for placeholders when launching the game. + * It needs to match the installer used. + */ + @Input + public abstract Property getNeoForgeVersion(); + + /** + * Where NeoForge should be installed. + */ + @OutputDirectory + public abstract DirectoryProperty getInstallDir(); + + /** + * Where the game assets will be downloaded to. + */ + @OutputDirectory + public abstract DirectoryProperty getAssetsDir(); + + /** + * Where the shared libraries directory will be placed. + */ + @OutputDirectory + public abstract DirectoryProperty getLibrariesDir(); + + /** + * Points to the original Vanilla client JAR-file, if applicable. + */ + @OutputFile + public abstract RegularFileProperty getObfuscatedClientJar(); + + /** + * Points to a JVM compatible Arg-File, which contains the JVM parameters needed to launch the game. + */ + @OutputFile + public abstract RegularFileProperty getVanillaJvmArgFile(); + + /** + * A JVM compatible Arg-File that only contains the name of the main-class used by Vanilla. + */ + @OutputFile + public abstract RegularFileProperty getVanillaMainClassArgFile(); + + /** + * A File that contains one line per program argument passed to Vanilla. Encoded as UTF-8, unlike + * the normal JVM arg-files. + */ + @OutputFile + public abstract RegularFileProperty getVanillaProgramArgFile(); + + /** + * Points to a JVM compatible Arg-File, which contains the JVM parameters needed to launch NeoForge. + * This is additive to {@link #getVanillaJvmArgFile()}. + */ + @OutputFile + public abstract RegularFileProperty getNeoForgeJvmArgFile(); + + /** + * A JVM compatible Arg-File that contains the class-name used by NeoForge for launching. + * This is mutually exclusive with {@link #getVanillaMainClassArgFile()}. + */ + @OutputFile + public abstract RegularFileProperty getNeoForgeMainClassArgFile(); + + /** + * A File that contains one line per program argument passed to NeoForge for startup. Encoded as UTF-8, unlike + * the normal JVM arg-files. + * This is additive to {@link #getVanillaProgramArgFile()}. + */ + @OutputFile + public abstract RegularFileProperty getNeoForgeProgramArgFile(); + + @Inject + public InstallProductionClientTask(ExecOperations execOperations) { + this.execOperations = execOperations; + } + + @TaskAction + public void install() throws Exception { + var installDir = getInstallDir().getAsFile().get().toPath().toAbsolutePath(); + Files.createDirectories(installDir); + + // Installer looks for this file + var profilesJsonPath = installDir.resolve("launcher_profiles.json"); + Files.writeString(profilesJsonPath, "{}"); + + execOperations.javaexec(spec -> { + spec.workingDir(installDir); + spec.classpath(getInstaller().getSingleFile()); + spec.args("--install-client", installDir.toString()); + try { + spec.setStandardOutput(new BufferedOutputStream(Files.newOutputStream(installDir.resolve("install.log")))); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + + var minecraftVersion = getMinecraftVersion().get(); + var neoForgeVersion = getNeoForgeVersion().get(); + + // Download Minecraft Assets and read the asset index id and root for the program arguments + var assetPropertiesFile = new File(getTemporaryDir(), "asset.properties"); + execOperations.javaexec(spec -> { + spec.classpath(getNfrt().getSingleFile()); + spec.args( + "download-assets", + "--minecraft-version", + minecraftVersion, + "--output-properties-to", + assetPropertiesFile.getAbsolutePath() + ); + }); + + var assetProperties = new Properties(); + try (var in = new FileInputStream(assetPropertiesFile)) { + assetProperties.load(in); + } + + var assetIndex = Objects.requireNonNull(assetProperties.getProperty("asset_index"), "asset_index"); + var assetsRoot = Objects.requireNonNull(assetProperties.getProperty("assets_root"), "assets_root"); + var nativesDir = installDir.resolve("natives"); + Files.createDirectories(nativesDir); + + // Set up the placeholders generally used by Vanilla profiles in their argument definitions. + var placeholders = new HashMap(); + placeholders.put("auth_player_name", "FMLDev"); + placeholders.put("version_name", minecraftVersion); + placeholders.put("game_directory", getInstallDir().getAsFile().get().getAbsolutePath()); + placeholders.put("auth_uuid", "00000000-0000-4000-8000-000000000000"); + placeholders.put("auth_access_token", "0"); + placeholders.put("clientid", "0"); + placeholders.put("auth_xuid", "0"); + placeholders.put("user_type", "legacy"); + placeholders.put("version_type", "release"); + placeholders.put("assets_index_name", assetIndex); + placeholders.put("assets_root", assetsRoot); + placeholders.put("launcher_name", "NeoForgeProdInstallation"); + placeholders.put("launcher_version", "1.0"); + placeholders.put("natives_directory", nativesDir.toAbsolutePath().toString()); + // These are used by NF but provided by the launcher + placeholders.put("library_directory", getLibrariesDir().get().getAsFile().getAbsolutePath()); + placeholders.put("classpath_separator", File.pathSeparator); + + writeArgFiles(installDir, minecraftVersion, placeholders, getVanillaJvmArgFile(), getVanillaMainClassArgFile(), getVanillaProgramArgFile()); + writeArgFiles(installDir, "neoforge-" + neoForgeVersion, placeholders, getNeoForgeJvmArgFile(), getNeoForgeMainClassArgFile(), getNeoForgeProgramArgFile()); + } + + private void writeArgFiles(Path installDir, + String profileName, + HashMap placeholders, + RegularFileProperty jvmArgFileDestination, + RegularFileProperty mainClassArgFileDestination, + RegularFileProperty programArgFileDestination) throws IOException { + // Read back the version manifest and get the startup arguments + var manifestPath = installDir.resolve("versions").resolve(profileName).resolve(profileName + ".json"); + var manifest = readJson(manifestPath); + + var mainClass = manifest.getAsJsonPrimitive("mainClass").getAsString(); + + // Vanilla Arguments + var programArgs = getArguments(manifest, "game"); + expandPlaceholders(programArgs, placeholders); + RunUtils.escapeJvmArgs(programArgs); + var programArgsFile = programArgFileDestination.get().getAsFile().toPath(); + // Program args are generally read as UTF-8 by user-code (i.e. our argument expansion, DevLaunch, etc.) + Files.write(programArgsFile, programArgs, StandardCharsets.UTF_8); + + // This file can be read by both the JVM itself, or DevLaunch, which makes the lowest common denominator character set ASCII + var mainArgsFile = mainClassArgFileDestination.get().getAsFile().toPath(); + Files.writeString(mainArgsFile, mainClass, StandardCharsets.US_ASCII); + + var jvmArgs = getArguments(manifest, "jvm"); + expandPlaceholders(jvmArgs, placeholders); + RunUtils.cleanJvmArgs(jvmArgs); + RunUtils.escapeJvmArgs(jvmArgs); + var jvmArgsFile = jvmArgFileDestination.get().getAsFile().toPath(); + // The JVM receives the args in native platform encoding, so we have to encode them as such + Files.write(jvmArgsFile, jvmArgs, Charset.forName(System.getProperty("native.encoding"))); + } + + private static void expandPlaceholders(List args, Map variables) { + var pattern = Pattern.compile("\\$\\{([^}]+)}"); + + args.replaceAll(s -> { + var matcher = pattern.matcher(s); + return matcher.replaceAll(match -> { + var variable = match.group(1); + return Matcher.quoteReplacement(variables.getOrDefault(variable, matcher.group())); + }); + }); + } + + private static List getArguments(JsonObject manifest, String kind) { + var result = new ArrayList(); + + var gameArgs = manifest.getAsJsonObject("arguments").getAsJsonArray(kind); + for (var gameArg : gameArgs) { + if (gameArg.isJsonObject()) { + evaluateRule(gameArg.getAsJsonObject(), result); + } else { + result.add(gameArg.getAsString()); + } + } + + return result; + } + + /** + * Given a "rule" object from a Vanilla launcher profile, evaluate it into the effective arguments. + */ + private static void evaluateRule(JsonObject ruleObject, List out) { + for (var ruleEl : ruleObject.getAsJsonArray("rules")) { + var rule = ruleEl.getAsJsonObject(); + boolean allow = "allow".equals(rule.getAsJsonPrimitive("action").getAsString()); + // We only care about "os" rules + if (rule.has("os")) { + var os = rule.getAsJsonObject("os"); + var name = os.getAsJsonPrimitive("name"); + var arch = os.getAsJsonPrimitive("arch"); + boolean ruleMatches = (name == null || isCurrentOsName(name.getAsString())) && (arch == null || isCurrentOsArch(arch.getAsString())); + if (ruleMatches != allow) { + return; + } + } else { + // We assume unknown rules do not apply + return; + } + } + + var value = ruleObject.get("value"); + if (value.isJsonPrimitive()) { + out.add(value.getAsString()); + } else { + for (var valueEl : value.getAsJsonArray()) { + out.add(valueEl.getAsString()); + } + } + } + + private static boolean isCurrentOsName(String os) { + return switch (os) { + case "windows" -> OperatingSystem.current() == OperatingSystem.WINDOWS; + case "osx" -> OperatingSystem.current() == OperatingSystem.MACOS; + case "linux" -> OperatingSystem.current() == OperatingSystem.LINUX; + default -> false; + }; + } + + private static boolean isCurrentOsArch(String arch) { + return switch (arch) { + case "x86" -> System.getProperty("os.arch").equals("x86"); + default -> false; + }; + } + + private static JsonObject readJson(Path path) throws IOException { + try (var reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + return new Gson().fromJson(reader, JsonObject.class); + } + } + +} + diff --git a/buildSrc/src/main/java/fmlbuild/InstallProductionServerTask.java b/buildSrc/src/main/java/fmlbuild/InstallProductionServerTask.java new file mode 100644 index 000000000..680132bdc --- /dev/null +++ b/buildSrc/src/main/java/fmlbuild/InstallProductionServerTask.java @@ -0,0 +1,140 @@ +package fmlbuild; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import org.gradle.api.DefaultTask; +import org.gradle.api.GradleException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.OutputDirectory; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; +import org.gradle.process.ExecOperations; + +import javax.inject.Inject; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.zip.ZipFile; + +/** + * Downloads and installs a production NeoForge server. + */ +public abstract class InstallProductionServerTask extends DefaultTask { + private final ExecOperations execOperations; + + /** + * The NeoForge installer jar is expected to be the only file in this file collection. + */ + @InputFiles + public abstract ConfigurableFileCollection getInstaller(); + + /** + * The NeoForge version that is being installed. This is required to then look up the server argument + * file after the installer has done its thing. + */ + @Input + public abstract Property getNeoForgeVersion(); + + /** + * Where the server should be installed. + */ + @OutputDirectory + public abstract DirectoryProperty getInstallDir(); + + /** + * Write an argument-file for the JVM containing the required JVM args for startup. + * Any module or classpath arguments have been stripped. + */ + @OutputFile + public abstract RegularFileProperty getNeoForgeJvmArgFile(); + + /** + * Write an argument-file for DevLauncher here that contains the program arguments to launch the server. + */ + @OutputFile + public abstract RegularFileProperty getNeoForgeProgramArgFile(); + + /** + * Write an argument-file for DevLauncher here that contains the original main class name used + * to launch the server. + */ + @OutputFile + public abstract RegularFileProperty getNeoForgeMainClassArgFile(); + + @Inject + public InstallProductionServerTask(ExecOperations execOperations) { + this.execOperations = execOperations; + } + + @TaskAction + public void install() throws Exception { + var installDir = getInstallDir().getAsFile().get().toPath().toAbsolutePath(); + Files.createDirectories(installDir); + + execOperations.javaexec(spec -> { + spec.workingDir(installDir); + spec.classpath(getInstaller().getSingleFile()); + spec.args("--install-server", installDir.toString()); + try { + spec.setStandardOutput(new BufferedOutputStream(Files.newOutputStream(installDir.resolve("install.log")))); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + + // We need to know the name of the main class to split the arg-file into JVM and program arguments + var mainClass = getMainClass(); + // The difference here is only really in path separators... + var argFileName = File.pathSeparatorChar == ':' ? "unix_args.txt" : "win_args.txt"; + var argFilePath = installDir.resolve("libraries/net/neoforged/neoforge/" + getNeoForgeVersion().get() + "/" + argFileName); + var argFileContent = Files.readString( + argFilePath, + StandardCharsets.UTF_8 + ); + var startOfSplit = argFileContent.indexOf(mainClass); + if (startOfSplit == -1) { + throw new GradleException("Argfile " + argFilePath + " does not contain the main class name " + mainClass); + } + if (argFileContent.indexOf(mainClass, startOfSplit + 1) != -1) { + throw new GradleException("Argfile " + argFilePath + " contains the main class name " + mainClass + " more than once."); + } + + var jvmArgs = argFileContent.substring(0, startOfSplit); + var programArgs = argFileContent.substring(startOfSplit + mainClass.length() + 1); + + // We need to sanitize all JVM args by removing modular args + var jvmArgParams = RunUtils.splitJvmArgs(jvmArgs); + RunUtils.cleanJvmArgs(jvmArgParams); + + // This is read by the JVM using the native platform encoding + Files.write(getNeoForgeJvmArgFile().getAsFile().get().toPath(), jvmArgParams, Charset.forName(System.getProperty("native.encoding"))); + Files.writeString(getNeoForgeMainClassArgFile().getAsFile().get().toPath(), mainClass, Charset.forName(System.getProperty("native.encoding"))); + // This is read by our own code in UTF-8 + Files.writeString(getNeoForgeProgramArgFile().getAsFile().get().toPath(), programArgs, StandardCharsets.UTF_8); + } + + private String getMainClass() throws IOException { + String versionContent; + try (var zf = new ZipFile(getInstaller().getSingleFile())) { + var entry = zf.getEntry("version.json"); + if (entry == null) { + throw new GradleException("The installer " + getInstaller().getSingleFile() + " contains no version.json"); + } + try (var in = zf.getInputStream(entry)) { + versionContent = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + var versionRoot = new Gson().fromJson(versionContent, JsonObject.class); + return versionRoot.getAsJsonPrimitive("mainClass").getAsString(); + } +} diff --git a/buildSrc/src/main/java/fmlbuild/NeoForgeClientInstallation.java b/buildSrc/src/main/java/fmlbuild/NeoForgeClientInstallation.java new file mode 100644 index 000000000..eb286f6c4 --- /dev/null +++ b/buildSrc/src/main/java/fmlbuild/NeoForgeClientInstallation.java @@ -0,0 +1,49 @@ +package fmlbuild; + +import org.gradle.api.Project; +import org.gradle.api.file.RegularFileProperty; + +import javax.inject.Inject; + +public abstract class NeoForgeClientInstallation extends NeoForgeInstallation { + @Inject + public NeoForgeClientInstallation(Project project, String name) { + super(project, name); + getVanillaJvmArgFile().convention(getDirectory().file("vanilla_jvm_args.txt")); + getVanillaMainClassArgFile().convention(getDirectory().file("vanilla_main_class.txt")); + getVanillaProgramArgFile().convention(getDirectory().file("vanilla_args.txt")); + getNeoForgeJvmArgFile().convention(getDirectory().file("neoforge_jvm_args.txt")); + getNeoForgeMainClassArgFile().convention(getDirectory().file("neoforge_main_class.txt")); + getNeoForgeProgramArgFile().convention(getDirectory().file("neoforge_args.txt")); + } + + /** + * An argfile with the program arguments defined by the Vanilla launcher profile will be written here. + */ + public abstract RegularFileProperty getVanillaJvmArgFile(); + + /** + * An argfile with the main class defined in the Vanilla launcher profile will be written here. + */ + public abstract RegularFileProperty getVanillaMainClassArgFile(); + + /** + * An argfile with the program arguments defined by the Vanilla launcher profile will be written here. + */ + public abstract RegularFileProperty getVanillaProgramArgFile(); + + /** + * An argfile with the JVM args defined in the NeoForge launcher profile will be written here. + */ + public abstract RegularFileProperty getNeoForgeJvmArgFile(); + + /** + * An argfile with the main class defined in the NeoForge launcher profile will be written here. + */ + public abstract RegularFileProperty getNeoForgeMainClassArgFile(); + + /** + * An argfile with the program args defined in the NeoForge launcher profile will be written here. + */ + public abstract RegularFileProperty getNeoForgeProgramArgFile(); +} diff --git a/buildSrc/src/main/java/fmlbuild/NeoForgeInstallation.java b/buildSrc/src/main/java/fmlbuild/NeoForgeInstallation.java new file mode 100644 index 000000000..57cc82932 --- /dev/null +++ b/buildSrc/src/main/java/fmlbuild/NeoForgeInstallation.java @@ -0,0 +1,35 @@ +package fmlbuild; + +import org.gradle.api.Named; +import org.gradle.api.Project; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.provider.Property; + +public abstract class NeoForgeInstallation implements Named { + private final String name; + + public NeoForgeInstallation(Project project, String name) { + this.name = name; + getDirectory().convention(project.getLayout().getBuildDirectory().dir("installs/" + name)); + } + + @Override + public String getName() { + return name; + } + + /** + * The NeoForge version to install. + */ + public abstract Property getVersion(); + + /** + * The Minecraft version matching the NeoForge version. + */ + public abstract Property getMinecraftVersion(); + + /** + * Where the installation should be made. + */ + public abstract DirectoryProperty getDirectory(); +} diff --git a/buildSrc/src/main/java/fmlbuild/NeoForgeInstallationsPlugin.java b/buildSrc/src/main/java/fmlbuild/NeoForgeInstallationsPlugin.java new file mode 100644 index 000000000..1c8f0e8ad --- /dev/null +++ b/buildSrc/src/main/java/fmlbuild/NeoForgeInstallationsPlugin.java @@ -0,0 +1,127 @@ +package fmlbuild; + +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; + +/** + * Apply this plugin to get access to a local production client installation. + */ +public abstract class NeoForgeInstallationsPlugin implements Plugin { + + @Override + public void apply(Project project) { + var installations = project.getObjects().polymorphicDomainObjectContainer(NeoForgeInstallation.class); + installations.registerFactory(fmlbuild.NeoForgeClientInstallation.class, name -> { + return project.getObjects().newInstance(NeoForgeClientInstallation.class, name); + }); + installations.registerFactory(fmlbuild.NeoForgeServerInstallation.class, name -> { + return project.getObjects().newInstance(NeoForgeServerInstallation.class, name); + }); + + // Shared configurations + var dependencyFactory = project.getDependencyFactory(); + var nfrtCliConfig = project.getConfigurations().create("nfrtCli", config -> { + config.setDescription("This configuration pulls the NFRT CLI tool"); + config.setCanBeResolved(true); + config.setCanBeConsumed(false); + config.setTransitive(false); + config.withDependencies(dependencies -> { + dependencies.add(dependencyFactory.create("net.neoforged:neoform-runtime:0.1.64:all")); + }); + }); + + // When a new install is declared, immediately create the associated Gradle objects + project.getExtensions().add("neoForgeInstallations", installations); + installations.all(installation -> { + if (installation instanceof NeoForgeClientInstallation clientInstallation) { + addClientInstallation(project, nfrtCliConfig, clientInstallation); + } else if (installation instanceof NeoForgeServerInstallation serverInstallation) { + addServerInstallation(project, serverInstallation); + } + }); + installations.whenObjectRemoved(installation -> { + throw new GradleException("Cannot remove installations once they have been registered"); + }); + } + + private void addClientInstallation(Project project, Configuration nfrtCliConfig, NeoForgeClientInstallation installation) { + var depFactory = project.getDependencyFactory(); + + var capitalizedName = installation.getName(); + capitalizedName = capitalizedName.substring(0, 1).toUpperCase() + capitalizedName.substring(1); + + var installerConfig = project.getConfigurations().create("neoForgeInstaller" + capitalizedName, config -> { + config.setDescription("This configuration pulls the NeoForge installer fat-jar for the requested version of NeoForge"); + config.setCanBeResolved(true); + config.setCanBeConsumed(false); + config.setTransitive(false); + config.withDependencies(dependencies -> { + dependencies.addLater(installation + .getVersion() + .map(v -> depFactory + .create("net.neoforged:neoforge:" + v).capabilities(caps -> { + caps.requireCapability("net.neoforged:neoforge-installer"); + }))); + }); + }); + + project.getTasks().register("installNeoForge" + capitalizedName, InstallProductionClientTask.class, task -> { + task.setGroup("fml/installations"); + task.getInstaller().from(installerConfig); + task.getNfrt().from(nfrtCliConfig); + task.getMinecraftVersion().set(installation.getMinecraftVersion()); + task.getNeoForgeVersion().set(installation.getVersion()); + task.getInstallDir().set(installation.getDirectory()); + task.getAssetsDir().set(task.getInstallDir().dir("assets")); + task.getLibrariesDir().set(task.getInstallDir().dir("libraries")); + + // Write the JVM args to files + task.getVanillaJvmArgFile().set(installation.getVanillaJvmArgFile()); + task.getVanillaMainClassArgFile().set(installation.getVanillaMainClassArgFile()); + task.getVanillaProgramArgFile().set(installation.getVanillaProgramArgFile()); + task.getNeoForgeJvmArgFile().set(installation.getNeoForgeJvmArgFile()); + task.getNeoForgeMainClassArgFile().set(installation.getNeoForgeMainClassArgFile()); + task.getNeoForgeProgramArgFile().set(installation.getNeoForgeProgramArgFile()); + + // Path to the obfuscated Minecraft jar + var obfuscatedJar = task.getInstallDir().file(installation.getMinecraftVersion().map(minecraftVersion -> "versions/%s/%s.jar".formatted(minecraftVersion, minecraftVersion))); + task.getObfuscatedClientJar().set(obfuscatedJar); + }); + } + + private void addServerInstallation(Project project, NeoForgeServerInstallation installation) { + var depFactory = project.getDependencyFactory(); + + var capitalizedName = installation.getName(); + capitalizedName = capitalizedName.substring(0, 1).toUpperCase() + capitalizedName.substring(1); + + var installerConfig = project.getConfigurations().create("neoForgeInstaller" + capitalizedName, config -> { + config.setDescription("This configuration pulls the NeoForge installer fat-jar for the requested version of NeoForge"); + config.setCanBeResolved(true); + config.setCanBeConsumed(false); + config.setTransitive(false); + config.withDependencies(dependencies -> { + dependencies.addLater(installation + .getVersion() + .map(v -> depFactory + .create("net.neoforged:neoforge:" + v).capabilities(caps -> { + caps.requireCapability("net.neoforged:neoforge-installer"); + }))); + }); + }); + + project.getTasks().register("installNeoForge" + capitalizedName, InstallProductionServerTask.class, task -> { + task.setGroup("fml/installations"); + task.getInstaller().from(installerConfig); + task.getInstallDir().set(installation.getDirectory()); + task.getNeoForgeVersion().set(installation.getVersion()); + + // Write the JVM args to files + task.getNeoForgeJvmArgFile().set(installation.getNeoForgeJvmArgFile()); + task.getNeoForgeMainClassArgFile().set(installation.getNeoForgeMainClassArgFile()); + task.getNeoForgeProgramArgFile().set(installation.getNeoForgeProgramArgFile()); + }); + } +} diff --git a/buildSrc/src/main/java/fmlbuild/NeoForgeServerInstallation.java b/buildSrc/src/main/java/fmlbuild/NeoForgeServerInstallation.java new file mode 100644 index 000000000..f12d3f572 --- /dev/null +++ b/buildSrc/src/main/java/fmlbuild/NeoForgeServerInstallation.java @@ -0,0 +1,33 @@ +package fmlbuild; + +import org.gradle.api.Project; +import org.gradle.api.file.RegularFileProperty; + +import javax.inject.Inject; + +public abstract class NeoForgeServerInstallation extends NeoForgeInstallation { + @Inject + public NeoForgeServerInstallation(Project project, String name) { + super(project, name); + + // Write the JVM args to files + getNeoForgeJvmArgFile().set(getDirectory().file("neoforge_jvm_args.txt")); + getNeoForgeMainClassArgFile().set(getDirectory().file("neoforge_mainclass.txt")); + getNeoForgeProgramArgFile().set(getDirectory().file("neoforge_args.txt")); + } + + /** + * An JVM argfile with the necessary JVM args to launch will be written here. + */ + public abstract RegularFileProperty getNeoForgeJvmArgFile(); + + /** + * An JVM argfile with the main class needed to launch will be written here. + */ + public abstract RegularFileProperty getNeoForgeMainClassArgFile(); + + /** + * An argfile with the necessary program arguments will be written here. + */ + public abstract RegularFileProperty getNeoForgeProgramArgFile(); +} diff --git a/buildSrc/src/main/java/fmlbuild/OperatingSystem.java b/buildSrc/src/main/java/fmlbuild/OperatingSystem.java new file mode 100644 index 000000000..1c06bfb35 --- /dev/null +++ b/buildSrc/src/main/java/fmlbuild/OperatingSystem.java @@ -0,0 +1,23 @@ +package fmlbuild; + +import org.gradle.api.GradleException; + +enum OperatingSystem { + LINUX, + MACOS, + WINDOWS; + + public static OperatingSystem current() { + var osName = System.getProperty("os.name"); + // The following matches the logic in Apache Commons Lang 3 SystemUtils + if (osName.startsWith("Linux") || osName.startsWith("LINUX")) { + return LINUX; + } else if (osName.startsWith("Mac OS X")) { + return MACOS; + } else if (osName.startsWith("Windows")) { + return WINDOWS; + } else { + throw new GradleException("Unsupported operating system: " + osName); + } + } +} diff --git a/buildSrc/src/main/java/fmlbuild/RunConfigurationDependencies.java b/buildSrc/src/main/java/fmlbuild/RunConfigurationDependencies.java new file mode 100644 index 000000000..443678d77 --- /dev/null +++ b/buildSrc/src/main/java/fmlbuild/RunConfigurationDependencies.java @@ -0,0 +1,19 @@ +package fmlbuild; + +import org.gradle.api.artifacts.dsl.Dependencies; +import org.gradle.api.artifacts.dsl.DependencyCollector; + +/** + * Additional dependencies of the run configuration. + */ +public interface RunConfigurationDependencies extends Dependencies { + /** + * Additional dependencies to put on the classpath. + */ + DependencyCollector getClasspath(); + + /** + * Additional dependencies to put on the module path. + */ + DependencyCollector getModulepath(); +} diff --git a/buildSrc/src/main/java/fmlbuild/RunConfigurationSettings.java b/buildSrc/src/main/java/fmlbuild/RunConfigurationSettings.java new file mode 100644 index 000000000..470f7c2e0 --- /dev/null +++ b/buildSrc/src/main/java/fmlbuild/RunConfigurationSettings.java @@ -0,0 +1,102 @@ +package fmlbuild; + +import org.gradle.api.Action; +import org.gradle.api.Named; +import org.gradle.api.Project; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Nested; +import org.gradle.api.tasks.TaskProvider; + +import javax.inject.Inject; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public abstract class RunConfigurationSettings implements Named { + private final String name; + + /** + * The Gradle tasks that should be run before running this run. + */ + private List> tasksBefore = new ArrayList<>(); + + @Inject + public RunConfigurationSettings(Project project, String name) { + this.name = name; + getWorkingDirectory().convention(project.getLayout().getProjectDirectory()); + } + + @Override + public String getName() { + return name; + } + + /** + * The Gradle group to put the task into. + */ + public abstract Property getTaskGroup(); + + /** + * The main class to launch. + */ + public abstract Property getMainClass(); + + /** + * The working directory to launch in. + */ + public abstract DirectoryProperty getWorkingDirectory(); + + /** + * The program arguments to launch with. + */ + public abstract ListProperty getProgramArguments(); + + /** + * The JVM arguments to launch with. + */ + public abstract ListProperty getJvmArguments(); + + /** + * Additional system properties to add to the JVM arguments. + */ + public abstract MapProperty getSystemProperties(); + + /** + * Additional dependencies to add to the class- and module-path. + */ + @Nested + public abstract RunConfigurationDependencies getDependencies(); + + public void dependencies(Action action) { + action.execute(getDependencies()); + } + + /** + * Gets the Gradle tasks that should be run before running this run. + */ + public List> getTasksBefore() { + return tasksBefore; + } + + /** + * Sets the Gradle tasks that should be run before running this run. + * This also slows down running through your IDE since it will first execute Gradle to run the requested + * tasks, and then run the actual game. + */ + public void setTasksBefore(List> taskNames) { + this.tasksBefore = new ArrayList<>(Objects.requireNonNull(taskNames, "taskNames")); + } + + /** + * Configures the given Task to be run before launching the game. + * This also slows down running through your IDE since it will first execute Gradle to run the requested + * tasks, and then run the actual game. + */ + public void taskBefore(TaskProvider task) { + this.tasksBefore.add(task); + } + +} diff --git a/buildSrc/src/main/java/fmlbuild/RunConfigurationsPlugin.java b/buildSrc/src/main/java/fmlbuild/RunConfigurationsPlugin.java new file mode 100644 index 000000000..db7780b7d --- /dev/null +++ b/buildSrc/src/main/java/fmlbuild/RunConfigurationsPlugin.java @@ -0,0 +1,106 @@ +package fmlbuild; + +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.Task; +import org.gradle.api.file.Directory; +import org.gradle.api.file.FileSystemLocation; +import org.gradle.api.plugins.JavaPluginExtension; +import org.gradle.api.tasks.JavaExec; +import org.gradle.api.tasks.SourceSetContainer; +import org.gradle.jvm.toolchain.JavaToolchainService; + +import javax.inject.Inject; +import java.io.File; +import java.util.List; +import java.util.stream.Collectors; + +public abstract class RunConfigurationsPlugin implements Plugin { + @Inject + public abstract JavaToolchainService getJavaToolchainService(); + + @Override + public void apply(Project project) { + var logger = project.getLogger(); + + var java = project.getExtensions().getByType(JavaPluginExtension.class); + + var sourceSets = project.getExtensions().getByType(SourceSetContainer.class); + var runConfigurations = project.getObjects().domainObjectContainer(RunConfigurationSettings.class); + project.getExtensions().add("runConfigurations", runConfigurations); + runConfigurations.all(runConfiguration -> { + var name = runConfiguration.getName(); + logger.info("Adding run configuration {}", name); + var capitalizedName = Character.toUpperCase(name.charAt(0)) + name.substring(1); + + // Create a distinct source set to set up a separate classpath for this + var sourceSet = sourceSets.create(name); + var runtimeConfig = project.getConfigurations().getByName(sourceSet.getRuntimeOnlyConfigurationName()); + runtimeConfig.fromDependencyCollector(runConfiguration.getDependencies().getClasspath()); + runtimeConfig.fromDependencyCollector(runConfiguration.getDependencies().getModulepath()); + + var runtimeModulesConfig = project.getConfigurations().create(getRuntimeModuleConfigName(runConfiguration), spec -> { + spec.setDescription("The set of dependencies that should be put on the runtime module path"); + spec.setCanBeConsumed(false); + spec.setCanBeResolved(true); + var runtimeClasspathConfig = project.getConfigurations().getByName(sourceSet.getRuntimeClasspathConfigurationName()); + spec.shouldResolveConsistentlyWith(runtimeClasspathConfig); + }); + runtimeModulesConfig.fromDependencyCollector(runConfiguration.getDependencies().getModulepath()); + + project.getTasks().create("run" + capitalizedName, JavaExec.class, task -> { + task.getOutputs().upToDateWhen(ignored -> false); + task.classpath(sourceSet.getRuntimeClasspath()); + task.getMainClass().set(runConfiguration.getMainClass()); + var jvmArguments = task.getJvmArguments(); + jvmArguments.addAll(runConfiguration.getJvmArguments()); + jvmArguments.addAll(runConfiguration.getSystemProperties().map(properties -> { + return properties.entrySet().stream().map(entry -> "-D" + entry.getKey() + "=" + entry.getValue()).toList(); + })); + jvmArguments.addAll(runtimeModulesConfig.getElements().map(elements -> { + if (elements.isEmpty()) { + return List.of(); + } + return List.of("-p", elements.stream() + .map(FileSystemLocation::getAsFile) + .map(File::getAbsolutePath) + .collect(Collectors.joining(File.pathSeparator)) + ); + })); + + // I don't see a way to avoid querying this provider eagerly... + project.afterEvaluate(ignored -> { + task.setGroup(runConfiguration.getTaskGroup().get()); + }); + // Use the project java version to launch + task.getJavaLauncher().set(getJavaToolchainService().launcherFor(javaSpec -> { + javaSpec.getLanguageVersion().set(java.getToolchain().getLanguageVersion()); + })); + + // We need to pass some inputs to the task that cannot be set via Properties yet + var taskInputs = task.getInputs(); + // To avoid accidentally tripping Gradle by passing a "File", we convert it to String in the provider + taskInputs.property("runWorkingDirectory", runConfiguration.getWorkingDirectory().map(Directory::getAsFile).map(File::getAbsolutePath)); + taskInputs.property("runProgramArgs", runConfiguration.getProgramArguments()); + task.doFirst(RunConfigurationsPlugin::configureJavaExec); + + task.dependsOn(runConfiguration.getTasksBefore()); + }); + }); + runConfigurations.whenObjectRemoved(installation -> { + throw new GradleException("Cannot remove installations once they have been registered"); + }); + } + + private static String getRuntimeModuleConfigName(RunConfigurationSettings runConfig) { + return runConfig.getName() + "RuntimeModules"; + } + + private static void configureJavaExec(Task task) { + var javaExec = (JavaExec) task; + var inputProps = task.getInputs().getProperties(); + javaExec.workingDir(inputProps.get("runWorkingDirectory")); + javaExec.args((List) inputProps.get("runProgramArgs")); + } +} diff --git a/buildSrc/src/main/java/fmlbuild/RunUtils.java b/buildSrc/src/main/java/fmlbuild/RunUtils.java new file mode 100644 index 000000000..da063d28f --- /dev/null +++ b/buildSrc/src/main/java/fmlbuild/RunUtils.java @@ -0,0 +1,63 @@ +package fmlbuild; + +import java.io.IOException; +import java.io.StreamTokenizer; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.List; + +final class RunUtils { + private RunUtils() { + } + + static List splitJvmArgs(String jvmArgs) throws IOException { + StreamTokenizer tok = new StreamTokenizer(new StringReader(jvmArgs)); + tok.resetSyntax(); + tok.wordChars(32, 255); + tok.whitespaceChars(0, 32); + tok.quoteChar('"'); + tok.quoteChar('\''); + tok.commentChar('#'); + + var args = new ArrayList(); + while (tok.nextToken() != -1) { + args.add(tok.sval); + } + return args; + } + + /** + * We remove any classpath or module-path arguments since both have to be set up with project artifacts, + * and not artifacts from the installation. + */ + static void cleanJvmArgs(List jvmArgs) { + for (int i = 0; i < jvmArgs.size(); i++) { + var jvmArg = jvmArgs.get(i); + // Remove the classpath argument + if ("-cp".equals(jvmArg) || "-classpath".equals(jvmArg)) { + if (i + 1 < jvmArgs.size() && jvmArgs.get(i + 1).equals("${classpath}")) { + jvmArgs.remove(i + 1); + } + jvmArgs.remove(i--); + } else if ("-p".equals(jvmArg) || "--module-path".equals(jvmArg)) { + if (i + 1 < jvmArgs.size()) { + jvmArgs.remove(i + 1); + } + jvmArgs.remove(i--); + } + } + } + + static void escapeJvmArgs(List jvmArgs) { + jvmArgs.replaceAll(RunUtils::escapeJvmArg); + } + + static String escapeJvmArg(String arg) { + var escaped = arg.replace("\\", "\\\\").replace("\"", "\\\""); + if (escaped.contains(" ")) { + return "\"" + escaped + "\""; + } + return escaped; + } + +} diff --git a/gradle.properties b/gradle.properties index 63ceb52bf..965e27eb0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,9 +1,12 @@ -gradleutils_version=3.0.0-alpha.13 +gradleutils_version=3.0.0 + +test_neoforge_version=21.0.163 +test_minecraft_version=1.21 mergetool_version=2.0.0 -accesstransformers_version=10.0.1 +accesstransformers_version=11.0.1 coremods_version=7.0.3 -eventbus_version=7.0.16 +eventbus_version=8.0.1 modlauncher_version=11.0.3 securejarhandler_version=3.0.7 bootstraplauncher_version=1.1.8 @@ -11,18 +14,21 @@ asm_version=9.5 mixin_version=0.14.0+mixin.0.8.6 terminalconsoleappender_version=1.3.0 nightconfig_version=3.8.0 -jetbrains_annotations_version=24.0.1 +jetbrains_annotations_version=24.1.0 slf4j_api_version=1.8.0-beta4 apache_maven_artifact_version=3.8.5 jarjar_version=0.4.1 lwjgl_version=3.3.1 jupiter_version=5.10.2 mockito_version=5.11.0 +assertj_version=3.26.0 mojang_logging_version=1.1.1 -log4j_version=2.19.0 +log4j_version=2.22.1 guava_version=31.1-jre gson_version=2.10 apache_commons_lang3_version=3.12.0 jopt_simple_version=5.0.4 commons_io_version=2.11.0 + +org.gradle.configuration-cache=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b82aa23a4..a4413138c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle b/settings.gradle index d63eca05f..0fafe0109 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,23 +1,29 @@ +import org.gradle.api.initialization.resolve.RepositoriesMode +import org.gradle.api.initialization.resolve.RulesMode + pluginManagement { repositories { gradlePluginPortal() - mavenLocal() maven { url = 'https://maven.neoforged.net/releases' } } -} -buildscript { - dependencies { - classpath('com.google.code.gson:gson') { - version { - strictly '2.10.1' - } - } + plugins { + id 'net.neoforged.moddev' version '1.0.14' + id 'net.neoforged.moddev.repositories' version '1.0.14' } } plugins { - id 'org.gradle.toolchains.foojay-resolver-convention' version '0.5.0' + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0' + id 'net.neoforged.moddev.repositories' +} + +dependencyResolutionManagement { + repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS + rulesMode = RulesMode.FAIL_ON_PROJECT_RULES + repositories { + mavenCentral() + } } rootProject.name = 'FancyModLoader' @@ -25,3 +31,5 @@ rootProject.name = 'FancyModLoader' include 'loader' include 'earlydisplay' include 'junit' +include 'tests' +enableFeaturePreview "STABLE_CONFIGURATION_CACHE" diff --git a/tests/build.gradle b/tests/build.gradle new file mode 100644 index 000000000..92836b3d3 --- /dev/null +++ b/tests/build.gradle @@ -0,0 +1,87 @@ +plugins { + id 'net.neoforged.moddev' +} + +apply plugin: fmlbuild.NeoForgeInstallationsPlugin +apply plugin: fmlbuild.RunConfigurationsPlugin +apply plugin: fmlbuild.AttributesPlugin + +neoForge { + version = test_neoforge_version + runs { + client { + client() + gameDirectory = file("build/runs/client") + } + server { + server() + gameDirectory = file("build/runs/server") + } + data { + data() + gameDirectory = file("build/runs/data") + } + } +} + +runClient.group = "fml/test runs" +runServer.group = "fml/test runs" + +neoForgeInstallations { + register("client", fmlbuild.NeoForgeClientInstallation) { + version = test_neoforge_version + minecraftVersion = test_minecraft_version + } + register("server", fmlbuild.NeoForgeServerInstallation) { + version = test_neoforge_version + minecraftVersion = test_minecraft_version + } +} + +runConfigurations { + configureEach { + taskGroup = "fml/test runs" + mainClass = "net.neoforged.devlaunch.Main" + dependencies { + classpath project(":earlydisplay") + classpath project(":loader") + classpath "net.neoforged:minecraft-dependencies:$test_minecraft_version" + // Needed to support arg-files for program arguments + classpath "net.neoforged:DevLaunch:1.0.1" + modulepath("net.neoforged:neoforge:$test_neoforge_version") { + capabilities { + requireCapability("net.neoforged:neoforge-moddev-module-path") + } + } + } + } + productionClient { + workingDirectory = neoForgeInstallations.client.directory + jvmArguments.add(neoForgeInstallations.client.vanillaJvmArgFile.map { "@" + it }) + jvmArguments.add(neoForgeInstallations.client.neoForgeJvmArgFile.map { "@" + it }) + programArguments.add(neoForgeInstallations.client.neoForgeMainClassArgFile.map { "@" + it }) + programArguments.add(neoForgeInstallations.client.vanillaProgramArgFile.map { "@" + it }) + programArguments.add(neoForgeInstallations.client.neoForgeProgramArgFile.map { "@" + it }) + // While FML does not yet make use of it, for consistency with the vanilla launcher, + // the obfuscated client jar should be on the classpath. + dependencies { + classpath files(tasks.named("installNeoForgeClient").map { it.obfuscatedClientJar }) + } + taskBefore tasks.named("installNeoForgeClient") + } + productionServer { + workingDirectory = neoForgeInstallations.server.directory + jvmArguments.add(neoForgeInstallations.server.neoForgeJvmArgFile.map { "@" + it }) + programArguments.add(neoForgeInstallations.server.neoForgeMainClassArgFile.map { "@" + it }) + programArguments.add(neoForgeInstallations.server.neoForgeProgramArgFile.map { "@" + it }) + taskBefore tasks.named("installNeoForgeServer") + } +} + +// Since the neoforge-dependencies use strict resolution, we're kinda lost and have to force. +configurations.configureEach { + resolutionStrategy.dependencySubstitution { + substitute module("net.neoforged.fancymodloader:loader") using project(":loader") + substitute module("net.neoforged.fancymodloader:earlydisplay") using project(":earlydisplay") + } +}