Files
minesweeper/sidecar/prefix.ts
T
pastilhasandClaude Opus 5 105068170c 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
2026-08-23 22:10:30 +00:00

200 lines
10 KiB
TypeScript

import { existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { GAME_DIR, GAME_EXE, GAME_IN_PREFIX, WINEPREFIX } from './paths';
import { run } from './run';
/**
* Building the wine prefix, once, on first start.
*
* ── Why this is code and not an install step ──
*
* The plugin system has no install hook, and it should not grow one for this: an install that takes four
* minutes and downloads from Microsoft is not something to do behind a progress bar the owner cannot
* 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 — 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 ──
*
* Minesweeper X is a 68 KB i386 PE (machine `0x014c`, verified), so the prefix must be `win32`. A 64-bit
* prefix cannot load it and the failure is a bare `Bad EXE format`.
*
* It was built with Visual Basic 6, so it needs `MSVBVM60.DLL`, which wine does not ship. Without it the
* process exits immediately having printed one line — `err:module:import_dll Library MSVBVM60.DLL not
* found` — and nothing else. That is a quiet enough failure that it is worth checking for the DLL directly
* rather than inferring readiness from the prefix existing.
*/
/** The one file whose presence means this prefix can actually run the game. */
const VB6_RUNTIME = join(WINEPREFIX, 'drive_c', 'windows', 'system32', 'msvbvm60.dll');
export const prefixReady = (): boolean => existsSync(VB6_RUNTIME);
export const WINE_ENV = {
WINEPREFIX,
WINEARCH: 'win32',
// wine narrates every unimplemented call otherwise, and the game triggers thousands.
WINEDEBUG: '-all',
// Never prompt to install .NET or an embedded IE. The dialog appears on a display nobody is watching,
// and the start then hangs until it times out.
WINEDLLOVERRIDES: 'mscoree,mshtml=',
};
export type PrefixResult = { ok: boolean; detail: string };
/**
* Create the prefix if it is not there, then install the VB6 runtime into it.
*
* `winetricks -q vb6run` fetches Microsoft's own `VB6.0-KB290887-X86.exe` and verifies its checksum before
* unpacking, which is why this does not carry a redistributable of its own.
*/
export async function ensurePrefix(log: (line: string) => void, display: string): Promise<PrefixResult> {
// THE DISPLAY IS NOT OPTIONAL, and this cost a cold install to learn.
//
// `winetricks -q vb6run` runs Microsoft's redistributable, and that installer creates a window even
// when told to be quiet. With no `DISPLAY` wine cannot load a graphics driver, the installer dies, and
// winetricks reports `returned status 43` — which names neither wine nor a display and reads like a
// corrupt download.
//
// This used to run BEFORE Xvfb started, because building a prefix looks like preparation rather than
// something that needs a screen. It never showed up here: this machine's prefix was seeded from an
// older one, so the build path only ever ran on a prefix that was already complete. The first genuine
// cold install — alpha — failed immediately.
const env = { ...WINE_ENV, DISPLAY: display };
// Before the early return as well: an existing prefix may predate this, or have had `z:` restored by a
// wine upgrade. It is a few `unlink` calls, so it is cheaper to re-assert than to detect.
if (prefixReady()) {
confinePrefix();
return { ok: true, detail: 'prefix ready' };
}
if (!Bun.which('wine')) {
return { ok: false, detail: 'wine is not installed. On Debian or Ubuntu this also needs i386 multiarch: `sudo dpkg --add-architecture i386 && sudo apt update && sudo apt install wine wine32:i386`' };
}
if (!Bun.which('winetricks')) {
return { ok: false, detail: 'winetricks is not installed, and it is what fetches the Visual Basic 6 runtime the game needs' };
}
if (!existsSync(WINEPREFIX)) {
log('creating the 32-bit wine prefix (first run only, this takes a minute)');
// `wineboot -i` rather than letting the first wine call create it implicitly: implicit creation happens
// inside the game launch, races the window-detection poll, and looks like the game failing to start.
const boot = await run(['wineboot', '-i'], env, 300_000);
if (!boot.ok && !existsSync(WINEPREFIX)) {
return { ok: false, detail: `wineboot failed: ${(boot.stderr || boot.stdout).trim().slice(-400)}` };
}
}
log('installing the Visual Basic 6 runtime into the prefix (downloads from Microsoft)');
const tricks = await run(['winetricks', '-q', 'vb6run'], env, 600_000);
// winetricks exits 0 having done nothing often enough that the exit code is not the answer. The file is.
if (!prefixReady()) {
return {
ok: false,
detail: `the VB6 runtime did not install; msvbvm60.dll is still missing. winetricks said: ${(tricks.stderr || tricks.stdout).trim().slice(-400)}`,
};
}
// Last, not first: `winetricks` runs wine, and wine recreates `z:` on the way past.
confinePrefix();
log('prefix ready and confined to drive C');
return { ok: true, detail: 'prefix built' };
}
/**
* Cut the prefix off from the rest of the machine.
*
* ── Why this exists, and why it is not optional ──
*
* A fresh wine prefix is not a sandbox. `wineboot` creates `dosdevices/z: -> /`, which maps the ENTIRE
* filesystem to a drive letter, and symlinks the profile's Documents, Downloads and Music straight into the
* owner's real home. Minesweeper X has a replay feature with Open and Save dialogs, so anyone looking at the
* display gets a file browser over all of that, running as the server owner.
*
* That is precisely why the platform's permission registry classified Minesweeper as `execution` — a kind
* that can never be granted to anyone. But **a plugin permission is always grantable**, by design and with
* no field to opt out of. So extracting this feature without also fixing the prefix would have converted a
* structurally ungrantable capability into one an owner could hand to a Member, complete with a file picker
* over the owner's home. The extraction is what made this urgent; the hole was always there.
*
* `docs/affordances.md` has the rule that applies: if it can be fixed instead of documented, fix it. A note
* saying "do not grant this" outlives everyone's memory of why.
*
* ── 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. 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 ──
*
* Wine rebuilds `dosdevices` when it boots a prefix it thinks needs updating, and a wine package upgrade is
* enough to make it think so. Observed directly: after a run, every `com*` link had come back with a fresh
* timestamp — so a confinement that happens only before wine starts is a confinement wine can undo.
*
* `z:` did not come back in that observation, and the device nodes are harmless. But "the dangerous one
* happened not to regenerate this time" is not a property to rely on, so `confineAndVerify` runs again once
* the game is up and says so loudly if `z:` returned. The second pass is a handful of `unlink` calls.
*/
/**
* Confine, then check it held. Returns whether `z:` had to be removed a second time — which means wine
* regenerated it and the first pass was not enough.
*/
export function confineAndVerify(log: (line: string) => void): boolean {
const z = join(WINEPREFIX, 'dosdevices', 'z:');
const regenerated = existsSync(z);
confinePrefix();
if (regenerated) {
// Not fatal — it has just been removed again, before anything could look at the display. Worth saying
// out loud because it means a wine upgrade landed, and the NEXT thing to check is whether the prefix
// still runs the game at all.
log('wine regenerated the Z: drive mapping during startup; removed it again');
}
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)) {
for (const entry of readdirSync(dosdevices)) {
// Keep `c:` and only `c:`. Everything else is a door out of the prefix or a device node.
if (entry.toLowerCase() === 'c:') continue;
rmSync(join(dosdevices, entry), { force: true, recursive: true });
}
}
const users = join(WINEPREFIX, 'drive_c', 'users');
if (!existsSync(users)) return;
for (const user of readdirSync(users)) {
const profile = join(users, user);
for (const folder of readdirSync(profile)) {
const path = join(profile, folder);
// A symlink here points into the real home. Replace it with an ordinary directory so the game still
// has somewhere to save a replay, and that somewhere is inside the prefix.
if (!lstatSync(path).isSymbolicLink()) continue;
rmSync(path, { force: true });
mkdirSync(path, { recursive: true });
}
}
}