From 105068170c2e5f37b42a2cff3e8e58c3eb4594e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 22:10:30 +0000 Subject: [PATCH] run the game from inside the prefix, so it can save a replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Run-time error '76': Path not found` on finishing a game. Error 76 is VB6's "path not found", and the path it could not find was the game's own. Confining the prefix removed `z:`, which left `C:` as the only drive — and the executable was in the plugin checkout, which `C:` does not cover. So wine gave the game `\\?\unix\home\pastilhas\...\Minesweeper X.exe` as its own location (confirmed with `winepath`), VB6 file I/O cannot write to a path of that shape, and the first thing that needs to is the auto-saved recording. Which happens at the END of a game, so the failure arrived after somebody had played. The comment in prefix.ts already claimed "the game keeps its own directory, its settings and its replays". It did not. It does now: `installGame` copies the 68 KB executable into `C:\Minesweeper` on every start and the game runs from there, with cwd set to match so relative writes stop landing in C:\windows. Copied rather than symlinked — a symlink out of drive_c is a door out of the prefix, which is the exact thing confinePrefix exists to close. The cost is that replays now live inside prefix/, so deleting it to unwedge wine takes them along; that is in the README rather than left to be discovered. Verified end to end rather than by inspection: set Auto-Save Recording to All, played a beginner game to a loss, and `Beg 1.16 - Aug 23 2026 22.09.18 PM (Lost).mvr` appeared in C:\Minesweeper. Before this, that same moment was the error box. The default is "High scores", which is why an ordinary losing game never reproduced it. Co-Authored-By: Claude Opus 5 Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b --- README.md | 21 +++++++++++++++++++-- sidecar/paths.ts | 17 ++++++++++++++++- sidecar/prefix.ts | 25 +++++++++++++++++++++---- sidecar/session.ts | 17 ++++++++++++----- 4 files changed, 68 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8c0ce9c..11d9be6 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,27 @@ The download needs a browser `User-Agent`; the site answers `403` to curl's defa `DATA_PATH/minesweeper/` — never in this directory, which `plugins:update` will `git pull` over. -- `prefix/` — the wine prefix, ~600 MB, built on first `POST /start`. Deleting it is the standard fix for - a wedged wine, and it is safe: the next start rebuilds it. +- `prefix/` — the wine prefix, ~600 MB, built on first `POST /start`. Deleting it is still the standard fix + for a wedged wine and the next start rebuilds it — but it now takes the replays with it, see below. +- `prefix/drive_c/Minesweeper/` — where the game RUNS from, and where its replays land. The executable is + copied here on every start; `C:` is the only drive the prefix has, so this is the only directory the game + has a writable path to. Launched from the plugin checkout instead, its own path came back as + `\\?\unix\home\…` and finishing a game raised `Run-time error '76': Path not found` while it tried to + auto-save the recording. - `run/` — pid state, logs, and `vnc.sock`. **Mode 0700, and that is load-bearing.** +## Two gaps, both from having no window manager + +Worth knowing before you conclude something is broken. Neither is hard to fix; neither is fixed. + +**Some dialogs cannot be closed.** There is no window manager, so no window has a title bar. A dialog with +its own OK button is fine — the run-time error box is — but Extras → Auto Options has neither an OK button +nor a Cancel, and its only exit was the title bar. Restarting the sidecar clears it. + +**The panel sends no keyboard events at all.** `MinesweeperPanel` forwards pointer events and nothing else, +so Escape does not reach the game and a Custom board cannot be typed in. Adding keys to the RFB client is +the smaller half of this; wine also expects a window manager to hand it focus. + ## Two things worth knowing before you change anything **x11vnc has no password, and that is correct — but only because it also has no port.** It binds a unix diff --git a/sidecar/paths.ts b/sidecar/paths.ts index 0f8b7c3..229c204 100644 --- a/sidecar/paths.ts +++ b/sidecar/paths.ts @@ -30,9 +30,24 @@ export const RUN_DIR = join(STATE_DIR, 'run'); /** x11vnc's listening socket. A path, not a port: unreachable without traversing RUN_DIR. */ export const VNC_SOCKET = join(RUN_DIR, 'vnc.sock'); -/** Ships with the plugin. `import.meta.dir` is `/sidecar`. */ +/** Ships with the plugin. `import.meta.dir` is `/sidecar`. This is the SOURCE, not what runs. */ export const GAME_EXE = join(import.meta.dir, '..', 'game', 'Minesweeper X.exe'); +/** + * Where the game actually runs from, and where its settings and replays land. + * + * Inside the prefix, because once `confinePrefix` removes `z:` there is no drive letter for anywhere else + * on the machine. Launched from the plugin directory, the game's own path came back as + * `\\?\unix\home\…`, VB6 could not write beside its executable, and finishing a game raised + * `Run-time error '76': Path not found` as it tried to save the replay. + * + * The cost is that this is inside `prefix/`, so deleting the prefix — still the right fix for a wedged + * wine — now takes the replays with it. The alternative was a symlink out of `drive_c`, which is a door + * out of the prefix and exactly what the confinement exists to close. + */ +export const GAME_DIR = join(WINEPREFIX, 'drive_c', 'Minesweeper'); +export const GAME_IN_PREFIX = join(GAME_DIR, 'Minesweeper X.exe'); + export function ensureDirs(): void { mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 }); mkdirSync(RUN_DIR, { recursive: true, mode: 0o700 }); diff --git a/sidecar/prefix.ts b/sidecar/prefix.ts index d96feea..aa518c0 100644 --- a/sidecar/prefix.ts +++ b/sidecar/prefix.ts @@ -1,6 +1,6 @@ import { existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from 'node:fs'; import { join } from 'node:path'; -import { WINEPREFIX } from './paths'; +import { GAME_DIR, GAME_EXE, GAME_IN_PREFIX, WINEPREFIX } from './paths'; import { run } from './run'; /** @@ -13,8 +13,9 @@ import { run } from './run'; * cancel. Doing it inside the first `POST /start` means it happens when somebody actually wants to play, * it can report what it is doing, and a machine that installs the plugin and never opens it pays nothing. * - * It is also self-healing. Deleting the prefix — which is the standard fix for a wedged wine — is safe, - * because the next start rebuilds it. + * It is also self-healing. Deleting the prefix — which is the standard fix for a wedged wine — rebuilds on + * the next start. Since `installGame` it costs the player's saved replays, which live in + * `drive_c/Minesweeper`; everything else in there is generated. * * ── The two facts that decide the whole setup ── * @@ -127,7 +128,8 @@ export async function ensurePrefix(log: (line: string) => void, display: string) * ── What is left ── * * `C:` alone, pointing at the prefix. The game keeps its own directory, its settings and its replays; it can - * see nothing else. + * see nothing else. That sentence was aspirational until `installGame` below — the game's directory was in + * the plugin checkout, which `C:` does not cover, so it had no writable path of its own at all. * * ── It runs TWICE per start, and that is not belt-and-braces ── * @@ -156,6 +158,21 @@ export function confineAndVerify(log: (line: string) => void): boolean { return regenerated; } +/** + * Copy the game into the prefix, so it has a directory it can actually write to. + * + * Unconditional, not conditional on a size or hash check: it is 68 KB, it happens once per session start, + * and "is the copy stale" is a question worth not having. A `plugins:update` that changes the executable + * is then picked up by the next start rather than by whoever notices. + * + * Copied rather than symlinked. A symlink out of `drive_c` is a door out of the prefix, which is the exact + * thing `confinePrefix` exists to close. + */ +export async function installGame(): Promise { + mkdirSync(GAME_DIR, { recursive: true }); + await Bun.write(GAME_IN_PREFIX, Bun.file(GAME_EXE)); +} + export function confinePrefix(): void { const dosdevices = join(WINEPREFIX, 'dosdevices'); if (existsSync(dosdevices)) { diff --git a/sidecar/session.ts b/sidecar/session.ts index 01b1042..5be958e 100644 --- a/sidecar/session.ts +++ b/sidecar/session.ts @@ -1,7 +1,7 @@ import type { Subprocess } from 'bun'; import { existsSync, rmSync } from 'node:fs'; -import { GAME_EXE, RUN_DIR, VNC_SOCKET, ensureDirs } from './paths'; -import { WINE_ENV, confineAndVerify, ensurePrefix, prefixReady } from './prefix'; +import { GAME_DIR, GAME_EXE, GAME_IN_PREFIX, RUN_DIR, VNC_SOCKET, ensureDirs } from './paths'; +import { WINE_ENV, confineAndVerify, ensurePrefix, installGame, prefixReady } from './prefix'; import { run, waitFor } from './run'; /** @@ -212,8 +212,8 @@ export async function status(): Promise { }; } -const spawn = (cmd: string[], env: Record, logName: string): Proc => { - const proc = Bun.spawn(cmd, { env: { ...process.env, ...env }, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }); +const spawn = (cmd: string[], env: Record, logName: string, cwd?: string): Proc => { + const proc = Bun.spawn(cmd, { env: { ...process.env, ...env }, cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }); // Wine and x11vnc both say useful things only when something is wrong. Prefixed so `pm2 logs // officer-minesweeper` shows which process is complaining. for (const stream of [proc.stdout, proc.stderr]) { @@ -270,8 +270,15 @@ async function startInner(log: (line: string) => void): Promise { // ── the game ── if (!alive(game)) { + // Into the prefix first. Run from the plugin directory the game has no DOS path of its own, and the + // first thing that needs one is saving a replay — which happens at the END of a game, so the failure + // arrives after somebody has played rather than when they start. + await installGame(); + log('launching Minesweeper X under wine'); - game = spawn(['wine', GAME_EXE], env, 'wine'); + // cwd as well as the exe path: without it wine reports "could not open working directory" and starts + // the process in C:\windows, so anything the game writes relatively lands there instead. + game = spawn(['wine', GAME_IN_PREFIX], env, 'wine', GAME_DIR); const drew = await waitFor(async () => !!(await windowGeometry()), 30_000); if (!drew) {