diff --git a/launcher/bootstrap/pom.xml b/launcher/bootstrap/pom.xml
index 728a00f..b7f8108 100644
--- a/launcher/bootstrap/pom.xml
+++ b/launcher/bootstrap/pom.xml
@@ -7,7 +7,7 @@
me.sashegdev
ZernMCLauncher
- 1.0.10
+ 1.0.11
zernmc-bootstrap
diff --git a/launcher/launcher/pom.xml b/launcher/launcher/pom.xml
index 36f5f9b..cbe5118 100644
--- a/launcher/launcher/pom.xml
+++ b/launcher/launcher/pom.xml
@@ -7,7 +7,7 @@
me.sashegdev
ZernMCLauncher
- 1.0.10
+ 1.0.11
zernmclauncher
diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java
index 0fde470..3bc1f3c 100644
--- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java
+++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java
@@ -1,8 +1,7 @@
package me.sashegdev.zernmc.launcher.minecraft;
import me.sashegdev.zernmc.launcher.minecraft.installer.FabricInstaller;
-import me.sashegdev.zernmc.launcher.minecraft.installer.ForgeInstaller;
-import me.sashegdev.zernmc.launcher.minecraft.installer.NeoForgeInstaller;
+import me.sashegdev.zernmc.launcher.minecraft.installer.ModLoaderInstaller;
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
@@ -45,13 +44,13 @@ public class MinecraftLib {
}
public boolean installForge(String minecraftVersion, String forgeVersion) throws Exception {
- ForgeInstaller installer = new ForgeInstaller(instance);
- return installer.install(minecraftVersion, forgeVersion);
+ return new ModLoaderInstaller(instance)
+ .install(minecraftVersion, forgeVersion, ModLoaderInstaller.LoaderType.FORGE);
}
public boolean installNeoForge(String minecraftVersion, String neoforgeVersion) throws Exception {
- NeoForgeInstaller installer = new NeoForgeInstaller(instance);
- return installer.install(minecraftVersion, neoforgeVersion);
+ return new ModLoaderInstaller(instance)
+ .install(minecraftVersion, neoforgeVersion, ModLoaderInstaller.LoaderType.NEOFORGE);
}
public boolean installFabric(String minecraftVersion, String loaderVersion) throws Exception {
@@ -123,6 +122,15 @@ public class MinecraftLib {
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(instance.getPath().toFile());
+ // Write launch command to file for debugging
+ Path cmdLogFile = instance.getPath().resolve("launch-command.log");
+ try {
+ Files.writeString(cmdLogFile, String.join(" \\\n ", command));
+ System.out.println(ZAnsi.green(" Launch command written to " + cmdLogFile.toAbsolutePath()));
+ } catch (Exception e) {
+ System.out.println(ZAnsi.yellow(" Failed to write launch command log: " + e.getMessage()));
+ }
+
System.out.println(ZAnsi.brightGreen("\nStarting Minecraft...\n"));
ConsoleUtils.clearScreen();
diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ModLoaderInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ModLoaderInstaller.java
new file mode 100644
index 0000000..fb61490
--- /dev/null
+++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ModLoaderInstaller.java
@@ -0,0 +1,319 @@
+package me.sashegdev.zernmc.launcher.minecraft.installer;
+
+import me.sashegdev.zernmc.launcher.minecraft.Instance;
+import me.sashegdev.zernmc.launcher.utils.ProgressBar;
+import me.sashegdev.zernmc.launcher.utils.ZAnsi;
+
+import java.io.*;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.time.Duration;
+import java.util.*;
+
+public class ModLoaderInstaller {
+
+ public enum LoaderType {
+ FORGE("forge", "net.minecraftforge", "forge", "https://maven.minecraftforge.net"),
+ NEOFORGE("neoforge", "net.neoforged", null, "https://maven.neoforged.net/releases");
+
+ final String loaderName;
+ final String mavenGroup;
+ final String fixedArtifact;
+ final String mavenBase;
+
+ LoaderType(String loaderName, String mavenGroup, String fixedArtifact, String mavenBase) {
+ this.loaderName = loaderName;
+ this.mavenGroup = mavenGroup;
+ this.fixedArtifact = fixedArtifact;
+ this.mavenBase = mavenBase;
+ }
+
+ public String artifact(String mcVersion) {
+ if (this == NEOFORGE && !"1.20.1".equals(mcVersion)) return "neoforge";
+ return "forge";
+ }
+ }
+
+ private final Instance instance;
+ private final HttpClient httpClient = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(30))
+ .build();
+
+ public ModLoaderInstaller(Instance instance) {
+ this.instance = instance;
+ }
+
+ public boolean install(String mcVersion, String loaderVersion, LoaderType type) throws Exception {
+ System.out.println(ZAnsi.cyan("Installing " + type.loaderName + " " + loaderVersion + " for Minecraft " + mcVersion));
+
+ System.out.println(ZAnsi.cyan("Installing base Minecraft version " + mcVersion + "..."));
+ VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
+ String assetIndex = vanillaInstaller.install(mcVersion);
+
+ if (assetIndex == null || assetIndex.isEmpty()) {
+ System.out.println(ZAnsi.brightRed("Failed to install base Minecraft version"));
+ return false;
+ }
+
+ instance.setAssetIndex(assetIndex);
+ createLauncherProfile();
+
+ String installerUrl = buildInstallerUrl(mcVersion, loaderVersion, type);
+ String jarName = type.loaderName + "-installer.jar";
+ Path installerJar = instance.getPath().resolve(jarName);
+
+ System.out.println(ZAnsi.cyan("Downloading " + type.loaderName + " Installer..."));
+ downloadFileWithProgress(installerUrl, installerJar, type);
+
+ System.out.println(ZAnsi.cyan("Running " + type.loaderName + " Installer..."));
+ System.out.println(ZAnsi.yellow("This may take a few minutes. Please wait...\n"));
+
+ boolean success = runInstaller(installerJar, type);
+
+ if (success) {
+ try {
+ downloadMissingLibraries(type);
+ } catch (Exception e) {
+ System.out.println(ZAnsi.yellow("Warning: could not download some libraries: " + e.getMessage()));
+ }
+
+ System.out.println(ZAnsi.brightGreen("\n" + type.loaderName + " " + loaderVersion + " installed successfully!"));
+ instance.setMinecraftVersion(mcVersion);
+ instance.setLoaderType(type.loaderName);
+ instance.setLoaderVersion(loaderVersion);
+
+ Files.deleteIfExists(installerJar);
+ return true;
+ } else {
+ System.out.println(ZAnsi.brightRed("\nError installing " + type.loaderName + "!"));
+ return false;
+ }
+ }
+
+ private String buildInstallerUrl(String mcVersion, String loaderVersion, LoaderType type) {
+ if (type == LoaderType.FORGE) {
+ return type.mavenBase + "/" + type.mavenGroup.replace('.', '/')
+ + "/" + type.fixedArtifact + "/" + mcVersion + "-" + loaderVersion
+ + "/" + type.fixedArtifact + "-" + mcVersion + "-" + loaderVersion + "-installer.jar";
+ }
+
+ String artifact = type.artifact(mcVersion);
+ return type.mavenBase + "/" + type.mavenGroup.replace('.', '/')
+ + "/" + artifact + "/" + loaderVersion
+ + "/" + artifact + "-" + loaderVersion + "-installer.jar";
+ }
+
+ private void createLauncherProfile() throws IOException {
+ Path profilePath = instance.getPath().resolve("launcher_profiles.json");
+ if (Files.exists(profilePath)) return;
+
+ String minimalProfile = """
+ {
+ "profiles": {},
+ "selectedProfile": "Default"
+ }
+ """;
+ Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
+ System.out.println(ZAnsi.yellow("Created launcher_profiles.json"));
+ }
+
+ private void downloadFileWithProgress(String url, Path target, LoaderType type) throws Exception {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .GET()
+ .build();
+
+ HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
+
+ if (response.statusCode() != 200) {
+ throw new IOException("HTTP " + response.statusCode());
+ }
+
+ long contentLength = response.headers().firstValueAsLong("Content-Length").orElse(-1);
+
+ try (InputStream in = response.body();
+ FileOutputStream out = new FileOutputStream(target.toFile())) {
+
+ byte[] buffer = new byte[8192];
+ int bytesRead;
+ long totalRead = 0;
+ int lastPercent = -1;
+
+ while ((bytesRead = in.read(buffer)) != -1) {
+ out.write(buffer, 0, bytesRead);
+ totalRead += bytesRead;
+
+ if (contentLength > 0) {
+ int percent = (int) ((totalRead * 100) / contentLength);
+ if (percent != lastPercent) {
+ ProgressBar.show(type.loaderName + " Installer", percent, 100,
+ "% (" + ProgressBar.formatBytes(totalRead) + "/" + ProgressBar.formatBytes(contentLength) + ")");
+ lastPercent = percent;
+ }
+ } else {
+ char[] spinner = {'|', '/', '-', '\\'};
+ int idx = (int) (totalRead / 1024) % 4;
+ System.out.print("\rDownloading " + type.loaderName + " Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]);
+ }
+ }
+ }
+
+ ProgressBar.finish(type.loaderName + " Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")");
+ }
+
+ private boolean runInstaller(Path installerJar, LoaderType type) throws IOException, InterruptedException {
+ int maxRetries = 3;
+ int attempt = 1;
+
+ while (attempt <= maxRetries) {
+ System.out.println(ZAnsi.cyan("Attempt " + attempt + " of " + maxRetries));
+
+ ProcessBuilder pb = new ProcessBuilder(
+ "java",
+ "-jar",
+ installerJar.toAbsolutePath().toString(),
+ "--installClient",
+ instance.getPath().toAbsolutePath().toString()
+ );
+
+ pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000");
+ pb.directory(instance.getPath().toFile());
+ pb.redirectErrorStream(true);
+
+ Process process = pb.start();
+
+ StringBuilder output = new StringBuilder();
+ boolean hasErrors = false;
+
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ output.append(line).append("\n");
+
+ if (line.contains("Downloading") || line.contains("Extracting")) {
+ System.out.println(ZAnsi.blue(" -> " + line));
+ } else if (line.contains("SUCCESS") || line.contains("successfully")) {
+ System.out.println(ZAnsi.brightGreen(" + " + line));
+ } else if (line.contains("WARNING") || line.contains("warning")) {
+ System.out.println(ZAnsi.yellow(" ! " + line));
+ } else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) {
+ System.out.println(ZAnsi.brightRed(" X " + line));
+ if (line.contains("timed out") || line.contains("failed to download")) {
+ hasErrors = true;
+ }
+ } else if (!line.isBlank()) {
+ System.out.println(" " + line);
+ }
+ }
+ }
+
+ int exitCode = process.waitFor();
+
+ if (exitCode == 0 && !hasErrors) {
+ return true;
+ }
+
+ if (attempt < maxRetries) {
+ System.out.println(ZAnsi.yellow("Install error. Retrying in 5 seconds..."));
+ Thread.sleep(5000);
+
+ Path librariesDir = instance.getPath().resolve("libraries");
+ if (Files.exists(librariesDir)) {
+ try (var stream = Files.walk(librariesDir)) {
+ stream.filter(p -> p.toString().contains("asm") && p.toString().endsWith(".jar"))
+ .forEach(p -> {
+ try { Files.deleteIfExists(p); }
+ catch (IOException e) { /* ignore */ }
+ });
+ }
+ }
+ } else {
+ System.out.println(ZAnsi.brightRed(type.loaderName + " Installer exited with error code: " + exitCode));
+
+ if (output.toString().contains("timed out")) {
+ System.out.println(ZAnsi.yellow("\nPossible solutions:"));
+ System.out.println(ZAnsi.yellow("1. Check your internet connection"));
+ System.out.println(ZAnsi.yellow("2. Run the launcher as administrator"));
+ System.out.println(ZAnsi.yellow("3. Temporarily disable antivirus/firewall"));
+ System.out.println(ZAnsi.yellow("4. Try installing a different version"));
+ }
+ }
+
+ attempt++;
+ }
+
+ return false;
+ }
+
+ private void downloadMissingLibraries(LoaderType type) throws Exception {
+ System.out.println(ZAnsi.cyan("Checking and downloading missing libraries..."));
+
+ Map> alternativeUrls = new LinkedHashMap<>();
+ alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar", List.of(
+ "https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar",
+ "https://mirrors.huaweicloud.com/repository/maven/org/ow2/asm/asm/9.6/asm-9.6.jar"
+ ));
+
+ if (type == LoaderType.NEOFORGE) {
+ alternativeUrls.put("org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar", List.of(
+ "https://repo1.maven.org/maven2/org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar"
+ ));
+ alternativeUrls.put("org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar", List.of(
+ "https://repo1.maven.org/maven2/org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar"
+ ));
+ }
+
+ Path librariesDir = instance.getPath().resolve("libraries");
+
+ for (Map.Entry> entry : alternativeUrls.entrySet()) {
+ Path target = librariesDir.resolve(entry.getKey());
+ if (!Files.exists(target)) {
+ Files.createDirectories(target.getParent());
+ System.out.println(ZAnsi.yellow("Downloading: " + target.getFileName()));
+
+ boolean downloaded = false;
+ for (String mirrorUrl : entry.getValue()) {
+ for (int attempt = 1; attempt <= 3; attempt++) {
+ try {
+ downloadDirect(mirrorUrl, target);
+ downloaded = true;
+ break;
+ } catch (Exception e) {
+ if (attempt == 3 && mirrorUrl.equals(entry.getValue().get(entry.getValue().size() - 1))) throw e;
+ System.out.println(ZAnsi.yellow("Retry " + attempt + "/3..."));
+ Thread.sleep(2000);
+ }
+ }
+ if (downloaded) break;
+ }
+ }
+ }
+ }
+
+ private void downloadDirect(String url, Path target) throws Exception {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .GET()
+ .build();
+
+ HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
+
+ if (response.statusCode() != 200) {
+ throw new IOException("HTTP " + response.statusCode());
+ }
+
+ try (InputStream in = response.body();
+ FileOutputStream out = new FileOutputStream(target.toFile())) {
+ byte[] buffer = new byte[8192];
+ int bytesRead;
+ while ((bytesRead = in.read(buffer)) != -1) {
+ out.write(buffer, 0, bytesRead);
+ }
+ }
+ }
+}
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 4326675..a1cb42d 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
@@ -85,23 +85,14 @@ public class LaunchCommandBuilder {
String cpFile = writeClasspathFile(classpath);
- // DEBUG: print classpath entries to identify split-package sources
- String sep = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
- System.out.println(ZAnsi.cyan(" === CLASSPATH ENTRIES (" + classpath.split(sep.replace("\\","\\\\")).length + " jars) ==="));
- for (String entry : classpath.split(sep.replace("\\","\\\\"))) {
- String fileName = entry.contains("/") ? entry.substring(entry.lastIndexOf('/') + 1) : entry.substring(entry.lastIndexOf('\\') + 1);
- System.out.println(ZAnsi.cyan(" CP: " + fileName));
- }
- System.out.println(ZAnsi.cyan(" === END CLASSPATH ==="));
-
// Build variable map for placeholder substitution
Map 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 allJvmArgs = manifest != null ? manifest.getAllJvmArguments() : new ArrayList<>();
+ // Parse version.json JVM args (child only). Forge/NeoForge defines its own
+ // -p, --add-modules, --add-opens. -Djava.library.path is NOT in child args,
+ // so it's added manually below.
+ List allJvmArgs = manifest != null ? manifest.getJvmArguments() : new ArrayList<>();
if (!allJvmArgs.isEmpty()) {
for (String arg : allJvmArgs) {
String resolved = resolveVariable(arg, vars);
@@ -113,11 +104,11 @@ public class LaunchCommandBuilder {
} 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);
}
+ // Forge/NeoForge child version.json doesn't include -Djava.library.path
+ 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;
command.add("-Xmx" + ramMB + "M");
@@ -147,9 +138,9 @@ public class LaunchCommandBuilder {
}
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.
+ // Parse version.json game args (merged with parent) with placeholder
+ // substitution. Parent provides --username, --version, etc.;
+ // child provides --launchTarget, --fml.*.
List allGameArgs = manifest != null ? manifest.getAllGameArguments() : new ArrayList<>();
if (!allGameArgs.isEmpty()) {
for (String arg : allGameArgs) {
diff --git a/launcher/launcher/src/resources/ui/index.html b/launcher/launcher/src/resources/ui/index.html
index bce8137..2dd3787 100644
--- a/launcher/launcher/src/resources/ui/index.html
+++ b/launcher/launcher/src/resources/ui/index.html
@@ -27,7 +27,7 @@
ZernMC
- Launcher 1.0.9
+ Launcher 1.0.11