// Package dalamud downloads and runs the Dalamud plugin framework's injector, // following the same flow as FFXIVQuickLauncher: version info + release zip + // .NET runtime + asset bundle from kamori.goats.dev, then hand-off to // Dalamud.Injector.exe which spawns ffxiv_dx11.exe with our launch arguments. package dalamud import ( "archive/zip" "crypto/md5" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" "ffxiv_tiny_launcher/logx" ) const ( versionInfoURL = "https://kamori.goats.dev/Dalamud/Release/VersionInfo?track=release&bucket=Control" assetMetaURL = "https://kamori.goats.dev/Dalamud/Asset/Meta" runtimeBase = "https://kamori.goats.dev/Dalamud/Release/Runtime/" ) // ErrVersionMismatch means the latest Dalamud release does not support this // game version; the caller should launch without Dalamud. var ErrVersionMismatch = errors.New("dalamud release does not support this game version") type VersionInfo struct { AssemblyVersion string `json:"assemblyVersion"` RuntimeVersion string `json:"runtimeVersion"` RuntimeRequired bool `json:"runtimeRequired"` SupportedGameVer string `json:"supportedGameVer"` DownloadUrl string `json:"downloadUrl"` } type assetMeta struct { Version int `json:"version"` PackageUrl string `json:"packageUrl"` } // Paths is the on-disk layout under one base directory (the caller picks it, // typically /dalamud). It mirrors FFXIVQuickLauncher's layout: // hooks/ holds the release zip contents, runtime/ the .NET runtime, // assets/ the asset bundle. type Paths struct { Runner string // Dalamud.Injector.exe inside HooksDir HooksDir string RuntimeDir string AssetDir string Version VersionInfo } func newClient() *http.Client { return &http.Client{Timeout: 15 * time.Minute} } // Ensure downloads and updates everything needed to run Dalamud, checking // integrity the same way FFXIVQuickLauncher does (per-file MD5 maps). It // returns the injector path plus directories for Launch. gameVer is the // content of ffxivgame.ver; a mismatch yields ErrVersionMismatch. func Ensure(baseDir, gameVer string) (*Paths, error) { client := newClient() info, err := getVersionInfo(client) if err != nil { return nil, fmt.Errorf("fetching version info: %w", err) } logf("release %s (game %s), runtime %s required=%v", info.AssemblyVersion, info.SupportedGameVer, info.RuntimeVersion, info.RuntimeRequired) if info.SupportedGameVer != gameVer { return nil, fmt.Errorf("%w: game is %s, dalamud wants %s", ErrVersionMismatch, gameVer, info.SupportedGameVer) } hooksDir := filepath.Join(baseDir, "hooks", info.AssemblyVersion) if err := ensureHooks(client, baseDir, hooksDir, info.DownloadUrl); err != nil { return nil, err } runner := filepath.Join(hooksDir, "Dalamud.Injector.exe") if _, err := os.Stat(runner); err != nil { return nil, fmt.Errorf("injector missing after download: %w", err) } runtimeDir := filepath.Join(baseDir, "runtime") if info.RuntimeRequired { if err := ensureRuntime(client, runtimeDir, info.RuntimeVersion); err != nil { return nil, err } } assetDir, err := ensureAssets(client, filepath.Join(baseDir, "assets")) if err != nil { return nil, err } return &Paths{Runner: runner, HooksDir: hooksDir, RuntimeDir: runtimeDir, AssetDir: assetDir, Version: info}, nil } func getVersionInfo(client *http.Client) (VersionInfo, error) { resp, err := client.Get(versionInfoURL) if err != nil { return VersionInfo{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return VersionInfo{}, fmt.Errorf("status %d", resp.StatusCode) } var info VersionInfo if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { return VersionInfo{}, err } if info.AssemblyVersion == "" || info.DownloadUrl == "" { return VersionInfo{}, errors.New("incomplete version info") } return info, nil } // ensureHooks keeps hooks/ populated and hash-verified; on any failure it // re-downloads the release zip. Other versions are cleaned up afterwards. func ensureHooks(client *http.Client, baseDir, hooksDir, downloadUrl string) error { if dirExists(hooksDir) && verifyIntegrity(hooksDir, "hashes.json") { logf("hooks %s already intact", filepath.Base(hooksDir)) } else { logf("downloading dalamud release...") if err := os.RemoveAll(hooksDir); err != nil { return err } var lastErr error for attempt := 1; attempt <= 2; attempt++ { tmp, err := downloadToTemp(client, downloadUrl) if err != nil { lastErr = fmt.Errorf("release zip: %w", err) } else if err := extractZip(tmp, hooksDir); err != nil { os.Remove(tmp) lastErr = fmt.Errorf("extracting release zip: %w", err) } else if !verifyIntegrity(hooksDir, "hashes.json") { os.Remove(tmp) lastErr = errors.New("release zip failed integrity check after download") } else { os.Remove(tmp) logf("hooks %s downloaded and verified", filepath.Base(hooksDir)) break } if attempt < 2 { logf("download attempt %d failed (%v); retrying once", attempt, lastErr) if err := os.RemoveAll(hooksDir); err != nil { return err } } else { return lastErr } } } cleanSiblings(filepath.Dir(hooksDir), hooksDir) return nil } // ensureRuntime keeps runtime/ at the requested .NET version. The two zips // (core + Windows Desktop) extract into the same tree, exactly as // FFXIVQuickLauncher does; the per-file hash list is fetched from kamori and // cached next to the files it covers. func ensureRuntime(client *http.Client, runtimeDir, ver string) error { versionFile := filepath.Join(runtimeDir, "version") localVer := "" if b, err := os.ReadFile(versionFile); err == nil { localVer = strings.TrimSpace(string(b)) } hashesFile := filepath.Join(runtimeDir, "hashes-"+ver+".json") if localVer == ver && dirExists(filepath.Join(runtimeDir, "host", "fxr", ver)) { // A cached hash file means we can re-verify cheaply; without one the // version file alone is our record that this tree was checked (or // explicitly accepted unverified) at download time. if _, err := os.Stat(hashesFile); err == nil && !verifyCachedIntegrity(runtimeDir, hashesFile) { logf("runtime %s failed integrity check; redownloading", ver) } else { logf("runtime %s already intact", ver) return nil } } logf("downloading .NET runtime %s...", ver) if err := os.RemoveAll(runtimeDir); err != nil { return err } var lastErr error for attempt := 1; attempt <= 2; attempt++ { lastErr = downloadAndVerifyRuntime(client, runtimeDir, ver, hashesFile) if lastErr == nil { break } if attempt < 2 { logf("runtime attempt %d failed (%v); retrying once", attempt, lastErr) } if err := os.RemoveAll(runtimeDir); err != nil { return err } } return lastErr } // downloadAndVerifyRuntime fetches and extracts both runtime zips, then checks // the tree against kamori's published hash list. The hash list is cached in // hashesFile so later runs can re-verify without a network round trip; if it // cannot be fetched the tree is accepted unverified (with a warning), matching // FFXIVQuickLauncher's behavior. func downloadAndVerifyRuntime(client *http.Client, runtimeDir, ver, hashesFile string) error { if err := os.MkdirAll(runtimeDir, 0o755); err != nil { return err } for _, part := range []string{"DotNet", "WindowsDesktop"} { tmp, err := downloadToTemp(client, fmt.Sprintf("%s%s/%s", runtimeBase, part, ver)) if err != nil { return fmt.Errorf("runtime %s zip: %w", part, err) } if err := extractZip(tmp, runtimeDir); err != nil { os.Remove(tmp) return fmt.Errorf("extracting runtime %s zip: %w", part, err) } os.Remove(tmp) } // The published hash list covers a top-level version file that the zips do // not ship; FFXIVQuickLauncher writes it with exactly this content before // verifying. if err := os.WriteFile(filepath.Join(runtimeDir, "version"), []byte(ver), 0o644); err != nil { return err } hashesJSON, err := fetchRuntimeHashes(client, ver) if err != nil { logf("warning: could not verify runtime hashes (%v); continuing unverified", err) return nil } if err := os.WriteFile(hashesFile, []byte(hashesJSON), 0o644); err != nil { return err } if !verifyIntegrityFrom(runtimeDir, strings.NewReader(hashesJSON)) { return fmt.Errorf("runtime %s failed integrity check after download", ver) } logf("runtime %s downloaded and verified", ver) return nil } func fetchRuntimeHashes(client *http.Client, ver string) (string, error) { resp, err := client.Get(runtimeBase + "Hashes/" + ver) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("status %d", resp.StatusCode) } b, err := io.ReadAll(resp.Body) return string(b), err } // ensureAssets keeps assets/ at the version kamori currently publishes. func ensureAssets(client *http.Client, root string) (string, error) { resp, err := client.Get(assetMetaURL) if err != nil { return "", fmt.Errorf("fetching asset meta: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("asset meta status %d", resp.StatusCode) } var meta assetMeta if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { return "", err } logf("assets: remote version %d", meta.Version) localVer := 0 if b, err := os.ReadFile(filepath.Join(root, "asset.ver")); err == nil { fmt.Sscanf(strings.TrimSpace(string(b)), "%d", &localVer) } assetDir := filepath.Join(root, fmt.Sprintf("%d", meta.Version)) if localVer == meta.Version && dirExists(assetDir) { logf("assets %s already intact", assetDir) return assetDir, nil } logf("downloading asset bundle...") tmp, err := downloadToTemp(client, meta.PackageUrl) if err != nil { return "", fmt.Errorf("asset zip: %w", err) } defer os.Remove(tmp) if err := os.RemoveAll(assetDir); err != nil { return "", err } if err := extractZip(tmp, assetDir); err != nil { return "", fmt.Errorf("extracting asset zip: %w", err) } cleanSiblings(root, assetDir) if err := os.WriteFile(filepath.Join(root, "asset.ver"), []byte(fmt.Sprintf("%d", meta.Version)), 0o644); err != nil { return "", err } logf("assets %s downloaded", assetDir) return assetDir, nil } func downloadToTemp(client *http.Client, url string) (string, error) { resp, err := client.Get(url) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("status %d", resp.StatusCode) } f, err := os.CreateTemp("", "ffxiv-dalamud-*.zip") if err != nil { return "", err } n, err := io.Copy(f, resp.Body) closeErr := f.Close() if err != nil { os.Remove(f.Name()) return "", err } if closeErr != nil { os.Remove(f.Name()) return "", closeErr } // A missing/short body (dropped connection, proxy truncation) would only // surface later as an integrity failure; reject it up front when the // server told us the size. if resp.ContentLength > 0 && n != resp.ContentLength { os.Remove(f.Name()) return "", fmt.Errorf("truncated download: got %d of %d bytes", n, resp.ContentLength) } logf("downloaded %s (%d bytes)", url, n) return f.Name(), nil } // extractZip unpacks a zip into dest, rejecting entries that escape it. func extractZip(path, dest string) error { r, err := zip.OpenReader(path) if err != nil { return err } defer r.Close() for _, f := range r.File { name := strings.ReplaceAll(f.Name, "\\", "/") clean := filepath.Clean("/" + name) if clean == "/" || strings.HasPrefix(clean, "/../") { continue // skip traversal attempts and odd absolute entries } target := filepath.Join(dest, strings.TrimPrefix(clean, "/")) if f.FileInfo().IsDir() { if err := os.MkdirAll(target, 0o755); err != nil { return err } continue } if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return err } src, err := f.Open() if err != nil { return err } dst, err := os.Create(target) if err != nil { src.Close() return err } _, copyErr := io.Copy(dst, src) src.Close() closeErr := dst.Close() if copyErr != nil { os.Remove(target) return copyErr } if closeErr != nil { os.Remove(target) return closeErr } } return nil } // verifyIntegrity checks every file listed in / (a JSON map // of relative path -> uppercase MD5 hex, as shipped inside the zips). func verifyIntegrity(dir, hashesName string) bool { b, err := os.ReadFile(filepath.Join(dir, hashesName)) if err != nil { return false } return verifyIntegrityFrom(dir, strings.NewReader(string(b))) } func verifyCachedIntegrity(dir, hashesFile string) bool { if _, err := os.Stat(hashesFile); err != nil { return false } b, err := os.ReadFile(hashesFile) if err != nil { return false } return verifyIntegrityFrom(dir, strings.NewReader(string(b))) } func verifyIntegrityFrom(dir string, hashesJSON io.Reader) bool { var hashes map[string]string if err := json.NewDecoder(hashesJSON).Decode(&hashes); err != nil || len(hashes) == 0 { return false } for rel, want := range hashes { // The hash maps ship with Windows separators; normalize by hand since // filepath.FromSlash is a no-op on Linux. p := strings.ReplaceAll(rel, "\\", "/") f, err := os.Open(filepath.Join(dir, filepath.FromSlash(p))) if err != nil { logf("integrity: missing %s", rel) return false } h := md5.New() _, copyErr := io.Copy(h, f) closeErr := f.Close() if copyErr != nil || closeErr != nil { return false } got := hex.EncodeToString(h.Sum(nil)) if !strings.EqualFold(got, want) { logf("integrity: hash mismatch for %s", rel) return false } } return true } func dirExists(p string) bool { st, err := os.Stat(p) return err == nil && st.IsDir() } // cleanSiblings removes directories in parent that are not keep (old versions). func cleanSiblings(parent, keep string) { keep = filepath.Clean(keep) entries, err := os.ReadDir(parent) if err != nil { return } for _, e := range entries { if !e.IsDir() || filepath.Join(parent, e.Name()) == keep { continue } p := filepath.Join(parent, e.Name()) logf("cleaning old %s", p) os.RemoveAll(p) } } func logf(format string, args ...any) { logx.F("[dalamud]", format, args...) }