diff --git a/README.md b/README.md index 8f50d9f..7904c81 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,28 @@ The download needs a browser `User-Agent`; the site answers `403` to curl's defa auto-save the recording. - `run/` — pid state, logs, and `vnc.sock`. **Mode 0700, and that is load-bearing.** +## Best times live in the registry, and only a clean exit writes them + +`HKCU\Software\Brightsoft\Minesweeper X` in the prefix's `user.reg` — `Time1/2/3` for beginner, +intermediate and expert, `Name1/2/3` for the names, plus every other setting the game has. Nothing is +stored beside the executable except the recordings. + +**The game holds all of it in memory and commits it only when it exits cleanly.** `stop()` sent SIGTERM, +so every restart of this sidecar discarded every record set since the last one: six wins in one evening +left a Best Times table reading 9999.99. `stop()` now drives Game → Exit first and waits. + +Two things about that worth knowing before trusting it: + +- **`pm2 restart` will usually cut it short.** PM2 SIGKILLs 1600ms after SIGTERM by default and a full + stop here takes about 5.6 seconds, so the quit rarely finishes. `Pm2App` in the platform's + `plugins/ecosystem.ts` has no `kill_timeout` field to raise. An explicit `POST /stop` always completes. +- **A dialog blocks it**, like every other command that drives the menu, and it gives up rather than + clicking blindly. The log says so and the reap proceeds. + +Diagnosed by watching `user.reg` grow by 848 bytes at the moment the game exited; confirmed by writing a +time into the key by hand and seeing the game display it, then by switching difficulty and watching a clean +stop write `Width`/`Height`/`Mines` through to disk. + ## Two gaps, both from having no window manager Worth knowing before you conclude something is broken. Neither is hard to fix; neither is fixed. diff --git a/sidecar/menu.ts b/sidecar/menu.ts index 90f4ba7..d7b05c0 100644 --- a/sidecar/menu.ts +++ b/sidecar/menu.ts @@ -30,6 +30,9 @@ const EXTRAS_MENU = { x: 90, y: 11 }; /** "Play Recording…" in the Extras menu, and the File name box inside the dialog it opens. */ const PLAY_RECORDING_Y = 62; + +/** "Exit" at the bottom of the Game menu. The only way the game writes its settings — see `quitGame`. */ +const EXIT_Y = 176; const FILE_DIALOG = { name: 'Open Recording', nameBox: { x: 206, y: 190 } }; /** How far into an item to click horizontally — anywhere inside its width will do. */ @@ -202,3 +205,45 @@ async function findDialog(display: string): Promise<{ x: number; y: number } | n const y = /^Y=(\d+)$/m.exec(found.stdout); return x && y ? { x: Number(x[1]), y: Number(y[1]) } : null; } + +/** + * Quit the game the way it expects to be quit, so it writes its settings down. + * + * THIS IS WHY BEST TIMES KEPT VANISHING. Minesweeper X holds its whole settings key in memory and commits + * it to `HKCU\Software\Brightsoft\Minesweeper X` — `Time1/2/3`, `Name1/2/3` and the rest — only when it + * exits cleanly. `stop()` sent SIGTERM, so every restart of this sidecar threw away every record set since + * the last one. Six wins in one evening left a Best Times table reading 9999.99. + * + * Diagnosed by watching `user.reg` grow by 848 bytes at the exact moment the game exited, and confirmed by + * writing a time into that key by hand and seeing the game display it on the next start. + * + * Best effort by design. It returns false rather than throwing if the window is gone, a dialog is in the + * way, or the game ignores the click — `stop()` then reaps as it always did. Losing the settings is bad; + * failing to stop is worse. + */ +export async function quitGame(display: string, isGameAlive: () => boolean): Promise { + try { + if (await blockingDialog(display)) return false; + const win = await findWindow(); + if (!win) return false; + + const click = (x: number, y: number) => + run(['xdotool', 'mousemove', String(x), String(y), 'click', '1'], { DISPLAY: display }, 5_000); + + await click(win.x + GAME_MENU.x, win.y + GAME_MENU.y); + await Bun.sleep(400); + await click(win.x + ITEM_X, win.y + EXIT_Y); + + // Wine writes the registry a moment after the process goes, so the caller must not race ahead. + for (let i = 0; i < 20; i++) { + await Bun.sleep(250); + if (!isGameAlive()) { + await Bun.sleep(750); + return true; + } + } + return false; + } catch { + return false; + } +} diff --git a/sidecar/session.ts b/sidecar/session.ts index 4a7dd09..bf648a3 100644 --- a/sidecar/session.ts +++ b/sidecar/session.ts @@ -428,6 +428,25 @@ export async function stop(): Promise { centring = null; } + // FIRST, before anything else is torn down. A clean exit is the only time the game writes its settings + // key — best times included — so `stop()` used to discard every record set since the last start. Six + // wins in one evening left a Best Times table reading 9999.99. + // + // First for two reasons. It needs the display and the game both alive, and it is racing a clock: PM2 + // sends SIGTERM and SIGKILLs 1600ms later by default, while a full stop here takes about 5.6 seconds. + // Spending any of that budget on reaping x11vnc first is spending it on the part that does not matter. + // + // Even so, `pm2 restart` will usually cut this short — see the README. An explicit POST /stop always + // completes, and that is the path the platform uses when a panel asks. + // + // Imported dynamically on purpose: `menu.ts` imports from this file, and a static import would close the + // cycle. The dynamic form keeps the dependency one-way at module load, where it matters. + if (display && alive(game)) { + const { quitGame } = await import('./menu'); + const quit = await quitGame(display, () => alive(game)); + if (!quit) console.log('[minesweeper] the game did not quit cleanly; settings since the last start are lost'); + } + await reap(vnc, 'x11vnc'); vnc = null; rmSync(VNC_SOCKET, { force: true });