pastilhasandClaude Opus 5 26c8dd3e1a scale by whole pixels, and make the stage small enough to matter
Two halves of the same problem: the picture was sharp everywhere and wrong
overall.

── whole-number scaling ──

The canvas is now sized to the largest WHOLE number of device pixels per
source pixel that fits the panel, measured with a ResizeObserver and the
device pixel ratio. Below 1x it takes a unit fraction instead, so the rows
that survive are evenly spaced rather than dropped in clumps.

This is a 16px-per-cell bitmap from 1995. At a fractional scale one cell gets
three screen pixels and its neighbour gets two, the grid goes uneven and the
digits wobble — which reads as "not quite right" rather than as blur, and
`image-rendering: pixelated` does not help because unevenness is not blur.
A fractional devicePixelRatio produces exactly this at what looks like 1:1.

It is also allowed to scale UP past the framebuffer's own size, which was the
other half of it: `max-w-full` only ever shrinks, so on a wide panel the
canvas sat at its intrinsic size using a fraction of the pixels available.

── a smaller stage ──

800x600 down to 560x400. The stage is what gets scaled to fill the panel, so
every pixel of empty surround is panel the game does not get, and the whole-
number steps are far apart: in an 1866-device-pixel panel, 800 wide lands on
2x and 560 wide lands on 3x. Same panel, half again as much game.

560x400 is expert (504x342, measured on this machine) plus a margin. A custom
board bigger than the stage is now clipped at the right and bottom rather than
shrunk; `centre` clamps at zero so the menu bar stays reachable. Widen the two
constants if that turns out to be the wrong trade.

Verified: beginner centres at 204,93 on the new stage, which is exact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-23 21:26:10 +00:00

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 — 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 the standard fix for a wedged wine, and it is safe: the next start rebuilds it.
  • run/ — pid state, logs, and vnc.sock. Mode 0700, and that is load-bearing.

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 560x400 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 picks the largest whole number of DEVICE pixels per source pixel and every pixel of empty stage is panel the game does not get: in an 1866-device-pixel panel, 800 wide lands on 2x and 560 wide lands on 3x. 560x400 is expert (504x342) plus a margin. A custom board bigger than that is clipped at the right and bottom rather than shrunk, with the menu bar kept reachable.

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.

S
Description
Minesweeper X under wine on a headless display, streamed into an Officer panel
Readme
449 KiB
Languages
TypeScript 100%