// package launch handles Minecraft process launching (argument interpolation, classpath, exec). package launch import ( "crypto/sha1" "fmt" "io" "os" "os/exec" "path/filepath" "strings" "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. // onProgress is called with (downloadedBytes, totalBytes) during file sync. func (g *Game) Prepare(onProgress func(downloaded, total int64)) error { // 1. Ensure instance directory exists. if err := os.MkdirAll(g.InstanceDir, 0o755); err != nil { return fmt.Errorf("creating instance dir: %w", err) } // 2. Download manifest. manifestURL := fmt.Sprintf("%s/api/instances/%s/manifest.json", g.ServerBaseURL, slugFromDir(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 // 3. Resolve Java. bin, err := java.Find(m.JavaVersion) if err != nil { return err } g.JavaBin = bin // 4. Sync files (download missing / verify SHA-1). var totalBytes int64 for _, f := range m.Files { totalBytes += f.Size } var downloadedBytes int64 pool := fetcher.NewWorkerPool(4) for i := range m.Files { f := m.Files[i] dest := filepath.Join(g.InstanceDir, f.Path) // Skip if file exists and hash matches. if existing, err := os.Stat(dest); err == nil { if existing.Size() == f.Size { if got, err := fileHash(dest); err == nil && got == f.Hash { downloadedBytes += f.Size if onProgress != nil { onProgress(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) { downloadedBytes += n if onProgress != nil { onProgress(downloadedBytes, totalBytes) } }) }) } pool.Wait() // 5. Soft-delete unknown mods. if err := g.cleanupUnknownMods(); err != nil { return fmt.Errorf("cleaning up unknown mods: %w", err) } return nil } // cleanupUnknownMods moves files from mods/ that are not in the manifest // into mods_backup/ (soft deletion / self-healing). func (g *Game) cleanupUnknownMods() error { modsDir := filepath.Join(g.InstanceDir, "mods") backupDir := filepath.Join(g.InstanceDir, "mods_backup") entries, err := os.ReadDir(modsDir) if err != nil { if os.IsNotExist(err) { return nil } return err } // Build set of known mod filenames. known := make(map[string]bool) for _, f := range g.Manifest.ModFiles() { known[filepath.Base(f.Path)] = true } for _, entry := range entries { if entry.IsDir() { continue } if known[entry.Name()] { continue } // Move to backup. 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") } // Build classpath. var classpathEntries []string for _, lib := range g.Manifest.LibraryFiles() { classpathEntries = append(classpathEntries, filepath.Join(g.InstanceDir, lib.Path)) } // Add instance libraries dir. classpathEntries = append(classpathEntries, filepath.Join(g.InstanceDir, "libraries", "*")) classpath := strings.Join(classpathEntries, string(os.PathListSeparator)) // Authlib-injector path. authlibPath := filepath.Join(g.InstanceDir, "authlib-injector.jar") // Interpolation context. vars := map[string]string{ "player_name": g.Session.Username, "auth_uuid": g.Session.UUID, "auth_access_token": g.Session.AccessToken, "classpath": classpath, "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", } // Assemble arguments. var args []string // JVM args. 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 != "" { args = append(args, fmt.Sprintf("-javaagent:%s=%s", authlibPath, g.Manifest.Launch.AuthLibInjector)) } // Classpath. args = append(args, "-cp", classpath) // Main class. 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 } // slugFromDir extracts the modpack slug from the instance directory path. func slugFromDir(dir string) string { return filepath.Base(dir) } // fileHash is a local alias to avoid import cycle — computes SHA-1 hex of a file. func fileHash(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 }