quit the game cleanly on stop, so best times survive
Where they live: `HKCU\Software\Brightsoft\Minesweeper X` in the prefix's `user.reg`. `Time1/2/3` are beginner, intermediate and expert; `Name1/2/3` are the names, which is why the name you type sticks as the default. Nothing is stored beside the executable except the recordings. Why they were always empty: the game holds that whole key in memory and commits it ONLY on a clean exit. `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, which is what started this. `stop()` now drives Game > Exit and waits for the process to go before reaping. It runs FIRST, ahead of even reaping x11vnc, because it needs the display alive and it is racing PM2's clock. ── how it was pinned down ── Not by reading. `user.reg` grew by 848 bytes at the exact moment the game exited, which named the file. Writing `Time1="12.34"` / `Name1="Pastilhas"` into it by hand and restarting made the game display them, which proved the key and the load path. Then switching difficulty and stopping cleanly wrote Width/Height/Mines through from 30/16/99 to 8/8/10, which proved the write path carries in-memory state — beginner is 8x8 here, not the 9x9 I had assumed. ── the part this does NOT fix ── `pm2 restart` will usually still cut it short: PM2 SIGKILLs 1600ms after SIGTERM by default and a full stop takes about 5.6 seconds. That is not new — this sidecar's teardown has always exceeded that budget — but it does mean the fix only reliably applies to an explicit POST /stop. Raising it needs a `kill_timeout` on `Pm2App` in the platform's plugins/ecosystem.ts, which has no such field. Left alone rather than decided quietly; it is in the README. Also reverted: the 12.34 "Pastilhas" beginner record was my probe, not a game. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
This commit is contained in:
@@ -47,6 +47,28 @@ The download needs a browser `User-Agent`; the site answers `403` to curl's defa
|
|||||||
auto-save the recording.
|
auto-save the recording.
|
||||||
- `run/` — pid state, logs, and `vnc.sock`. **Mode 0700, and that is load-bearing.**
|
- `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
|
## Two gaps, both from having no window manager
|
||||||
|
|
||||||
Worth knowing before you conclude something is broken. Neither is hard to fix; neither is fixed.
|
Worth knowing before you conclude something is broken. Neither is hard to fix; neither is fixed.
|
||||||
|
|||||||
@@ -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. */
|
/** "Play Recording…" in the Extras menu, and the File name box inside the dialog it opens. */
|
||||||
const PLAY_RECORDING_Y = 62;
|
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 } };
|
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. */
|
/** 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);
|
const y = /^Y=(\d+)$/m.exec(found.stdout);
|
||||||
return x && y ? { x: Number(x[1]), y: Number(y[1]) } : null;
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -428,6 +428,25 @@ export async function stop(): Promise<SessionStatus> {
|
|||||||
centring = null;
|
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');
|
await reap(vnc, 'x11vnc');
|
||||||
vnc = null;
|
vnc = null;
|
||||||
rmSync(VNC_SOCKET, { force: true });
|
rmSync(VNC_SOCKET, { force: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user