diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java index dacbd91..8b81b84 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java @@ -70,43 +70,36 @@ public class LaunchCommandBuilder { List allJvmArgs = manifest != null ? manifest.getJvmArguments() : new ArrayList<>(); List allGameArgs = manifest != null ? manifest.getAllGameArguments() : new ArrayList<>(); - // Build variable map for placeholder substitution (${classpath}, - // ${natives_directory}, ${library_directory}, ${version_name}, ...) + // Build variable map for placeholder substitution (${library_directory}, + // ${classpath_separator}, ${version_name}, ${natives_directory}, ...) Map vars = buildVariableMap(options); - // Forge/NeoForge version.json defines its own -p / --module-path / - // --add-modules / --add-opens and references ${classpath}. We substitute - // ${classpath} once (only when referenced) and let the version.json - // arguments drive the classpath/module path exactly as AstralRinth and the - // 1.0.13 launcher did. Do NOT inject a separate -DlegacyClassPath: - // modern Forge's BootstrapLauncher derives the MC-BOOTSTRAP layer from the - // module path, and a redundant -DlegacyClassPath duplicates every library - // and can trigger split-package / duplicate-entry failures. - boolean needsClasspath = referencesClasspath(allJvmArgs) || referencesClasspath(allGameArgs); - if (needsClasspath) { - String classpath; - if (manifest != null) { - classpath = buildClasspathFromManifest(manifest, false); - } else { - classpath = ""; - } - - 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(); - } - - vars.put("classpath", writeClasspathFile(classpath)); - System.out.println(ZAnsi.green(" ${classpath} referenced, classpath built for substitution")); + // Real Forge/NeoForge version.json (1.17+ / 47.x) declares a fixed -p + // module path of bootstrap jars and references ${library_directory} / + // ${classpath_separator} / ${version_name} — but it does NOT reference + // ${classpath} and does NOT contain -cp. To let the JVM find + // cpw.mods.bootstraplauncher.BootstrapLauncher we must supply -cp + // ourselves (version jar + all libraries, exactly like the official + // launcher). If a version.json DOES reference ${classpath}, we substitute + // the same full classpath into it first. + String fullClasspath; + if (manifest != null) { + fullClasspath = buildClasspathFromManifest(manifest, true); } else { - System.out.println(ZAnsi.cyan(" ${classpath} not referenced by version.json, skipping classpath build")); + fullClasspath = buildVanillaClasspath(); } + boolean referencesClasspath = referencesClasspath(allJvmArgs) || referencesClasspath(allGameArgs); + if (referencesClasspath) { + vars.put("classpath", writeClasspathFile(fullClasspath)); + System.out.println(ZAnsi.green(" ${classpath} referenced, substituted full classpath")); + } else { + System.out.println(ZAnsi.cyan(" ${classpath} not referenced by version.json, not substituting")); + } + + boolean hasCpInJson = containsCpArg(allJvmArgs); + boolean hasLibraryPathInJson = containsLibraryPathArg(allJvmArgs); + if (!allJvmArgs.isEmpty()) { for (String arg : allJvmArgs) { String resolved = resolveVariable(arg, vars); @@ -120,8 +113,11 @@ public class LaunchCommandBuilder { command.addAll(getJvmArguments(options)); } - // Forge/NeoForge child version.json doesn't include -Djava.library.path - command.add("-Djava.library.path=" + nativesDir.toAbsolutePath()); + // Forge/NeoForge child version.json usually doesn't include + // -Djava.library.path (47.3.0 doesn't) — add it unless already present. + if (!hasLibraryPathInJson) { + command.add("-Djava.library.path=" + nativesDir.toAbsolutePath()); + } // Append memory/GC args (always after version.json args, like AstralRinth) int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096; @@ -139,6 +135,15 @@ public class LaunchCommandBuilder { command.addAll(options.getExtraJvmArgs()); } + // Real Forge/NeoForge version.json has no -cp, so the JVM would not find the + // main class. Add -cp with version jar + all libraries (official-launcher + // behaviour); skip only if the version.json itself declares -cp. + if (!hasCpInJson) { + command.add("-cp"); + command.add(writeClasspathFile(fullClasspath)); + System.out.println(ZAnsi.green(" Added -cp classpath for main class lookup")); + } + // Main class from version.json String mainClass = null; if (manifest != null) { @@ -564,6 +569,22 @@ public class LaunchCommandBuilder { return false; } + private boolean containsCpArg(List args) { + if (args == null) return false; + for (String arg : args) { + if (arg != null && (arg.equals("-cp") || arg.equals("-classpath"))) return true; + } + return false; + } + + private boolean containsLibraryPathArg(List args) { + if (args == null) return false; + for (String arg : args) { + if (arg != null && arg.startsWith("-Djava.library.path=")) return true; + } + return false; + } + private String buildClasspathFromManifest(VersionManifest manifest, boolean includeVersionJar) throws Exception { List paths = new ArrayList<>(); Path librariesDir = instance.getPath().resolve("libraries"); diff --git a/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilderTest.java b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilderTest.java index b039f23..561c98d 100644 --- a/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilderTest.java +++ b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilderTest.java @@ -75,20 +75,36 @@ class LaunchCommandBuilderTest { createJarWithModule(dir.resolve("versions/1.20.1/1.20.1.jar"), "minecraft"); createJar(dir.resolve("libraries/net/minecraft/client/1.20.1/client-1.20.1.jar")); - // Forge child + // Forge child — real 1.20.1-47.3.0 format: -p is a fixed bootstrap list, + // NO ${classpath} and NO -cp (the launcher must add -cp itself). String forgeJson = """ { "id": "1.20.1-forge-47.3.0", "inheritsFrom": "1.20.1", "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", "arguments": { - "jvm": ["-p", "${classpath}", "--add-modules=ALL-MODULE-PATH", "--add-opens=java.base/java.util.jar=ALL-UNNAMED", "--add-opens=java.base/java.lang.invoke=ALL-UNNAMED"], - "game": ["--launchTarget", "forgeclient", "--fml.forgeVersion", "47.3.0", "--fml.mcVersion", "1.20.1", "--fml.forgeGroup", "net.minecraftforge"] + "jvm": ["-Djava.net.preferIPv6Addresses=system", + "-DignoreList=bootstraplauncher,securejarhandler,asm-commons,asm-util,asm-analysis,asm-tree,asm,JarJarFileSystems,client-extra,fmlcore,javafmllanguage,lowcodelanguage,mclanguage,forge-,${version_name}.jar", + "-DmergeModules=jna-5.10.0.jar,jna-platform-5.10.0.jar", + "-DlibraryDirectory=${library_directory}", + "-p", "${library_directory}/cpw/mods/bootstraplauncher/1.1.2/bootstraplauncher-1.1.2.jar${classpath_separator}${library_directory}/cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar", + "--add-modules", "ALL-MODULE-PATH", + "--add-opens", "java.base/java.util.jar=cpw.mods.securejarhandler", + "--add-opens", "java.base/java.lang.invoke=cpw.mods.securejarhandler"], + "game": ["--launchTarget", "forgeclient", "--fml.forgeVersion", "47.3.0", + "--fml.mcVersion", "1.20.1", "--fml.forgeGroup", "net.minecraftforge", + "--fml.mcpVersion", "20230612.114412"] }, - "libraries": [{"name": "net.minecraftforge:forge:1.20.1-47.3.0", "downloads": {"artifact": {"path": "net/minecraftforge/forge/1.20.1-47.3.0/forge-1.20.1-47.3.0.jar"}}}] + "libraries": [ + {"name": "cpw.mods:bootstraplauncher:1.1.2", "downloads": {"artifact": {"path": "cpw/mods/bootstraplauncher/1.1.2/bootstraplauncher-1.1.2.jar"}}}, + {"name": "cpw.mods:securejarhandler:2.1.10", "downloads": {"artifact": {"path": "cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar"}}}, + {"name": "net.minecraftforge:forge:1.20.1-47.3.0", "downloads": {"artifact": {"path": "net/minecraftforge/forge/1.20.1-47.3.0/forge-1.20.1-47.3.0.jar"}}} + ] } """; writeVersionJson(dir.resolve("versions/" + versionId + "/" + versionId + ".json"), forgeJson); + createJar(dir.resolve("libraries/cpw/mods/bootstraplauncher/1.1.2/bootstraplauncher-1.1.2.jar")); + createJar(dir.resolve("libraries/cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar")); createJar(dir.resolve("libraries/net/minecraftforge/forge/1.20.1-47.3.0/forge-1.20.1-47.3.0.jar")); Files.createDirectories(dir.resolve("natives")); @@ -215,21 +231,35 @@ class LaunchCommandBuilderTest { List command = builder.build(options); - // Parent's -cp must NOT appear (causes split-package with child's -p) - assertFalse(command.contains("-cp"), "Forge must not have -cp from parent"); + // Real Forge version.json has no -cp (and no ${classpath}), so the launcher MUST add + // -cp with version jar + all libraries for the JVM to find BootstrapLauncher. + int cpIdx = command.indexOf("-cp"); + assertTrue(cpIdx >= 0, "Launcher must add -cp for Forge (version.json has none)"); + assertTrue(cpIdx + 1 < command.size(), "-cp must have a value"); + String classpath = command.get(cpIdx + 1); + assertNotNull(classpath, "-cp value must not be null"); // -Djava.library.path must be added manually (not in child args) String expectedLibPath = "-Djava.library.path=" + tempDir.resolve("natives").toAbsolutePath(); assertTrue(command.contains(expectedLibPath), "Forge must have -Djava.library.path"); - // Child's -p (module path) must be present + // Child's -p (module path) must be the fixed bootstrap list passed through unchanged int pIndex = command.indexOf("-p"); assertTrue(pIndex >= 0, "Forge must have -p"); assertTrue(pIndex + 1 < command.size(), "-p must have a value"); - assertNotNull(command.get(pIndex + 1), "-p value must not be null"); + String modulePath = command.get(pIndex + 1); + assertNotNull(modulePath, "-p value must not be null"); + assertTrue(modulePath.contains("bootstraplauncher-1.1.2.jar"), "-p must contain bootstrap jar"); + assertTrue(modulePath.contains("securejarhandler-2.1.10.jar"), "-p must contain bootstrap jar"); - // Child's --add-modules must be present - assertTrue(command.contains("--add-modules=ALL-MODULE-PATH"), "Forge must have --add-modules=ALL-MODULE-PATH"); + // ${classpath} must NOT be substituted / referenced in the real-json JVM args + assertFalse(classpath.contains("${classpath}"), "-cp must not contain unresolved ${classpath} placeholder"); + + // Child's --add-modules must be present (two-arg format) + int amIdx = command.indexOf("--add-modules"); + assertTrue(amIdx >= 0, "Forge must have --add-modules"); + assertTrue(amIdx + 1 < command.size(), "--add-modules must have a value"); + assertEquals("ALL-MODULE-PATH", command.get(amIdx + 1), "--add-modules must be ALL-MODULE-PATH"); // Memory/GC args assertTrue(command.contains("-Xmx4096M"), "Forge must have memory args"); @@ -255,9 +285,10 @@ class LaunchCommandBuilderTest { List command = builder.build(options); - // Find -p's classpath value - int pIdx = command.indexOf("-p"); - String cpValue = command.get(pIdx + 1); + // Find -cp's classpath value (launcher-added; version.json has no -cp) + int cpIdx = command.indexOf("-cp"); + assertTrue(cpIdx >= 0, "Launcher must add -cp for Forge"); + String cpValue = command.get(cpIdx + 1); // Version jar from ensureVersionJarForForge assertTrue(cpValue.contains("1.20.1-forge-47.3.0.jar") || cpValue.contains("1.20.1.jar"), @@ -400,8 +431,9 @@ class LaunchCommandBuilderTest { List command = builder.build(options); - // Same checks as Forge - assertFalse(command.contains("-cp"), "NeoForge must not have -cp from parent"); + // NeoForge child jvm args reference ${classpath} (substituted into -p); version.json + // declares no -cp, so the launcher adds -cp with version jar + all libraries. + assertTrue(command.contains("-cp"), "NeoForge must have -cp (added by launcher)"); String expectedLibPath = "-Djava.library.path=" + tempDir.resolve("natives").toAbsolutePath(); assertTrue(command.contains(expectedLibPath), "NeoForge must have -Djava.library.path"); assertTrue(command.contains("-p"), "NeoForge must have -p"); @@ -494,27 +526,28 @@ class LaunchCommandBuilderTest { String modulePath = command.get(pIdx + 1); // Extract --add-modules, -Djava.library.path - String addModules = null; String libPath = null; for (String arg : command) { if (arg.startsWith("-Djava.library.path=")) { libPath = arg; } } + String addModules = null; for (int i = 0; i < command.size(); i++) { - if ("--add-modules=ALL-MODULE-PATH".equals(command.get(i))) { - addModules = command.get(i); + if ("--add-modules".equals(command.get(i)) && i + 1 < command.size()) { + addModules = command.get(i + 1); } } - assertNotNull(addModules, "Must have --add-modules=ALL-MODULE-PATH"); + assertNotNull(addModules, "Must have --add-modules"); + assertEquals("ALL-MODULE-PATH", addModules, "--add-modules must be ALL-MODULE-PATH"); // Build minimal Java command that validates the module path - // java -Djava.library.path= -p --add-modules=ALL-MODULE-PATH -version + // java -Djava.library.path= -p --add-modules ALL-MODULE-PATH -version ProcessBuilder pb = new ProcessBuilder( "java", libPath, "-p", modulePath, - "--add-modules=ALL-MODULE-PATH", + "--add-modules", addModules, "-version" ); pb.redirectErrorStream(true); diff --git a/launcher/pom.xml b/launcher/pom.xml index ec86eea..25b0334 100644 --- a/launcher/pom.xml +++ b/launcher/pom.xml @@ -19,7 +19,7 @@ 1.0.15 - 0 + 1 21 21 UTF-8 diff --git a/server/main.py b/server/main.py index 9f132fc..283a8ae 100644 --- a/server/main.py +++ b/server/main.py @@ -1377,6 +1377,37 @@ def generate_launcher_builds_meta(): logger.warning(f"Failed to save meta.json: {e}") +def generate_version_meta(version_path: Path, version: str) -> dict: + """Generate and cache meta.json for an extracted version directory.""" + files = [] + for file_path in version_path.rglob("*"): + if file_path.is_file() and file_path.name != "meta.json": + rel_path = str(file_path.relative_to(version_path)) + stat = file_path.stat() + file_hash = calculate_file_hash(file_path) + files.append({ + "path": rel_path, + "size": stat.st_size, + "hash": f"sha256:{file_hash}" + }) + + meta = { + "version": version, + "type": "new", + "release_date": datetime.utcnow().isoformat(), + "files": files + } + + try: + meta_path = version_path / "meta.json" + with open(meta_path, 'w', encoding='utf-8') as f: + json.dump(meta, f, indent=2) + except Exception as e: + logger.warning(f"Failed to save launcher meta for {version}: {e}") + + return meta + + def scan_launcher_version(version: str) -> Optional[dict]: """Scan a launcher version directory and return meta""" # First check if meta exists in builds/ directly (for new format) @@ -1407,33 +1438,7 @@ def scan_launcher_version(version: str) -> Optional[dict]: pass # Generate meta - files = [] - for file_path in version_path.rglob("*"): - if file_path.is_file() and file_path.name != "meta.json": - rel_path = str(file_path.relative_to(version_path)) - stat = file_path.stat() - file_hash = calculate_file_hash(file_path) - files.append({ - "path": rel_path, - "size": stat.st_size, - "hash": f"sha256:{file_hash}" - }) - - meta = { - "version": version, - "type": "new", - "release_date": datetime.utcnow().isoformat(), - "files": files - } - - # Save meta - try: - with open(meta_path, 'w', encoding='utf-8') as f: - json.dump(meta, f, indent=2) - except Exception as e: - logger.warning(f"Failed to save launcher meta for {version}: {e}") - - return meta + return generate_version_meta(version_path, version) def parse_version_key(v: str) -> tuple: @@ -1492,6 +1497,12 @@ def extract_new_format_versions(): # Extract all files zf.extractall(extract_dir) + # Generate meta.json cache for the extracted version. Without this the + # "already extracted" check above always fails: scan_launcher_version() + # short-circuits on BUILDS_DIR/meta.json for the latest version and never + # writes versions//meta.json, so every scan would re-extract the zip. + generate_version_meta(extract_dir, version) + logger.info(f"Extracted {zip_file.name} successfully") except Exception as e: logger.error(f"Failed to extract {zip_file.name}: {e}")