using System; using System.IO; using System.Linq; using System.Net.Http; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using CmlLib.Core; using CmlLib.Core.Auth; using CmlLib.Core.Installers; using CmlLib.Core.ProcessBuilder; using CmlLib.Core.Installer.Forge; using CmlLib.Core.Installer.NeoForge; using ModpackLauncher.Models; namespace ModpackLauncher.Services; public sealed class LaunchService { private readonly MinecraftLauncher _launcher; private readonly ForgeInstaller _forgeInstaller; private readonly NeoForgeInstaller _neoForgeInstaller; private readonly string _installDir; public LaunchService(string installDir) { _installDir = installDir; var path = new MinecraftPath(installDir); _launcher = new MinecraftLauncher(path); _forgeInstaller = new ForgeInstaller(_launcher); _neoForgeInstaller = new NeoForgeInstaller(_launcher); } public MinecraftLauncher Launcher => _launcher; /// /// Belt-and-braces check after the loader installer runs: parse the version JSON /// and download any libraries that are listed but missing on disk. Works around /// a known CmlLib quirk where libraries with @jar suffix in the Maven coordinate /// (e.g. "cpw.mods:bootstraplauncher:2.0.2@jar" used by NeoForge 21.1.x) get /// skipped by the installer's library downloader. /// private async Task VerifyVersionLibrariesAsync(string versionId, IProgress progress, CancellationToken ct) { var versionJsonPath = Path.Combine(_installDir, "versions", versionId, $"{versionId}.json"); if (!File.Exists(versionJsonPath)) return; using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(versionJsonPath, ct).ConfigureAwait(false)); if (!doc.RootElement.TryGetProperty("libraries", out var libsArr)) return; var libsDir = Path.Combine(_installDir, "libraries"); var missing = new System.Collections.Generic.List<(string Path, string Url, string? Sha1)>(); foreach (var lib in libsArr.EnumerateArray()) { if (!lib.TryGetProperty("downloads", out var dls)) continue; if (!dls.TryGetProperty("artifact", out var art)) continue; if (!art.TryGetProperty("path", out var pPath) || !art.TryGetProperty("url", out var pUrl)) continue; var relPath = pPath.GetString(); var url = pUrl.GetString(); var sha1 = art.TryGetProperty("sha1", out var pSha1) ? pSha1.GetString() : null; if (string.IsNullOrEmpty(relPath) || string.IsNullOrEmpty(url)) continue; var fullPath = Path.Combine(libsDir, relPath.Replace('/', Path.DirectorySeparatorChar)); if (!File.Exists(fullPath)) { missing.Add((fullPath, url, sha1)); } } if (missing.Count == 0) return; progress.Report(new ProgressReport( ProgressKind.Status, $"Verifying loader libraries: {missing.Count} missing, fetching...")); using var http = new HttpClient(); http.Timeout = TimeSpan.FromMinutes(5); for (int i = 0; i < missing.Count; i++) { ct.ThrowIfCancellationRequested(); var (path, url, _) = missing[i]; progress.Report(new ProgressReport( ProgressKind.Log, $" Library {i + 1}/{missing.Count}: {Path.GetFileName(path)}")); Directory.CreateDirectory(Path.GetDirectoryName(path)!); var bytes = await http.GetByteArrayAsync(url, ct).ConfigureAwait(false); await File.WriteAllBytesAsync(path, bytes, ct).ConfigureAwait(false); } progress.Report(new ProgressReport( ProgressKind.Status, "Loader libraries verified.")); } public async Task InstallVersionAsync( Manifest manifest, IProgress progress, CancellationToken ct) { EventHandler fileProgressHandler = (_, args) => { var pct = args.TotalTasks > 0 ? (args.ProgressedTasks * 100.0 / args.TotalTasks) : -1; progress.Report(new ProgressReport( ProgressKind.Progress, $"{args.EventType}: {args.Name ?? ""}", Current: args.ProgressedTasks, Total: args.TotalTasks, Percent: pct )); }; EventHandler byteProgressHandler = (_, args) => { if (args.TotalBytes <= 0) return; var pct = args.ToRatio() * 100.0; progress.Report(new ProgressReport( ProgressKind.Progress, $"{args.ProgressedBytes:N0} / {args.TotalBytes:N0} bytes", Current: (int)Math.Min(args.ProgressedBytes, int.MaxValue), Total: (int)Math.Min(args.TotalBytes, int.MaxValue), Percent: pct )); }; _launcher.FileProgressChanged += fileProgressHandler; _launcher.ByteProgressChanged += byteProgressHandler; try { var mcVersion = manifest.Minecraft.Version; var loader = manifest.Loader; progress.Report(new ProgressReport(ProgressKind.Status, $"Installing Minecraft {mcVersion}...")); await _launcher.InstallAsync(mcVersion, ct).AsTask().ConfigureAwait(false); if (loader == null || string.IsNullOrEmpty(loader.Type) || loader.Type.Equals("vanilla", StringComparison.OrdinalIgnoreCase)) { return mcVersion; } if (loader.Type.Equals("forge", StringComparison.OrdinalIgnoreCase)) { progress.Report(new ProgressReport( ProgressKind.Status, $"Installing Forge {loader.Version} for {mcVersion}..." )); var fid = await _forgeInstaller.Install(mcVersion, loader.Version).ConfigureAwait(false); await VerifyVersionLibrariesAsync(fid, progress, ct).ConfigureAwait(false); return fid; } if (loader.Type.Equals("neoforge", StringComparison.OrdinalIgnoreCase)) { progress.Report(new ProgressReport( ProgressKind.Status, $"Installing NeoForge {loader.Version} for {mcVersion}..." )); var nid = await _neoForgeInstaller.Install(mcVersion, loader.Version).ConfigureAwait(false); await VerifyVersionLibrariesAsync(nid, progress, ct).ConfigureAwait(false); return nid; } throw new NotSupportedException( $"Loader '{loader.Type}' is not yet supported. Use 'forge', 'neoforge', or 'vanilla'." ); } finally { _launcher.FileProgressChanged -= fileProgressHandler; _launcher.ByteProgressChanged -= byteProgressHandler; } } public async Task LaunchAsync( string versionId, MSession session, int minMemoryMB, int maxMemoryMB, IProgress progress, CancellationToken ct) { var option = new MLaunchOption { Session = session, MaximumRamMb = maxMemoryMB, MinimumRamMb = minMemoryMB, // Generational ZGC: low-pause concurrent collector -- recommended by // Distant Horizons (and broadly better than the default G1 for modded MC). // Requires Java 21+ which CmlLib auto-installs for MC 1.21.1. ExtraJvmArguments = new[] { new MArgument("-XX:+UseZGC"), new MArgument("-XX:+ZGenerational"), } }; progress.Report(new ProgressReport(ProgressKind.Status, "Building launch process...")); var process = await _launcher.BuildProcessAsync(versionId, option).ConfigureAwait(false); var wrapper = new ProcessWrapper(process); wrapper.OutputReceived += (_, log) => { progress.Report(new ProgressReport(ProgressKind.Log, log)); }; wrapper.StartWithEvents(); progress.Report(new ProgressReport( ProgressKind.Status, $"Minecraft launched (PID {process.Id})" )); var exitCode = await wrapper.WaitForExitTaskAsync().ConfigureAwait(false); progress.Report(new ProgressReport( ProgressKind.Status, $"Game exited (code {exitCode})" )); return exitCode; } }