master
11
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
977448868c |
POST /difficulty — set the board from outside the game
First command in what will presumably be several, so the shape matters more
than the feature: name the OUTCOME, verify it against the world, and report
what the game now IS rather than that a click was sent.
POST /api/minesweeper/difficulty { "level": "expert" }
-> { "ok": true, "level": "expert", "changed": true }
The proxy forwards <prefix>/*, so this needed no platform-side change.
── clicks, not keys, and that was measured ──
The obvious mechanism is `alt+g` then the underlined letter. It does not work
reliably: the same sequence gave a different difficulty depending on what was
selected before, and `alt+g e` landed on Intermediate more than once. Arrow
navigation was no better. A menu keystroke is a SEQUENCE, so it depends on
timing and on where the highlight already was; a click is one event that names
its target. Six scrambled clicks in a row hit six correct items.
There is a second reason keys are wrong here. With no window manager, X input
focus is PointerRoot — keys go to whatever the pointer is over — so a sequence
that RESIZES the window can move it out from under the pointer and silently
drop the rest of its own keystrokes. That is exactly what the first attempt
did, and it looked like a mapping problem.
── two things that make it a command rather than a click ──
`withStableWindow` holds off the re-centring watcher for the duration. A menu
interaction is two clicks ~450ms apart and the watcher ticks every second, so
it WILL move the window between them, and the second click then lands where
the item used to be — which on this game is the board. A stray click on a live
board reveals a cell and can lose the game.
And the check is the window's SIZE, not that the click was sent: 152x214,
280x342, 504x342 are distinct, so the result is verifiable. That is what makes
retrying safe, and what makes a repeat a no-op (`changed: false`) rather than a
second difficulty change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
|
||
|
|
5c8c7937f5 |
stage 512x384, so a replay can open
"Your screen cannot fit a window of that size" on Extras > Play Recording. The replay window is 504x342 — exactly expert, which the 512x352 stage holds with ten pixels to spare — so the message is not about the window it is about to draw. Not a positional check either, which was the first guess: from a centred beginner window an expert window would run 236px off the right edge, and the game repositions it without complaint. It is a minimum-screen check wanting slack it never spends, roughly the title bar it does not have here. Measured rather than reasoned about, on a stage enlarged for the purpose: the replay window is 504x342, the file dialog 420x239. Then 512x352 verified refusing a replay and 512x384 verified opening one. The height is the whole fix; the width is unchanged. That matters because 42 more pixels of stage cost nothing at the sizes people use: the panel caps the scale at 2x, so any stage up to about 726x451 draws the game at exactly the same size. The stage only starts costing pixels once the fit falls under the cap — which is also why the number is generous rather than the minimum that passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b |
||
|
|
e4247cd760 |
smaller stage, half steps above 2x — bigger, still even
Trimming the stage alone does nothing, and that is worth writing down: the scale is snapped, so at 3x the game is 1512 device pixels wide whether the stage is 560 or 512. Shrinking the surround only pays when it crosses a step. So both halves land together. ── the stage ── 560x400 down to 512x352, which is expert (504x342, measured) plus four pixels a side. There is nothing left to give — intermediate is 342 tall too, so anything shorter clips a board people actually play. ── half steps from 2x ── Not a retreat from whole-number scaling. The unit that has to stay regular is the CELL, not the pixel: this is a 16px grid, so at 3.5x a cell is exactly 56 screen pixels — as even as 3x or 4x — and only sub-cell pixels alternate between three and four. Below 2x that alternation is a third of a pixel rather than a seventh, so whole numbers still apply there. The case that started all this, a fractional devicePixelRatio at nominal 1:1, put the CELL boundaries at 26.4px, which is a different thing entirely. Together, on a 1800x1400 device-pixel panel: 3.0x becomes 3.5x, and the game goes from 1512 to 1764 device pixels wide. Known cost, and it is in the README: the Extras > Auto Options dialog is 328 wide and opens 25px right of the game, so it needs a 554-wide stage to fit whole from beginner. It cannot be closed from the panel anyway — no window manager, no title bar, no OK button — so that is the thing to fix before this number is revisited. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b |
||
|
|
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 |
||
|
|
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 |
||
|
|
ad2aa632a5 |
hide the cursor the server draws — yours is the accurate one
`-nocursor`. The RFB client asks for Raw and CopyRect and nothing else, so x11vnc has no cursor pseudo-encoding to send a shape through and composites the pointer into the framebuffer instead. That is a second cursor in the picture, a round trip behind the real one, and on a bare X server with no window manager it is the big black X of the root cursor rather than an arrow. Verified on the live socket by parking the pointer at 100,100 and counting what is not stage-teal in a 48x48 patch around it: 960 pixels before, 0 after. Not fixed by teaching the client the cursor encodings — the answer is not to draw the remote cursor better, it is not to draw it at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b |
||
|
|
e6cec953e4 |
a fixed stage for the game, and close the VNC port nobody meant to open
── the stage ── The framebuffer is now the picture. 800x600 whatever the difficulty, the game centred in it by the sidecar, and the panel draws it entire — scaled to fit, centred by its flex parent, never cropped. Changing difficulty moves the board instead of resizing the panel. What this replaces parked the window at the origin and had the client crop to the window's size, polled every four seconds. Two things wrong with it. The panel resized under the player mid-game; and wine puts the window at 3,29 rather than 0,0, so cropping from the origin showed a black strip on top and cut a row of cells off the bottom — the "expert gets cut off" report. Centring runs on a one-second timer because nothing announces a resize: not RFB, which keeps the framebuffer, and not X without a window manager to listen through. One chained `xdotool search … getwindowgeometry` per tick, one process, and a `windowmove` only when the numbers actually changed. The client loses the geometry poll, the offscreen canvas and the crop with it. ── the port ── x11vnc was listening on 0.0.0.0:5900 and [::]:5900 with -nopw. Every account on this machine, every device on the tailnet and everything on the LAN could open it and take the display — which is precisely the hole the 5907 version was replaced to close, reopened wider and documented as impossible. `-unixsock` ADDS a unix socket. It does not remove the TCP one. Three flags do: -rfbport 0 for IPv4, -rfbportv6 0 for IPv6, -noipv6 for outgoing. `-rfbport 0` alone leaves [::]:5900 up, which is how this nearly shipped half-fixed. Found with `ss -lntp` on the running process, and that is the only check that was right about it at any point. Running the same argv by hand showed no IPv6 listener — most likely because the PM2 session still held [::]:5900 and the hand-run copy failed to bind it silently. Comments in session.ts and index.ts that asserted there was no port are corrected rather than deleted. Verified on the live socket: the streamed framebuffer is 447k teal pixels around a 152x214 window at 324,193 — dead centre — and window grey arrives as c0 c0 c0, no TCP listener for the x11vnc pid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b |
||
|
|
41020b1b65 |
build the wine prefix after the display exists, not before
A cold install could never work. `winetricks -q vb6run` runs Microsoft's redistributable, and that installer creates a window even when told to be quiet — so with no DISPLAY wine cannot load a graphics driver, the installer dies, and winetricks reports `returned status 43`. That message names neither wine nor a display, and reads like a corrupt download. ensurePrefix ran before Xvfb started, because building a prefix looks like preparation rather than something that needs a screen. It never showed up in testing here: this machine's prefix was seeded from the pre-extraction one, so the build path only ever ran against a prefix that was already complete. The first genuine cold install — alpha — failed on the first Start. Diagnosed from `err:winediag:nodrv_CreateWindow` in the wine output, which is the only line that says what is actually wrong. Order is now display, prefix, game. ensurePrefix takes the display explicitly rather than reading the ambient environment, so it cannot silently run without one again, and a failed build now tears the display down instead of leaving an Xvfb behind. Verified by moving this machine's prefix aside and starting cold: prefix built from nothing, msvbvm60.dll installed, session up with the window at 152x214, Z: confined, socket mode 700, and RFB 003.008 through the platform relay. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
b88cacecac |
confine the prefix again after wine boots, and fix two manifest package names
Two things the live install found. The manifest was refused at discovery. `apt: 'wine wine32:i386'` is two packages and an architecture suffix, and a package name may be neither — nor should it be, since `dpkg --add-architecture i386` has to happen first and no package name can express that. And Fedora's Xvfb package is `xorg-x11-server-Xvfb`, with a capital X, which a manifest package name may not have; the dnf entry is dropped rather than guessed, which turns into an instruction to the owner instead of a wrong install. More importantly: confining the prefix before wine starts is a confinement wine can undo. Observed directly — after a run, every com* link had come back with a fresh timestamp, because wine rebuilds dosdevices when it boots a prefix it thinks needs updating, and a wine package upgrade is enough to make it think so. Z: did not come back that time, but "the dangerous one happened not to regenerate" is not a property to rely on. confineAndVerify now runs again once the game is up and says so if Z: returned. Verified on the live server: Z: and the three home symlinks are gone from a prefix that had them, run/ is mode 700, nothing listens on 5907, and the RFB handshake arrives through the platform relay with a valid owner token and through nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
f3cc1242c4 |
minesweeper: extracted from the platform
Minesweeper X under wine on a headless display, as a plugin. It was /api/minesweeper, a Dashboard screen, an officerdev panel, a permission registry entry and a hand-written websocket provider in server.tsx. The socket is why it had to live in the platform: Bun's route table is fixed at serve(), so nothing installed later could add one. That ceiling is gone, and this is the first plugin through the per-plugin relay. Three things changed rather than moved: The sidecar owns the session. The platform used to spawn bin/session.sh detached, so the game outlived every restart, nothing supervised it, and a crashed run left wine processes holding the prefix — the next start hung rather than failed. PM2 supervises a sidecar, and stop() uses 'wineserver -k' scoped by WINEPREFIX instead of 'pkill -f wineserver', which would have killed every other wine prefix on the machine. x11vnc moved from 127.0.0.1:5907 to a unix socket in a mode-0700 directory. -nopw was only ever safe because of the machine, not the platform: any local account could open that port and take the display. A fixed port is squattable too. There is now no listener to authenticate against. The wine prefix is confined to C:. This is the one that mattered. The platform classified Minesweeper as kind:'execution' — never grantable — because a fresh prefix maps the whole filesystem to Z: and symlinks the profile into the owner's real home, and the game has Open and Save dialogs. A plugin permission has no kind and is always grantable, so extracting it without this would have converted a sealed capability into a file picker running as the owner. Re-asserted on every start, because wine restores z: whenever it updates a prefix. Not solved: one display and one game, so two grantees share a board. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |