// package launch handles Minecraft process launching (argument interpolation, classpath, exec). package launch import ( "crypto/sha1" "fmt" "io" "os" "os/exec" "path/filepath" "strings" "sync/atomic" "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/auth" "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/config" "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/fetcher" "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/java" ) // Game holds all parameters needed to launch a modpack instance. type Game struct { ServerBaseURL string Session *auth.Session Manifest *Manifest InstanceDir string JavaBin string MemoryMB int ExtraJVMArgs string } // Prepare downloads the manifest, syncs files, and resolves the Java binary. func (g *Game) Prepare(onProgress func(downloaded, total int64)) error { if err := os.MkdirAll(g.InstanceDir, 0o755); err != nil { return fmt.Errorf("creating instance dir: %w", err) } // Download manifest. manifestURL := fmt.Sprintf("%s/api/instances/%s/manifest.json", g.ServerBaseURL, filepath.Base(g.InstanceDir)) manifestPath := filepath.Join(g.InstanceDir, "manifest.json") if err := fetcher.Download(manifestURL, manifestPath, "", nil); err != nil { return fmt.Errorf("downloading manifest: %w", err) } m, err := LoadManifest(manifestPath) if err != nil { return err } g.Manifest = m // Resolve Java. bin, err := java.Find(m.JavaVersion) if err != nil { return err } g.JavaBin = bin // Sync files. var totalBytes int64 for _, f := range m.Files { totalBytes += f.Size } var downloadedBytes int64 pool := fetcher.NewWorkerPool(4) for _, f := range m.Files { dest := filepath.Join(g.InstanceDir, f.Path) // Skip existing verified files. if existing, err := os.Stat(dest); err == nil && existing.Size() == f.Size { if got, err := sha1File(dest); err == nil && got == f.Hash { atomic.AddInt64(&downloadedBytes, f.Size) if onProgress != nil { onProgress(atomic.LoadInt64(&downloadedBytes), totalBytes) } continue } } pool.Submit(func() { url := f.URL if url == "" { url = fmt.Sprintf("%s/files/%s", g.ServerBaseURL, f.Hash) } _ = fetcher.Download(url, dest, f.Hash, func(n, _ int64) { atomic.AddInt64(&downloadedBytes, n) if onProgress != nil { onProgress(atomic.LoadInt64(&downloadedBytes), totalBytes) } }) }) } pool.Wait() return g.cleanupUnknownMods() } // cleanupUnknownMods moves untracked files from mods/ to mods_backup/. func (g *Game) cleanupUnknownMods() error { modsDir := filepath.Join(g.InstanceDir, "mods") entries, err := os.ReadDir(modsDir) if err != nil { if os.IsNotExist(err) { return nil } return err } known := make(map[string]bool) for _, f := range g.Manifest.ModFiles() { known[filepath.Base(f.Path)] = true } backupDir := filepath.Join(g.InstanceDir, "mods_backup") for _, entry := range entries { if entry.IsDir() || known[entry.Name()] { continue } if err := os.MkdirAll(backupDir, 0o755); err != nil { return err } src := filepath.Join(modsDir, entry.Name()) dst := filepath.Join(backupDir, entry.Name()) if err := os.Rename(src, dst); err != nil { return fmt.Errorf("backing up %s: %w", entry.Name(), err) } } return nil } // BuildCommand constructs the full exec.Command for launching the game. func (g *Game) BuildCommand() (*exec.Cmd, error) { if g.JavaBin == "" { return nil, fmt.Errorf("java binary not resolved") } if g.Manifest == nil { return nil, fmt.Errorf("manifest not loaded") } if g.Session == nil { return nil, fmt.Errorf("no active session — login required") } var classpathEntries []string for _, lib := range g.Manifest.LibraryFiles() { classpathEntries = append(classpathEntries, filepath.Join(g.InstanceDir, lib.Path)) } classpathEntries = append(classpathEntries, filepath.Join(g.InstanceDir, "libraries", "*")) classpathStr := strings.Join(classpathEntries, string(os.PathListSeparator)) vars := map[string]string{ "player_name": g.Session.Username, "auth_uuid": g.Session.UUID, "auth_access_token": g.Session.AccessToken, "classpath": classpathStr, "java_path": g.JavaBin, "game_directory": g.InstanceDir, "assets_root": filepath.Join(g.InstanceDir, "assets"), "assets_index_name": g.Manifest.MinecraftVersion, "version_name": g.Manifest.MinecraftVersion, "natives_directory": filepath.Join(g.InstanceDir, "natives"), "launcher_name": config.AppName, "launcher_version": "dev", "auth_player_name": g.Session.Username, "auth_session": g.Session.AccessToken, "user_type": "mojang", "version_type": "release", "resolution_width": "1280", "resolution_height": "720", } var args []string // JVM memory + flags. args = append(args, fmt.Sprintf("-Xms%dM", g.MemoryMB)) args = append(args, fmt.Sprintf("-Xmx%dM", g.MemoryMB)) for _, a := range g.Manifest.Launch.JVMArgs { args = append(args, interpolate(a, vars)) } if g.ExtraJVMArgs != "" { args = append(args, strings.Fields(g.ExtraJVMArgs)...) } // Authlib-injector. if g.Manifest.Launch.AuthLibInjector != "" { authlibPath := filepath.Join(g.InstanceDir, "authlib-injector.jar") args = append(args, fmt.Sprintf("-javaagent:%s=%s", authlibPath, g.Manifest.Launch.AuthLibInjector)) } // Classpath + main class. args = append(args, "-cp", classpathStr) args = append(args, g.Manifest.Launch.MainClass) // Game args. for _, a := range g.Manifest.Launch.GameArgs { args = append(args, interpolate(a, vars)) } cmd := exec.Command(g.JavaBin, args...) cmd.Dir = g.InstanceDir cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin return cmd, nil } // Start launches the game process and waits for it to finish. func (g *Game) Start() error { cmd, err := g.BuildCommand() if err != nil { return err } return cmd.Run() } // interpolate replaces ${var} placeholders in s with values from vars. func interpolate(s string, vars map[string]string) string { for k, v := range vars { s = strings.ReplaceAll(s, "${"+k+"}", v) } return s } // sha1File computes the SHA-1 hex digest of a file. func sha1File(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() h := sha1.New() if _, err := io.Copy(h, f); err != nil { return "", err } return fmt.Sprintf("%x", h.Sum(nil)), nil }