run the game from inside the prefix, so it can save a replay

`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 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
This commit is contained in:
2026-08-23 22:10:30 +00:00
co-authored by Claude Opus 5
parent 26c8dd3e1a
commit 105068170c
4 changed files with 68 additions and 12 deletions
+21 -4
View File
@@ -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<void> {
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)) {