package main import ( "fmt" "io" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "sync" "sync/atomic" "syscall" "unsafe" "github.com/tadvi/winc" "github.com/tadvi/winc/w32" "ffxiv_tiny_launcher/auth" "ffxiv_tiny_launcher/config" "ffxiv_tiny_launcher/dalamud" "ffxiv_tiny_launcher/logx" "ffxiv_tiny_launcher/sqex" ) // logMaxBytes caps launcher.log (see logx.Capped): long sessions and repeated // login attempts must not grow the file without bound. const logMaxBytes = 32 << 10 // initLog mirrors all launcher logs to launcher.log next to the executable so // history survives after the (hidden) console goes away; other packages' log // lines reach this sink via logx too. The file is capped at logMaxBytes — // once a write would cross it, the oldest half is dropped (newest wins). func initLog() { exe, err := os.Executable() if err != nil { return } f, err := os.OpenFile(filepath.Join(filepath.Dir(exe), "launcher.log"), os.O_CREATE|os.O_RDWR|os.O_APPEND, 0o644) if err == nil { logx.AddSink(logx.Capped(f, logMaxBytes)) } } // logf writes to every sink (stderr + log file) so progress is visible even // when the dialog (or console) is not. func logf(format string, args ...any) { logx.F("[ffxiv-launcher]", format, args...) } // hideConsole hides the console window a double-clicked build opens, so only // the dialog appears. It never touches a console shared with another process // (e.g. started from a terminal for debugging), and FFXIV_CONSOLE=1 keeps it // visible either way; stderr stays attached in all cases, as does launcher.log. func hideConsole() { if os.Getenv("FFXIV_CONSOLE") != "" { logf("console kept visible (FFXIV_CONSOLE set)") return } h := w32.GetConsoleWindow() if h == 0 { return // no console attached at all } if n := consoleProcessCount(); n != 1 { logf("console shared with %d process(es); leaving visible", n) return } hideConsoleWindow(h) logf("console window hidden") } type app struct { // cfg is shared between the UI thread (path browse, Dalamud toggle) and the // login worker (credentials after success); it may only be touched under mu // once the main loop starts. Workers take an immutable snapshot in startLogin. cfg config.Config form *winc.Form user *winc.Edit pass *winc.Edit otp *winc.Edit dlmChk *winc.CheckBox statusLbl *winc.Label pathLbl *winc.Label loginBtn *winc.PushButton busy atomic.Bool mu sync.Mutex // guards inflight, the notice fields, and cfg inflight [][]uint16 // WM_SETTEXT buffers, kept alive until the OS reclaims them (tiny) noticeTitle string // pending modal popup (title + body), shown on the UI thread noticeText string reenableLogin bool // set by a worker goroutine when login finishes; noticeTick re-enables the button on the UI thread // Callbacks registered with Win32 (edit wndprocs for Enter-to-login, the // gate-alert poller timer): kept alive because NewCallback addresses must // not move. UI-thread-only state follows: oldProcs chains subclassed // windows back to their original procedures. pinned []func(hwnd, msg, wparam, lparam uintptr) uintptr oldProcs map[w32.HWND]uintptr } // buildID is stamped into dist builds by build.sh (-X main.buildID=) // so launcher.log proves which binary a test round actually ran. var buildID = "dev" func main() { initLog() logf("starting ffxiv-tiny-launcher (%s/%s) build %s", runtime.GOOS, runtime.GOARCH, buildID) hideConsole() cfg, cerr := config.Load() if cerr != nil { logf("config load failed: %v (using defaults)", cerr) } else { logf("config loaded (user set=%v, path set=%v, dalamud=%v)", cfg.User != "", cfg.GamePath != "", cfg.Dalamud) } a := &app{cfg: cfg, oldProcs: make(map[w32.HWND]uintptr)} form := winc.NewForm(nil) logf("form created") form.SetText("FFXIV Tiny Launcher") // Size by CLIENT area (not window rect): the controls below are positioned // in client coordinates and would be clipped if the title bar ate into ch. sizeWindowToClient(form.Handle(), 470, 216) // content ends at y=204 + margin form.EnableSizable(false) form.EnableMaxButton(false) a.form = form mkLabel := func(text string, x, y int) *winc.Label { l := winc.NewLabel(form) l.SetText(text) l.SetPos(x, y) l.SetSize(86, 18) return l } mkEdit := func(y int) *winc.Edit { e := winc.NewEdit(form) e.SetPos(104, y) e.SetSize(350, 23) return e } // Layout: status line on top, then the three credential rows, the install // path row (with its browse button pinned right), and one bottom row: // Log in (left) — Enable Dalamud (middle) — Open official launcher (right, // beneath the browse button). The form is fixed-size, so "pinned" means a // static x. a.user = mkEdit(35) a.pass = mkEdit(67) a.pass.SetPassword(true) a.otp = mkEdit(99) mkLabel("Username:", 12, 38) mkLabel("Password:", 12, 70) mkLabel("2FA code:", 12, 102) a.statusLbl = winc.NewLabel(form) a.statusLbl.SetText("Enter your credentials and press Log in.") a.statusLbl.SetPos(12, 8) a.statusLbl.SetSize(442, 18) a.pathLbl = winc.NewLabel(form) a.pathLbl.SetText(gamePathLabel(cfg.GamePath)) a.pathLbl.SetPos(12, 140) a.pathLbl.SetSize(350, 18) browseBtn := winc.NewPushButton(form) browseBtn.SetText("...") browseBtn.SetPos(360, 137) browseBtn.SetSize(94, 23) a.loginBtn = winc.NewPushButton(form) a.loginBtn.SetText("Log in") a.loginBtn.SetPos(12, 175) a.loginBtn.SetSize(110, 28) seBtn := winc.NewPushButton(form) seBtn.SetText("Open official launcher") seBtn.SetPos(276, 176) seBtn.SetSize(178, 26) a.dlmChk = winc.NewCheckBox(form) a.dlmChk.SetText("Enable Dalamud") a.dlmChk.SetPos(134, 179) a.dlmChk.SetSize(130, 22) a.dlmChk.SetChecked(cfg.Dalamud) // Click actions are wired via nativeClickHandler (below), not winc event // bindings: on GameNative the raw messages reach DefWindowProc (press // animation, BS_AUTOCHECKBOX auto-toggle) but winc's post-WndProc event // dispatch — where OnLBUp/OnClick would fire — provably never runs. // winc's Form.WndProc answers WM_CLOSE with 0 and never destroys the // window itself, so closing is two-step: post WM_QUIT for the main loop to // end AND destroy the window (its WM_DESTROY posts a second WM_QUIT as a // belt-and-braces in case the first one was lost). form.OnClose().Bind(func(*winc.Event) { logf("close requested: posting WM_QUIT and destroying window") winc.Exit() w32.DestroyWindow(a.form.Handle()) }) a.user.SetText(cfg.User) a.pass.SetText(cfg.Pass) a.enableEnterToLogin() a.startNoticePoller() // Native click handling: real WM_LBUTTONUP messages run DefWindowProc // (press animation, checkbox auto-toggle) and fire the action. a.nativeClickHandler("loginBtn", a.loginBtn.Handle(), func() { a.startLogin() }) a.nativeClickHandler("browseBtn", browseBtn.Handle(), func() { a.browseFolder() }) // The official-launcher button gets a short cooldown after each click: it is // disabled immediately and a one-shot timer (the SetTimer channel proven on // GameNative) re-enables it. A disabled control is skipped by hit-testing, // so clicks during the cooldown cannot launch a second copy. a.nativeClickHandler("seBtn", seBtn.Handle(), func() { seBtn.SetEnabled(false) logf("official-launcher button disabled for %d ms cooldown", seCooldownMS) re := func(hwnd, msg, id, ticks uintptr) uintptr { // return value ignored by the OS killTimer(seBtn.Handle(), seCooldownTimer) seBtn.SetEnabled(true) logf("official-launcher button re-enabled") return 0 } a.pinned = append(a.pinned, re) // keep closure alive for NewCallback setTimer(seBtn.Handle(), seCooldownTimer, seCooldownMS, uintptr(syscall.NewCallback(re))) a.openSELauncher() }) a.nativeClickHandler("dlmChk", a.dlmChk.Handle(), func() { // The auto-toggle already happened inside the chained DefWindowProc call. on := a.dlmChk.Checked() a.setDalamud(on) logf("native click: dalamud enabled=%v", on) }) logf("UI ready; showing form") form.Center() form.Show() logf("entering main loop") winc.RunMainLoop() logf("main loop returned, exiting") os.Exit(0) } // browseFolder runs on the UI thread; the native folder dialog is modal. func (a *app) browseFolder() { dir, ok := winc.ShowBrowseFolderDlg(a.form, "Select the FFXIV install folder (the one containing game/)") if !ok || dir == "" { // cancelled return } a.setGamePath(dir) a.pathLbl.SetText(gamePathLabel(dir)) logf("game path set: %s", dir) } // openSELauncher starts the official launcher (boot/ffxivboot64.exe) so Square // Enix can patch/update the client. Runs on the UI thread; the child keeps // running after this app exits, as Windows children do. The password is put on // the clipboard first so it can be pasted into the official login form with // Ctrl+V. func (a *app) openSELauncher() { dir := a.gamePath() if dir == "" { d, ok := winc.ShowBrowseFolderDlg(a.form, "Select the FFXIV install folder (the one containing game/)") if !ok || d == "" { // cancelled return } a.setGamePath(d) dir = d a.pathLbl.SetText(gamePathLabel(dir)) } exe := filepath.Join(dir, "boot\\ffxivboot64.exe") if st, err := os.Stat(exe); err != nil || st.IsDir() { logf("official launcher not found at %s", exe) a.setUIStatus(fmt.Sprintf("ffxivboot64.exe not found in %s\\boot", dir)) return } pass := a.pass.Text() if pass == "" { // field cleared but we have saved credentials a.mu.Lock() pass = a.cfg.Pass a.mu.Unlock() } clipped := false if pass != "" { if err := setClipboardText(pass); err != nil { logf("clipboard: %v", err) } else { clipped = true } } cmd := exec.Command(exe) cmd.Dir = filepath.Dir(exe) // the launcher resolves its own files relative to boot/ cmd.Stdout, cmd.Stderr = io.Discard, io.Discard // don't let the child write into our console/log if err := cmd.Start(); err != nil { logf("official launcher start failed: %v", err) a.setUIStatus(fmt.Sprintf("Could not start %s: %v", filepath.Base(exe), err)) return } logf("official launcher started (dir %s, password copied=%v)", dir, clipped) if clipped { a.setUIStatus("Password is on the clipboard — press Ctrl+V in the official launcher's login form.") } else { a.setUIStatus("Official launcher opened; enter your credentials there.") } } // startLogin runs on the UI thread (button click or Enter key). It captures // field values here — control state may only be read from the UI thread — and // hands off to a worker goroutine so the dialog stays responsive. The worker // receives an immutable snapshot of everything it needs (credentials, game // path, Dalamud flag) so it never touches shared state; concurrent UI clicks // (browse, checkbox) may freely mutate a.cfg while login is in flight. func (a *app) startLogin() { if a.busy.Swap(true) { logf("login requested while busy; ignored") return } a.setLoginButtonEnabled(false) // UI thread only logf("login starting from UI thread") user, pass, otp := a.user.Text(), a.pass.Text(), a.otp.Text() a.mu.Lock() gamePath, dalamud := a.cfg.GamePath, a.cfg.Dalamud a.mu.Unlock() if gamePath == "" { dir, ok := winc.ShowBrowseFolderDlg(a.form, "Select the FFXIV install folder (the one containing game/)") if !ok || dir == "" { // cancelled a.busy.Store(false) a.setLoginButtonEnabled(true) return } gamePath = dir a.setGamePath(dir) a.pathLbl.SetText(gamePathLabel(dir)) } go a.doLogin(user, pass, otp, gamePath, dalamud) // re-enabled by noticeTick once doLogin's defer runs } // setLoginButtonEnabled disables/re-enables the Log in button. UI thread only. // A disabled Win32 child is skipped by hit-testing and receives no mouse // messages at all — exactly the guard we want mid-login; Enter-to-login stays // possible but is caught by the busy flag. Both directions are logged so a // future launcher.log shows whether re-enabling actually happened. func (a *app) setLoginButtonEnabled(enabled bool) { a.loginBtn.SetEnabled(enabled) logf("login button enabled=%v", enabled) } // requestReenableLogin is called from doLogin's worker goroutine: window state // may only change on the UI thread, so it sets a flag and lets the 10 Hz // notice tick perform the EnableWindow (the timer-callback channel proven to // work on GameNative). func (a *app) requestReenableLogin() { a.mu.Lock() a.reenableLogin = true a.mu.Unlock() } // gamePath reads cfg.GamePath under mu; safe from any goroutine. func (a *app) gamePath() string { a.mu.Lock() defer a.mu.Unlock() return a.cfg.GamePath } // setGamePath records the install folder chosen on the UI thread and Saves a // snapshot, so it can never race the login worker's credential write. func (a *app) setGamePath(dir string) { a.mu.Lock() a.cfg.GamePath = dir snap := a.cfg a.mu.Unlock() config.Save(snap) } // setDalamud records the checkbox state (UI thread), same snapshot rule as // setGamePath. func (a *app) setDalamud(on bool) { a.mu.Lock() a.cfg.Dalamud = on snap := a.cfg a.mu.Unlock() config.Save(snap) } // saveCredentials records the credentials of a finished login from its worker // goroutine; same snapshot rule as setGamePath. func (a *app) saveCredentials(user, pass string) { a.mu.Lock() a.cfg.User = user a.cfg.Pass = pass snap := a.cfg a.mu.Unlock() config.Save(snap) } func (a *app) doLogin(user, pass, otp, gamePath string, dalamud bool) { defer func() { a.busy.Store(false) a.requestReenableLogin() // login failed/succeeded/stopped — the button comes back either way }() logf("login start: user=%q region=%d", user, region()) // QL checks the login gate before logging in and again right before it // launches; during maintenance either check stops us with SE's notice. if !a.checkGate() { return } a.setUIStatus("Logging in...") res, err := auth.Login(user, pass, otp, region()) if err != nil { logf("login failed: %v", err) a.setUIStatus(err.Error()) // For a rejected login this carries SE's own message (bad credentials, // locked account, ...); the "login rejected:" prefix is for the log. a.postNotice("Login failed", strings.TrimPrefix(err.Error(), "login rejected: ")) return } logf("login ok: session=%s region=%d playable=%d maxex=%d", res.SessionId, res.Region, res.Playable, res.MaxExpansion) a.setUIStatus("Registering session...") testSID, patches, err := auth.RegisterSession(res.SessionId, gamePath, res.MaxExpansion) if err != nil { logf("session registration failed: %v", err) a.setUIStatus(err.Error()) a.postNotice("Login failed", err.Error()) return } prefix := testSID if len(prefix) > 4 { prefix = prefix[:4] + "***" } logf("session registered: testsid=%s (len %d)", prefix, len(testSID)) a.saveCredentials(user, pass) if len(patches) > 0 { target := sqex.TargetVersion(patches) logf("updates available: %d pending patch(es), target version %s", len(patches), target) a.setUIStatus(fmt.Sprintf("Updates available (target %s). Press \"Open official launcher\" to patch.", target)) a.postNotice("Updates available", fmt.Sprintf( "Your game client is out of date — the server requires version %s.\n\n"+ "Press \"Open official launcher\", let it finish updating, then log in again here.", target)) return } if !a.checkGate() { return } pid, err := a.launchGame(res, testSID, gamePath, dalamud) if err != nil { logf("launch failed: %v", err) a.setUIStatus(err.Error()) a.postNotice("Launch failed", err.Error()) return } logf("game started (pid %d)", pid) a.closeApp() } // reportGateClosed surfaces SE's maintenance notice: first line in the status // label, full text (plus any published news lines) in launcher.log. func (a *app) reportGateClosed(gs *auth.GateStatus) { msg := gs.Message if msg == "" { msg = "The game is currently under maintenance." } logf("gate closed: %s", strings.ReplaceAll(msg, "\n", " | ")) if gs.News != "" { logf("gate news: %s", strings.ReplaceAll(gs.News, "\n", " | ")) } a.setUIStatus(firstLine(msg)) } func firstLine(s string) string { if i := strings.IndexByte(s, '\n'); i >= 0 { return s[:i] } return s } // checkGate runs a server-status check and reports false when login must stop. // The reason is surfaced in the status label AND as a modal popup — without it, // a gate-closed dialog with only its button disabled looks frozen. Dismissing // the popup re-enables login so the user can retry later if they like. func (a *app) checkGate() bool { a.setUIStatus("Checking server status...") gs, err := auth.CheckGate(gateLang()) if err != nil { logf("server status check failed: %v", err) a.setUIStatus("Could not reach the server-status service (maintenance or connection problem). Try again later.") a.postNotice("FFXIV servers unavailable", "Could not reach the FFXIV server-status service.\n\n"+ "This happens during some maintenance windows, or when your internet connection is down.\nPlease check back later and try logging in again.") return false } if !gs.Up { a.reportGateClosed(gs) a.postNotice("FFXIV servers unavailable", gateNoticeText(gs)) return false } return true } // gateNoticeText builds the popup body from SE's notice: the published // message, plus any news lines. func gateNoticeText(gs *auth.GateStatus) string { msg := gs.Message if msg == "" { msg = "The game is currently under maintenance." } if gs.News == "" { return msg } return msg + "\n\n" + gs.News } // postNotice queues a modal popup (title + body) for the UI thread. The poller // timer shows it and re-enables login afterwards so the user can retry without // relaunching; a modal box must be pumped by the owning thread, never this // worker goroutine itself. func (a *app) postNotice(title, body string) { a.mu.Lock() a.noticeTitle = title a.noticeText = body a.mu.Unlock() logf("UI notice queued: %q", title) } const ( noticeTimerID = 0x474154 // 'GAT' — the 10 Hz UI notice/re-enable poller seCooldownTimer = 0x53454344 // 'SECD' — one-shot re-enable of the official-launcher button seCooldownMS = 3000 // how long that button stays disabled after a click ) // startNoticePoller arms a SetTimer callback on the form. DispatchMessage // invokes timer procedures directly on the UI thread WITHOUT consulting any // window procedure, so this works even where GWLP_WNDPROC overrides are // ignored — GameNative is one such place: the wndproc-subclass version of this // posted its message fine (PostMessage succeeded) but never saw it. The tick // polls for queued popup text at 10 Hz; it stays armed so later notices (a // second gate failure, a login rejection) also appear — killing the timer made // the popup one-shot. func (a *app) startNoticePoller() { tick := func(hwnd, msg, id, ticks uintptr) uintptr { // return value ignored by the OS a.noticeTick() return 0 } a.pinned = append(a.pinned, tick) // keep closure alive for NewCallback if !setTimer(a.form.Handle(), noticeTimerID, 100, uintptr(syscall.NewCallback(tick))) { logf("WARNING: SetTimer failed; UI notices will not appear") return } logf("notice poller started (10 Hz timer on UI thread)") } // noticeTick runs on the UI thread from the poller's timer callback: it shows // a queued modal popup and performs any re-enable of the Log in button that a // finished login requested. Dismissing a notice leaves the launcher ready to // retry. func (a *app) noticeTick() { a.mu.Lock() title, text := a.noticeTitle, a.noticeText if text != "" { a.noticeTitle = "" a.noticeText = "" } reenable := a.reenableLogin if reenable { a.reenableLogin = false } a.mu.Unlock() if reenable { logf("login finished; re-enabling Log in button") a.setLoginButtonEnabled(true) } if text == "" { return } logf("showing notice: %s", title) winc.MsgBoxOk(a.form, title, text) logf("notice acknowledged") } // nativeClickHandler subclasses hwnd's wndproc so real click messages run // their normal course — DefWindowProc draws the press-down animation and, for // the BS_AUTOCHECKBOX checkbox, performs its auto-toggle inside that chained // call — while also firing the control's action as soon as WM_LBUTTONUP // arrives (winc's own OnLBUp event dispatch provably never runs on GameNative, // so the action is hooked here instead). Everything else chains through // untouched. func (a *app) nativeClickHandler(name string, hwnd w32.HWND, act func()) { old := getWndProc(hwnd) cb := func(h, msg, wparam, lparam uintptr) uintptr { if uint32(msg) == 0x0202 { // WM_LBUTTONUP: a real click landed on this control ret := w32.CallWindowProc(old, h, uint32(msg), wparam, lparam) // animation / auto-toggle happen in here logf("native click: LBUTTONUP on %s -> acting", name) act() return ret } return w32.CallWindowProc(old, h, uint32(msg), wparam, lparam) } a.pinned = append(a.pinned, cb) // keep closure alive for NewCallback setWndProc(hwnd, uintptr(syscall.NewCallback(cb))) logf("native click handling armed on %s", name) } // setUIStatus updates the status label from any goroutine. A Win32 window is // owned by the thread that created it, so we post WM_SETTEXT and let the UI // thread's DefWindowProc apply it; the UTF-16 buffer must stay alive until // then, so spent buffers are kept (bounded by the number of status updates). func (a *app) setUIStatus(s string) { buf := syscall.StringToUTF16(s) a.mu.Lock() a.inflight = append(a.inflight, buf) a.mu.Unlock() w32.PostMessage(a.statusLbl.Handle(), w32.WM_SETTEXT, 0, uintptr(unsafe.Pointer(&buf[0]))) } // closeApp terminates the UI from any goroutine. winc.Exit() must not be // called here — it posts WM_QUIT to the CALLING thread's queue; posting // WM_CLOSE instead lets the form's OnClose binding run on the UI thread, // which is where winc.Exit() belongs. func (a *app) closeApp() { logf("closing app after launch") w32.PostMessage(a.form.Handle(), w32.WM_CLOSE, 0, 0) } // enableEnterToLogin makes Enter in any of the three fields start login, like // the old Tk dialog did. Win32 delivers key input straight to the focused // window and a plain (non-dialog) parent never sees it, so we subclass each // edit's wndproc: intercept VK_RETURN, chain everything else to the system // edit proc via CallWindowProc. Runs on the UI thread; setup happens before // RunMainLoop, so no locking is needed. func (a *app) enableEnterToLogin() { for _, ed := range []*winc.Edit{a.user, a.pass, a.otp} { hwnd := ed.Handle() oldProc := getWndProc(hwnd) a.oldProcs[hwnd] = oldProc cb := func(hwnd, msg, wparam, lparam uintptr) uintptr { if uint32(msg) == w32.WM_KEYDOWN && uint16(wparam) == w32.VK_RETURN && !a.busy.Load() { logf("Log in via Enter key") a.startLogin() return 0 // consumed } return w32.CallWindowProc(oldProc, hwnd, uint32(msg), wparam, lparam) } a.pinned = append(a.pinned, cb) // keep closures alive for NewCallback setWndProc(hwnd, uintptr(syscall.NewCallback(cb))) } logf("enter-to-login enabled on %d fields", len(a.oldProcs)) } func region() int { if v := os.Getenv("FFXIV_REGION"); v != "" { if r, err := strconv.Atoi(v); err == nil && (r == 1 || r == 2) { return r } } return 1 // NA; 2 = EU } func gamePathLabel(p string) string { if p == "" { return "Game folder: not set" } return "Game folder: " + p } // launchGame starts the game, routing through the Dalamud injector when that // option is enabled. It receives its inputs (game path, Dalamud flag) as an // immutable snapshot taken by startLogin on the UI thread — it never reads // a.cfg itself. With Dalamud on, any failure (download, integrity check, // version mismatch, injector error) cancels the launch — the user opted into // plugins, so silently starting without them would be misleading. func (a *app) launchGame(res *auth.Result, testSID, gamePath string, withDalamud bool) (int, error) { exe := filepath.Join(gamePath, "game", "ffxiv_dx11.exe") wd := filepath.Join(gamePath, "game") if _, err := os.Stat(exe); err != nil { logf("game exe not found at %s (is Game folder the one CONTAINING game/?)", exe) return 0, &os.PathError{Op: "stat", Path: exe, Err: os.ErrNotExist} } argStr := buildLaunchArgs(res, testSID, gamePath) if withDalamud { baseDir := dalamudBaseDir() a.setUIStatus("Preparing Dalamud (first run downloads ~150 MB)...") p, derr := dalamud.Ensure(baseDir, readVer(gamePath)) if derr != nil { logf("dalamud unavailable: %v; cancelling launch", derr) return 0, fmt.Errorf("Dalamud failed to prepare (%v); launch cancelled. Fix the issue or turn Dalamud off.", derr) } a.setUIStatus("Starting game with Dalamud...") pid, lerr := dalamud.Launch(p, dalamud.LaunchConfig{ GameExe: exe, ConfigDir: filepath.Join(baseDir, "config"), LogDir: filepath.Join(baseDir, "logs"), Language: langInt(), GameArgs: argStr, }) if lerr != nil { logf("dalamud launch failed: %v; cancelling launch", lerr) return 0, fmt.Errorf("Dalamud injector failed (%v); launch cancelled. Fix the issue or turn Dalamud off.", lerr) } return pid, nil } return startPlain(exe, wd, argStr) } func dalamudBaseDir() string { dir, err := os.UserConfigDir() if err != nil { return "" } return filepath.Join(dir, "ffxiv-tiny-launcher", "dalamud") } // startPlain starts ffxiv_dx11.exe directly. The launcher exits; the game keeps running. // stdio goes to NUL: a child holding our console keeps it (and its window) alive // after this process exits, and foreign output in a shared handle is indistinguishable from ours. func startPlain(exe, wd, argStr string) (int, error) { cmd := exec.Command(exe, argStr) logf("launch: %s (cwd %s)", exe, wd) cmd.Dir = wd cmd.Stdout, cmd.Stderr = io.Discard, io.Discard if err := cmd.Start(); err != nil { return 0, err } // Don't wait: the game outlives this launcher. go func() { _ = cmd.Wait() }() return cmd.Process.Pid, nil } func buildLaunchArgs(res *auth.Result, testSID, gamePath string) string { args := [][2]string{ {"DEV.DataPathType", "1"}, {"DEV.MaxEntitledExpansionID", strconv.Itoa(res.MaxExpansion)}, // TestSID is the X-Patch-Unique-Id from session registration, NOT the // OAuth session id; the lobby only accepts the former. {"DEV.TestSID", testSID}, {"DEV.UseSqPack", "1"}, {"SYS.Region", strconv.Itoa(res.Region)}, {"language", language()}, {"resetConfig", "0"}, {"ver", readVer(gamePath)}, } ticks := sqex.RawTickCount() key := ticks & 0xFFFF0000 // top 16 bits; the game derives its key from its own uptime args = append([][2]string{{"T", strconv.FormatUint(uint64(ticks), 10)}}, args...) return sqex.BuildEncrypted(key, args) } // language is the ClientLanguage enum int: 0=Japanese, 1=English, 2=German, 3=French. func language() string { if v := os.Getenv("FFXIV_LANG"); v != "" { if l, err := strconv.Atoi(v); err == nil && l >= 0 && l <= 3 { return strconv.Itoa(l) } } return "1" } // gateLang maps the client-language enum to the code SE's server-status // endpoint expects in ?lang= (the game exe takes the bare int, the HTTP API // does not). func gateLang() string { return [4]string{"ja", "en", "de", "fr"}[langInt()] } func langInt() int { l, err := strconv.Atoi(language()) if err != nil || l < 0 || l > 3 { return 1 } return l } // readVer reads game/ffxivgame.ver; falls back to the base version. func readVer(gamePath string) string { const fallback = "2012.01.01.0000.0000" b, err := os.ReadFile(filepath.Join(gamePath, "game", "ffxivgame.ver")) if err != nil { return fallback } v := string(b) for len(v) > 0 && (v[len(v)-1] == '\n' || v[len(v)-1] == '\r') { v = v[:len(v)-1] } if v == "" { return fallback } return v }