The 2x cap flattened the panel's zoom control into doing nothing, and the reason is worth writing down: `fit` is the SAME NUMBER at every zoom level. CSS `zoom` takes off `offsetWidth` exactly what it adds to the pixel ratio, so the fit is zoom-invariant by construction — and capping it therefore caps every zoom level to the same size. So the cap applies to the baseline, the size at 100%, and zoom multiplies what comes out. 100% stays exactly where it is; zooming in goes past 2x again. Quarter steps now go all the way down rather than whole numbers under 2x. With whole numbers, zooming out from a 2x baseline fell straight to 1x and halved the board in one press. Cells stay whole at every quarter — 20px at 1.25x — which was always the property that mattered, not the size of the step. The resulting ladder over the panel's 70-160% range: 1.25, 1.5, 1.75, 2.0, 2.0, 2.25, 2.5, 2.75, 3.0, 3.0. Eight of the ten presses move it; two land on the same quarter, which is the price of keeping the grid even. Also `overflow-hidden` on the box, so zooming past what the panel holds clips rather than shoving the status line out of the way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
172 lines
10 KiB
Markdown
172 lines
10 KiB
Markdown
# 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.**
|
||
|
||
## 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 512x352
|
||
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 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.
|