Files
minesweeper/README.md
T
pastilhasandClaude Opus 5 035c374563 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
2026-08-23 23:50:37 +00:00

199 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Minesweeper
Minesweeper X, running under wine on a headless X display, streamed into an Officer panel.
It is a game. It is also the smallest honest proof that Officer can put an arbitrary X11 program in a
panel — a framebuffer, a process on it, a VNC server reading it, a client in the browser — and it is the
first plugin to use the platform's per-plugin websocket relay.
## What it needs
Six host binaries, declared in `manifest.ts` and checked before install: `wine`, `Xvfb`, `x11vnc`,
`xdotool`, `xdpyinfo`, `winetricks`.
The awkward one is wine. Minesweeper X is a **32-bit** PE (machine `0x014c`, Visual Basic 6), so the host
needs i386 multiarch and a `win32` prefix. A 64-bit-only wine cannot load it, and the failure is a bare
`Bad EXE format`. On Debian or Ubuntu:
```
sudo dpkg --add-architecture i386
sudo apt update
sudo apt install wine wine32:i386 xvfb x11vnc x11-utils xdotool winetricks
```
## The game
[Minesweeper X](https://minesweepergame.com/download/minesweeper-x.zip) — the competition build, with
timing and replay extras. `game/Minesweeper X.exe` is committed; it is 68 KB and it is the thing being
run rather than state produced by running it.
| | |
| --- | --- |
| zip sha256 | `21089193924919ef876a5b464f06bed9cc149867c51e40c259288ebe623014f0` |
| exe sha256 | `e414c207adaa14999b0464c3d0a701f20ed11282d5ed0f84b27b62d25389f661` |
The download needs a browser `User-Agent`; the site answers `403` to curl's default.
## Where its state lives
`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 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.**
## 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.
**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.
**Keys go to the canvas, and only while it has focus.** It takes focus on mousedown — the gesture you use
to play — and `keysymFor` in `rfb.ts` maps `KeyboardEvent.key` to an X11 keysym. Anything with a modifier,
and anything unmapped, is left to the browser rather than swallowed: this is one panel on a dashboard, not
a full-screen viewer, so a global key trap would be the wrong shape.
Keys reach wine perfectly well without a window manager — verified by pressing F2 on the display and
watching the board reset. The Auto Options dialog ignoring Escape is that VB6 form having no Cancel button,
which is a different thing from keys not arriving.
## 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
socket inside `run/`, mode 0700. The kernel refuses every other account before x11vnc is asked anything,
so a password would be a second lock on a door only one person can reach. The version this replaced bound
`127.0.0.1:5907` with `-nopw`, which is a different claim entirely — every local account could open it and
take the display.
**`-unixsock` does not remove the TCP listener, and the first alpha shipped believing it did.** It adds a
unix socket beside the port; x11vnc was on `0.0.0.0:5900` and `[::]:5900` with no password, reachable from
the whole tailnet and the whole LAN, under a comment saying there was no port at all. Three flags close it
and each one earns its place: `-rfbport 0` for IPv4, `-rfbportv6 0` for IPv6 — `-rfbport 0` alone leaves
`[::]:5900` up — and `-noipv6` for outgoing sockets.
The check that matters is `ss -lntp` against the **running** process. Reading the arguments cannot show a
listener that exists because a flag is missing, and running the same arguments by hand did not reproduce
it either.
**The game is centred on a fixed stage, and the whole stage is what you see.** The framebuffer is 512x384
whatever the difficulty, `sidecar/session.ts` keeps the window in the middle of it, and the panel draws the
framebuffer entire — scaled to fit, never cropped. So changing difficulty moves the board rather than
resizing the panel, and the surround is teal (`-solid`) rather than the root's black.
**The stage is small because the panel scales it by whole numbers.** Pixel art at a fractional scale gets
uneven cells — some three screen pixels wide, their neighbours two — which reads as "not quite right"
rather than as blur, and `image-rendering: pixelated` does not help because unevenness is not blur. So the
panel snaps the scale, in DEVICE pixels, to QUARTER steps. Quarters are safe because the unit that has to
stay regular is the CELL: the board is a 16px grid, so every quarter step gives a whole cell — 20px at
1.25x, 36px at 2.25x — and only sub-cell pixels alternate. Quarters rather than halves because coarse steps
overshoot, and quarters all the way down rather than whole numbers under 2x because otherwise zooming out
from a 2x baseline falls straight to 1x and halves the board in one press.
The stage is 512x384 rather than 512x352 because the game refuses to open a REPLAY on a screen only ten
pixels taller than the board — "Your screen cannot fit a window of that size", for a window it is about to
draw at exactly 504x342. It wants slack it never spends. Growing the stage is free at the sizes people use:
the scale is capped at 2x, so anything up to about 726x451 draws the game identically.
The BASELINE — the size at 100% — is capped at 2x. Fitting the panel is not the same as looking right, and
past 2x this stops reading as a window on a desktop.
The panel's own zoom control then multiplies that, so zooming in still goes past 2x and zooming out still
shrinks. This needs saying because it is not automatic: `fit` is the same number at every zoom level, since
CSS `zoom` takes off `offsetWidth` exactly what it adds to the pixel ratio. A cap applied to the result
rather than the baseline therefore flattens the zoom control into doing nothing, which is how the first
version of it shipped.
Two things the scale has to know about that are easy to miss. `devicePixelRatio` is not the whole story:
the panel header has its own CSS `zoom`, so the effective ratio is `devicePixelRatio × zoom`, read as
`getBoundingClientRect().width / offsetWidth`. And the stage is fitted into the box MINUS a margin, because
filling the panel edge to edge is most of what "too large" looks like.
That makes the size QUANTISED, which is the thing to understand before changing the stage: at 3x the game
is the same width whether the stage is 512 or 560. Trimming the surround is worth nothing on its own — it
only helps when it crosses a step. 512x352 is expert (504x342) plus four pixels a side, and there is no
room left: intermediate is 342 tall too. A custom board bigger than that is clipped at the right and bottom
rather than shrunk, with the menu bar kept reachable, and the Auto Options dialog needs a 554-wide stage to
fit whole from beginner.
It used to park the window at the origin and have the client crop to the window's size, polled every four
seconds. That resized the panel under the player, and it was off by the 3x29 wine puts between the root
origin and the window — a black strip along the top and a row of cut-off cells along the bottom, worst on
expert. `GET /status` still reports the window geometry; nothing consumes it now, it is a diagnostic.
**The wine prefix is confined to `C:` on every start.** A fresh prefix maps the whole filesystem to `Z:`
and symlinks the profile into the real home, and Minesweeper X has Open and Save dialogs for its replays.
That is why this was an ungrantable `execution` capability in the platform. `sidecar/prefix.ts` closes it
instead, which is what makes the plugin's permission safe to grant. Wine restores `z:` whenever it updates
a prefix, so the confinement is re-asserted rather than done once.
## Contract
```
GET /api/minesweeper/status { installed, running, window, error? }
POST /api/minesweeper/start idempotent; builds the prefix on a cold install (minutes)
POST /api/minesweeper/stop
/api/plugin-ws/minesweeper the display, as RFB
```
## Where this came from
Built first as a standalone directory, `officer-dev/wine-minesweeper`, and folded in here when it became a
plugin. Two things from that work are worth keeping, because both are judgement calls somebody will
otherwise make again from scratch.
**Wine came from Ubuntu noble, not the WineHQ repository.** The distribution package is older — 9.0 — and
that is fine for a 2017 VB6 application. The alternative was adding a third-party apt source, with its key
and its update cadence, to a machine that runs a bitcoin wallet and a mail store, for a game of Minesweeper.
If a future version genuinely needs newer wine, that trade is worth revisiting; until then it is not.
**Enabling i386 pulled four krb5 libraries forward** within noble-updates. That was checked before
installing rather than after: nothing moved near libc, systemd, openssl or postgres. Worth re-checking on
any host where `dpkg --add-architecture i386` is new, since it is the one step here that changes how the
whole machine resolves packages and is never undone by uninstalling the plugin.
### The original verification baseline
From the standalone build, on Ubuntu noble with wine 9.0. Useful as a comparison when something feels wrong
rather than as a test to run:
- The game launches in about a second and draws the classic beginner board at **152x214**.
- A synthetic `xdotool` click opens a region and starts the timer — so **input reaches the game**, which is
the part that matters for a playable stream and the part a screenshot cannot tell you.
- x11vnc completes an **RFB 003.008** handshake and reports an 800x600 32bpp framebuffer.
- Cold start, display through to game window, in under two seconds. That excludes building the prefix,
which happens once and takes minutes.
The shell prototype that ran all this (`bin/session.sh`) is not kept. Everything it knew — waiting for a
usable display rather than a live pid, parking the window at the origin, and clearing the whole wine
process family on stop — is in `sidecar/session.ts`, with the reasoning attached to the code that depends
on it.