fix: rewrite Forge launch to pass version.json args directly (AstralRinth approach)

Root cause: our launcher was fighting with version.json by manually constructing
-p, -cp, --add-modules, --add-opens, -DignoreList, and -DlibraryDirectory.
AstralRinth passes version.json JVM args through with placeholder substitution,
then appends memory/GC/custom args.

Changes:
- VersionManifest: add getAllJvmArguments() and getAllGameArguments() that merge
  parent (vanilla) args with child (forge) args
- LaunchCommandBuilder.build(): rewrite Forge/NeoForge branch to parse ALL
  version.json JVM args and game args with placeholder substitution
- Removed: ensureVersionJarForForge(), findSrgClientJar(),
  filterClasspathAgainstModulePath(), manual -Djava.library.path for Forge
- Classpath: built from version.json libraries + client jar (first position)
- Memory/GC args appended AFTER version.json args (like AstralRinth)
- Fallback to manual args if version.json has no JVM/game args
This commit is contained in:
SashegDev
2026-07-14 10:30:55 +00:00
parent dcb3412a58
commit d7ba06f24c
2 changed files with 140 additions and 89 deletions
@@ -44,92 +44,129 @@ public class LaunchCommandBuilder {
? options.getJavaPath() : "java";
command.add(javaPath);
command.addAll(getJvmArguments(options));
Path nativesDir = instance.getPath().resolve("natives");
if (!Files.exists(nativesDir)) {
Files.createDirectories(nativesDir);
}
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
String loaderType = instance.getLoaderType().toLowerCase();
boolean isModloader = "fabric".equals(loaderType) || "forge".equals(loaderType) || "neoforge".equals(loaderType);
VersionManifest manifest = resolveVersionManifest();
// For modloaders, use vanilla classpath but read main class from manifest when available
if (isModloader) {
// For Forge/NeoForge: use recursive scan of libraries/ for classpath,
// always use ModLauncher as main class (version.json might have
// BootstrapLauncher which breaks with Java module system)
if ("forge".equals(loaderType) || "neoforge".equals(loaderType)) {
// Ensure vanilla jar is at versions/<forgeId>/<forgeId>.jar
// so securejarhandler creates the "minecraft" module from it
ensureVersionJarForForge();
if ("forge".equals(loaderType) || "neoforge".equals(loaderType)) {
// Forge/NeoForge: Pass version.json JVM args and game args through
// with placeholder substitution. Do NOT manually add -p, -cp,
// --add-modules, --add-opens, or -DignoreList. The version.json
// defines all of these. This matches AstralRinth's approach.
System.out.println(ZAnsi.cyan(" Forge/NeoForge: using version.json args with placeholder substitution"));
// Build classpath from manifest libraries.
String classpath;
if (manifest != null) {
classpath = buildClasspathFromManifest(manifest, false);
} else {
classpath = "";
}
if (classpath == null || classpath.isEmpty()) {
classpath = buildVanillaClasspath();
}
// Build classpath from manifest libraries + client jar
String classpath;
if (manifest != null) {
classpath = buildClasspathFromManifest(manifest, false);
} else {
classpath = "";
}
// The forge client jar (forge-*-client.jar) is NOT listed in the
// version JSON's libraries array, so it's NOT on the classpath.
// FML discovers it by scanning the library directory, but
// securejarhandler only processes classpath entries.
// We add it explicitly so securejarhandler creates the "minecraft"
// module from its Automatic-Module-Name: minecraft.
String forgeVersion = instance.getMinecraftVersion() + "-" + instance.getLoaderVersion();
Path forgeClientJar = instance.getPath().resolve("libraries")
.resolve("net/minecraftforge/forge")
.resolve(forgeVersion)
.resolve("forge-" + forgeVersion + "-client.jar");
if (Files.exists(forgeClientJar) && isValidJar(forgeClientJar)) {
String sep = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
classpath = forgeClientJar.toAbsolutePath().toString() + sep + classpath;
System.out.println(ZAnsi.green(" Added forge client jar: " + forgeClientJar.getFileName()));
} else {
System.out.println(ZAnsi.yellow(" Forge client jar not found at " + forgeClientJar));
}
// Add client jar to classpath (first position)
Path clientJar = findVersionJar();
if (clientJar != null && isValidJar(clientJar)) {
String sep = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
classpath = clientJar.toAbsolutePath().toString() + (classpath.isEmpty() ? "" : sep + classpath);
System.out.println(ZAnsi.green(" Added client jar: " + clientJar.getFileName()));
} else {
System.out.println(ZAnsi.yellow(" Client jar not found, falling back to vanilla classpath"));
classpath = buildVanillaClasspath();
}
command.add("-cp");
String cpFile = writeClasspathFile(classpath);
command.add(cpFile);
String cpFile = writeClasspathFile(classpath);
// Add JVM arguments from version.json manifest.
// Forge's -p is a HARDCODED list of 8 bootstrap JARs
// (bootstraplauncher, securejarhandler, asm-*, JarJarFileSystems).
// CRITICAL: Do NOT modify -DignoreList. The "forge-" pattern
// tells securejarhandler to SKIP forge-related jars (forge-*-client.jar,
// forge-auto-renaming-tool) and NOT create modules from them.
// Removing "forge-" causes ForgeAutoRenamingTool to export
// org.objectweb.asm.tree, splitting with the asm-tree module.
if (manifest != null) {
Map<String, String> vars = buildVariableMap(options);
vars.put("classpath", cpFile);
for (String arg : manifest.getJvmArguments()) {
String resolved = resolveVariable(arg, vars);
// Build variable map for placeholder substitution
Map<String, String> vars = buildVariableMap(options);
vars.put("classpath", cpFile);
// Parse ALL version.json JVM args (merged with parent) with placeholder
// substitution. This includes -Djava.library.path, -cp, --add-modules,
// --add-opens, etc. — everything the launcher needs.
List<String> allJvmArgs = manifest != null ? manifest.getAllJvmArguments() : new ArrayList<>();
if (!allJvmArgs.isEmpty()) {
for (String arg : allJvmArgs) {
String resolved = resolveVariable(arg, vars);
if (!resolved.isEmpty()) {
command.add(resolved);
}
// Filter out classpath entries already on module path
// to prevent any remaining split-package conflicts
String filteredCp = filterClasspathAgainstModulePath(classpath, manifest.getJvmArguments());
if (!filteredCp.equals(classpath)) {
command.set(command.indexOf("-cp") + 1, writeClasspathFile(filteredCp));
System.out.println(ZAnsi.green(" Filtered classpath against module path"));
}
System.out.println(ZAnsi.green(" Using " + allJvmArgs.size() + " JVM args from version.json"));
} else {
System.out.println(ZAnsi.yellow(" WARNING: No JVM args in version.json, using manual fallback"));
command.addAll(getJvmArguments(options));
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
command.add("-cp");
command.add(cpFile);
}
// Append memory/GC args (always after version.json args, like AstralRinth)
int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096;
command.add("-Xmx" + ramMB + "M");
command.add("-Xms" + Math.max(512, ramMB / 2) + "M");
command.add("-XX:+UseG1GC");
command.add("-XX:+UnlockExperimentalVMOptions");
command.add("-XX:G1NewSizePercent=20");
command.add("-XX:G1ReservePercent=20");
command.add("-XX:MaxGCPauseMillis=50");
command.add("-XX:G1HeapRegionSize=32M");
// Append custom user JVM args
if (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) {
command.addAll(options.getExtraJvmArgs());
}
// Main class from version.json
String mainClass = null;
if (manifest != null) {
mainClass = manifest.getMainClass();
}
if (mainClass == null || mainClass.isEmpty()) {
mainClass = "cpw.mods.bootstraplauncher.BootstrapLauncher";
System.out.println(ZAnsi.yellow(" Using fallback main class: " + mainClass));
} else {
System.out.println(ZAnsi.green(" Main class from manifest: " + mainClass));
}
command.add(mainClass);
// Parse ALL version.json game args (merged with parent) with placeholder
// substitution. This includes --launchTarget, --fml.forgeVersion,
// --fml.mcVersion, --version, --gameDir, --assetsDir, etc.
List<String> allGameArgs = manifest != null ? manifest.getAllGameArguments() : new ArrayList<>();
if (!allGameArgs.isEmpty()) {
for (String arg : allGameArgs) {
String resolved = resolveVariable(arg, vars);
if (!resolved.isEmpty()) {
command.add(resolved);
}
}
command.add(getVanillaMainClass());
System.out.println(ZAnsi.green(" Using " + allGameArgs.size() + " game args from version.json"));
} else {
System.out.println(ZAnsi.yellow(" WARNING: No game args in version.json, using manual fallback"));
command.addAll(getVanillaGameArguments(options));
command.addAll(getModloaderLaunchArgs());
} else {
System.out.println(ZAnsi.cyan(" Modloader detected (" + loaderType + "), using vanilla classpath"));
}
// Append width/height if custom resolution
if (options.getWidth() > 0) {
command.add("--width");
command.add(String.valueOf(options.getWidth()));
}
if (options.getHeight() > 0) {
command.add("--height");
command.add(String.valueOf(options.getHeight()));
}
} else {
// Fabric, vanilla, and other loaders: use manual JVM args
command.addAll(getJvmArguments(options));
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
if ("fabric".equals(loaderType)) {
System.out.println(ZAnsi.cyan(" Fabric: using vanilla classpath"));
command.add("-cp");
command.add(writeClasspathFile(buildVanillaClasspath()));
String mainClass = null;
@@ -143,31 +180,27 @@ public class LaunchCommandBuilder {
}
command.add(mainClass);
command.addAll(getVanillaGameArguments(options));
}
} else if (manifest != null) {
String classpath = buildClasspathFromManifest(manifest, true);
// Fallback if classpath is empty
if (classpath.isEmpty() || classpath.equals(instance.getPath().resolve("versions").resolve(getVersionId()).resolve(getVersionId() + ".jar").toAbsolutePath().toString())) {
System.out.println(ZAnsi.yellow(" manifest classpath empty, using vanilla classpath"));
} else if (manifest != null) {
String classpath = buildClasspathFromManifest(manifest, true);
if (classpath.isEmpty()) {
System.out.println(ZAnsi.yellow(" manifest classpath empty, using vanilla classpath"));
command.add("-cp");
command.add(writeClasspathFile(buildVanillaClasspath()));
command.add(getVanillaMainClass());
command.addAll(getVanillaGameArguments(options));
} else {
command.add("-cp");
command.add(writeClasspathFile(classpath));
String mainClass = resolveMainClass(manifest);
command.add(mainClass);
command.addAll(resolveGameArguments(manifest, options));
}
} else {
command.add("-cp");
command.add(writeClasspathFile(buildVanillaClasspath()));
command.add(getVanillaMainClass());
command.addAll(getVanillaGameArguments(options));
} else {
command.add("-cp");
command.add(writeClasspathFile(classpath));
String mainClass = resolveMainClass(manifest);
command.add(mainClass);
command.addAll(resolveGameArguments(manifest, options));
}
} else {
command.add("-cp");
command.add(writeClasspathFile(buildVanillaClasspath()));
command.add(getVanillaMainClass());
command.addAll(getVanillaGameArguments(options));
}
return command;
@@ -46,6 +46,24 @@ public class VersionManifest {
public List<String> getGameArguments() { return gameArguments; }
public List<Library> getLibraries() { return libraries; }
public List<String> getAllJvmArguments() {
List<String> all = new ArrayList<>();
if (parent != null) {
all.addAll(parent.getAllJvmArguments());
}
all.addAll(jvmArguments);
return all;
}
public List<String> getAllGameArguments() {
List<String> all = new ArrayList<>();
if (parent != null) {
all.addAll(parent.getAllGameArguments());
}
all.addAll(gameArguments);
return all;
}
public void resolveParent(Path versionsDir) {
if (inheritsFrom == null || parent != null) return;
Path parentDir = versionsDir.resolve(inheritsFrom);