Letting LLMs play Windows XP Minesweeper
Intro
Every office job in the 90s came with Minesweeper. Not as a perk — as a pressure valve. You compiled something, you waited, you cleared a 9x9, you went back to work.
Your AI agent gets none of that. It refactors, it writes tests, it apologises for the tests, and then it just sits there. No lunch break, no window to stare out of, no Minesweeper :(
So I gave it Minesweeper.
Not a reimplementation — the real one. winmine.exe from Windows XP, straight off the Internet Archive.
Why shouldn’t it have some fun too?
The catch: a binary from 2001 has no API, no scripting hooks and no interest whatsoever in being automated by a language model.
Getting an agent into the game means reverse engineering the thing, reading the board straight out of its memory, and posting real mouse clicks back at the window — all wrapped in an MCP server.
Roughly a weekend of work, so a chatbot can enjoy a coffee break it will not remember.
Exploring Windows XP Minesweeper
winmine.exe was compiled in 2001, and has an image base of 0x1000000 with no ASLR.
That last part is the whole project. Every global in this binary lives at the same address in every process, on every machine, forever.
Nothing has to be scanned, pattern-matched or chased through a pointer chain. You find an address once and it is true for the rest of time.
The globals
Only eight of them matter:
| Address | Symbol | What it is |
|---|---|---|
0x1005000 | fStatus | 1 = playing, 0x10 = game over |
0x1005160 | iButtonCur | which smiley is drawn: 2 = dead, 3 = shades |
0x1005194 | cBombLeft | mines minus flags, the number in the LED counter |
0x1005330 | cBombStart | total mines |
0x1005334 | xBoxMac | board width |
0x1005338 | yBoxMac | board height |
0x1005340 | rgBlk | the board itself |
0x100579c | cSec | elapsed seconds |
iButtonCur deserves a note. There is no fWon global — winmine never stores whether you won. GameOver takes a fWin argument, uses it to pick a smiley, and throws it away:
iButtonCur = (fWin != 0) + 2;
DisplayButton(iButtonCur);
...
fStatus = 0x10;
So the only durable record of how the game ended is which face is on the button. To find out whether it won, my code reads the smiley. Somehow that feels correct.
The board is one flat array
rgBlk is a plain byte array: uint8_t[0x360], 864 bytes. One byte per cell.
Every access to it in the whole binary looks like this:
rgBlk[(y << 5) + x]
y << 5 is just y * 32. So to move down one row, you skip 32 bytes — always 32, whatever the board width happens to be. 864 bytes / 32 = 27 rows.
The board never uses all of it. A beginner board is 9x9 and an expert board is 30x16, but both live in the same array; only xBoxMac and yBoxMac change. The game just uses the top-left corner of a fixed 32-wide grid and ignores the rest.
Two consequences worth spelling out:
Coordinates start at 1, not 0. The top-left square you can actually click is rgBlk[1*32 + 1]. Row 0 and column 0 exist in the array but are not part of the board.
Row 0 and column 0 are a wall. When a game starts, ClearField fills the entire array with 0x0f (a blank covered square), then writes 0x10 into a one-cell-thick ring all the way around the playing field:
0x10 0x10 0x10 0x10 0x10 <- row 0 (the wall)
0x10 [ ] [ ] [ ] 0x10
0x10 [ ] [ ] [ ] 0x10 <- the real board
0x10 [ ] [ ] [ ] 0x10
0x10 0x10 0x10 0x10 0x10 <- row height+1 (the wall)
0x10 is not a real cell. It has no picture, it can’t be clicked, and no player will ever see it.
So why is it there? Because of this function, which computes the number you see when you open a cell:
int CountBombs(int x, int y) {
int result = 0;
for (int row = y - 1; row <= y + 1; row++)
for (int col = x - 1; col <= x + 1; col++)
if (rgBlk[(row << 5) + col] & 0x80) result++; // 0x80 = mine
return result;
}
It reads all nine neighbouring cells with no bounds checking at all. Open the top-left corner of the board and three of those nine reads go off the edge — into the wall. The wall byte is 0x10, which doesn’t have the mine bit set, so it counts as “no mine” and the answer comes out right anyway.
That’s the trick: the wall exists so this loop never has to check whether it’s at an edge. A few hundred wasted bytes to delete four if statements. On a 2001 machine, a good trade.
One byte per cell
Each cell byte is split into two halves. SetBlk shows the split — it keeps the top three bits and overwrites the bottom five:
rgBlk[(y << 5) + x] = (rgBlk[(y << 5) + x] & 0xe0) | iBlk;
The top bits are the facts about the cell:
0x80— there is a mine here0x40— the player has opened this cell
The bottom five bits are what the cell looks like. And they’re not an enum — they are literally an index into the array of loaded bitmaps. Here’s DrawBlk:
BitBlt(hdc, (x << 4) - 4, (y << 4) + 0x27, 16, 16,
rghdcBlk[rgBlk[(y << 5) + x] & 0x1f], 0, 0, SRCCOPY);
rghdcBlk[...] is the bitmap table. So the bottom five bits of a cell don’t describe the cell — they name the picture of it:
| Low bits | Picture |
|---|---|
0x00–0x08 | opened, showing that many adjacent mines |
0x0a | a mine, revealed because you lost |
0x0b | a mine with a red X — a flag in the wrong place |
0x0c | the mine you clicked, on a red background |
0x0d | question mark |
0x0e | flag |
0x0f | plain covered square |
0x10 | the wall — no picture, never drawn |
Putting the halves together: a covered cell hiding a mine is 0x8f (mine + blank square). Flag it and it becomes 0x8e. Click it instead and you get 0xcc — mine, opened, red-background-mine picture.
Opening a cell is StepSquare, and it’s four lines:
if (cell & 0x40) return; // already open, nothing to do
if ((cell & 0x1f) == 0x10) return; // it's the wall, ignore
if ((cell & 0x1f) == 0x0e) return; // flagged, so protected from clicks
rgBlk[iOffset] = CountBombs(x, y) | 0x40; // count neighbours, mark as opened
Note the last line: the adjacent-mine count is written into the picture bits. The number and the image of the number are the same five bits, because bitmap 3 is a picture of a 3.
And that’s the whole game. Board size, mine positions, what’s been opened, what’s been flagged, the clock, the outcome — all of it is a handful of fixed addresses and 864 bytes of flat array. Nothing left to reverse engineer; the only remaining question is how to read it out of a process that’s still running.
Two things I did not expect
Mines are placed by rejection sampling. StartGame just throws darts until they stick:
do {
x = Rnd(xBoxMac);
y = Rnd(yBoxMac) + 1;
} while (rgBlk[(y << 5) + x + 1] & 0x80); // occupied? try again
rgBlk[(y << 5) + x + 1] |= 0x80;
Pick a random cell; if it already has a mine, pick again. On expert that’s 99 mines squeezed into 480 cells, so the last few take a lot of retries. Nobody cared, and nobody was wrong not to.
Your first click can never be a mine. I always assumed this was folklore. It’s real, it’s in StepXY, and the implementation is wonderfully blunt:
if (rgBlk[(y << 5) + x] & 0x80) { // clicked a mine...
if (cBoxVisit != 0) { // ...and it isn't the first move
SetBlk(x, y, 0x4c); // boom
GameOver(0);
}
// first move: scan from the top-left for the first cell
// without a mine, and move the mine there
...
rgBlk[(y << 5) + x] = 0x0f; // this cell is now safe
rgBlk[(row << 5) + col] |= 0x80; // the mine lives there now
StepBox(x, y);
}
It doesn’t regenerate the board or pick somewhere fair. It scans in reading order and dumps the mine in the first free cell it finds — so on your first click, a mine teleports to the top-left of the board. Twenty-five years of players never noticed, because the board you see is the board after the move: there’s nothing to compare it against.
Reading the board out of a running process
We now know exactly what to read: a handful of fixed addresses, and 864 bytes at 0x1005340.
The catch is that those addresses mean nothing to us. They’re addresses in winmine’s address space, and every process gets its own. My program can’t dereference 0x1005340 any more than it can dereference your phone number — it has to ask the operating system to go and fetch those bytes from someone else’s memory.
Windows has an API for exactly that, and it takes four calls to get there. Roughly:
"Minesweeper" ──FindWindowW──► HWND
HWND ──GetWindowThreadProcessId──► PID
PID ──OpenProcess──► handle
handle ──ReadProcessMemory──► the bytes
Step 1: Find the window
Everything starts from the window, because that’s the only part of a running program that has a name you can look up. FindWindowW searches the top-level windows for a matching class name or title and hands back an HWND — an opaque handle that means “this window”.
// Try the visible title first...
if hwnd, err := FindWindowW(nil, name); err == nil && hwnd != 0 {
return hwnd, nil
}
// ...then fall back to the window class.
hwnd, err := FindWindowW(name, nil)
Two parameters, either of which can be nil to mean “don’t care”. I try the title "Minesweeper" first, then the same string as a class name, because localised builds of the game translate the title bar but not the class the window was registered with.
Step 2: Turn the window into a process
ReadProcessMemory doesn’t take windows, it takes processes. GetWindowThreadProcessId bridges the two: give it an HWND and it writes the owning process id into a pointer you pass in.
var pid uint32
GetWindowThreadProcessId(hwnd, &pid)
Mildly confusing API — it returns the thread id and gives you the process id via the out-parameter. We want the process id.
Step 3: Ask for access
A PID is just a number; it grants nothing. OpenProcess is where you actually request rights over another process, and the first argument is what you’re asking for:
proc, err := OpenProcess(PROCESS_VM_READ, false, a.pid)
if err != nil {
return nil, fmt.Errorf("OpenProcess: %w", err)
}
defer CloseHandle(proc)
PROCESS_VM_READ (0x0010) is read-only access to the target’s memory. There’s a matching PROCESS_VM_WRITE, and I deliberately don’t ask for it — this tool has no business modifying the game, and not holding the right is a better guarantee of that than remembering not to use it.
No special privileges are involved here. Reading the memory of another process running as the same user is ordinary, documented behaviour — it’s how debuggers work. You’d need more than this to poke at a process belonging to someone else, or to a protected system service.
The handle is a resource, so CloseHandle via defer closes it on the way out.
Step 4: Read the bytes
ReadProcessMemory is the payoff: hand it the process handle, an address in that process, a buffer in yours, and how many bytes to copy.
var n uintptr
ReadProcessMemory(proc, addrBoard, &b.cells[0], uintptr(len(b.cells)), &n)
That single call copies all 864 bytes of rgBlk into a Go array. The scalars are the same call with a size of 4, wrapped up so the loop reads nicely:
for _, f := range []struct {
addr uintptr
dst *uint32
}{
{addrWidth, &b.Width},
{addrHeight, &b.Height},
{addrMines, &b.Mines},
{addrTime, &b.TimeSeconds},
{addrOver, &over},
{addrStatus, &status},
} {
if *f.dst, err = readU32(proc, f.addr); err != nil {
return nil, err
}
}
Seven reads total, and we have the complete state of the game. This is the entire “hard part” of the project, and it’s about fifteen lines.
One caveat worth knowing: this is not an atomic snapshot. The game is free to run in between the reads, so in principle you could catch the board mid-update. In practice winmine only mutates state while handling input, and we control when it gets any.
Step 5: Make sure it’s actually winmine
Nothing about the above verifies we found the right program. If some other process happens to own a window called “Minesweeper”, those addresses will read fine — they’ll just contain whatever that program keeps at 0x1005334. Every subsequent index would then be quietly, catastrophically wrong.
So, a cheap sanity check:
if b.Width < 8 || b.Width > 30 || b.Height < 8 || b.Height > 24 {
return nil, fmt.Errorf("implausible board size %dx%d — not the Windows XP winmine.exe build?",
b.Width, b.Height)
}
Minesweeper boards live between 8x8 and 30x24. Anything outside that means we’re reading someone else’s memory and should say so loudly rather than produce a plausible-looking board out of noise.
Getting at the API from Go
Go’s standard library doesn’t wrap any of these. Rather than hand-write syscall boilerplate, you declare the signatures in a comment block and let mkwinsyscall generate the glue:
//go:generate go run golang.org/x/sys/windows/mkwinsyscall -output zsyscall_windows.go windows_windows.go
//sys FindWindowW(className *uint16, windowName *uint16) (hwnd windows.Handle, err error) = user32.FindWindowW
//sys GetWindowThreadProcessId(hwnd windows.Handle, pid *uint32) (tid uint32, err error) = user32.GetWindowThreadProcessId
//sys OpenProcess(access uint32, inheritHandle bool, pid uint32) (handle windows.Handle, err error) = kernel32.OpenProcess
//sys ReadProcessMemory(process windows.Handle, baseAddress uintptr, buffer *byte, size uintptr, read *uintptr) (err error) = kernel32.ReadProcessMemory
//sys CloseHandle(handle windows.Handle) (err error) = kernel32.CloseHandle
go generate turns each //sys line into a real function that lazy-loads the DLL and maps the return value to a Go error. Five lines of declaration instead of a few hundred lines of hand-rolled FFI.
One last thing before the agent sees any of this
There’s a problem with handing over what we just read.
The mine bit is 0x80, and it’s set on a cell whether or not that cell has been opened. A raw dump of rgBlk is a complete map of the minefield. Give that to the model and it isn’t playing Minesweeper, it’s reading the answer key and typing it back to us.
So the last thing readBoard does is take it away again:
b.hideUnrevealedMines()
func (b *Board) hideUnrevealedMines() {
if b.Over {
return
}
for row := 1; row <= int(b.Height); row++ {
for col := 1; col <= int(b.Width); col++ {
p := &b.cells[row*stride+col]
if *p&0x40 == 0 {
*p &= ^byte(0x80)
}
}
}
}
Walk the board and strip 0x80 from every cell that isn’t opened — but only while the game is still live, since losing reveals the mines anyway. A real player learns where a mine is by opening a cell or by dying, and now so does the agent.
Clicking without a mouse
The agent can see the board. Now it has to play, and winmine has no “open cell (4,7)” entry point to call.
What it does have is a window — and on Windows, a window is something you can send mail to.
Windows programs don’t check for clicks, they receive them
There’s no code in winmine that asks “has the mouse been clicked?”. Windows GUI programs are event-driven, and the events arrive by post:
- You click. The OS works out which window is under the cursor.
- It puts a message on that window’s queue: a number for what happened (
WM_LBUTTONDOWN), plus two parameters,wParamandlParam, for the details. - The program loops, pulling messages off its queue and passing each to a window procedure — one big switch on the message number.
winmine’s is MainWndProc, and that’s all it is: a switch over a few dozen message ids.
real mouse ──► OS ──► [ message queue ] ──► MainWndProc(hwnd, msg, wParam, lParam)
▲
PostMessageW ──────────────┘ (same queue, no mouse involved)
The arrow coming in from the side is the whole trick. PostMessageW is a public Win32 call that appends a message to any window’s queue. The window procedure receives a number and two integers, and has no way to tell where they came from. Hardware and forgery arrive through the same door in the same envelope.
Its sibling SendMessage calls the window procedure directly and blocks until it returns; PostMessage just drops the message in the queue and returns immediately. I use PostMessage, which has a consequence I’ll come back to.
Why the game believes us
You might expect the game to double-check — ask the OS where the cursor actually is and notice it’s nowhere near the board. It doesn’t, because in 2001 nobody was going to do this. Here’s the right-click case, straight out of MainWndProc:
case 0x204: // WM_RBUTTONDOWN
MakeGuess((LOWORD(lParam) + 4) >> 4, ((lParam >> 16) - 0x27) >> 4);
It converts the coordinates out of lParam into a cell and acts on them. No GetCursorPos, no validation. If the message says the click was at pixel (20, 63), that’s where you clicked.
So the entire input problem reduces to: put the right numbers in lParam.
Which pixel is cell (col, row)?
For mouse messages, lParam is two 16-bit values packed into one 32-bit word — x in the low half, y in the high half, in client coordinates (relative to the window’s top-left, not the screen).
I could measure the grid off a screenshot, but the game already told us where it draws things, back in DrawBlk:
BitBlt(hdc, (x << 4) - 4, (y << 4) + 0x27, 16, 16, ...);
Cell (x, y) is a 16x16 tile at pixel (x*16 - 4, y*16 + 39). The drawing code is the layout spec. Invert it, aim at the middle of the tile instead of the corner:
func cellLParam(col, row int) uintptr {
x := col*16 - 4 + 8
y := row*16 + 0x27 + 8
return uintptr(y)<<16 | uintptr(x)&0xffff
}
Sanity check against the game’s own conversion above: cell (1,1) → pixel (20, 63) → (20+4)>>4 = 1, (63-39)>>4 = 1. Round-trips.
A click is two messages
Posting WM_LBUTTONDOWN alone does nothing, because real mouse buttons let you change your mind — press over a cell, drag off, release, nothing happens. winmine splits the work accordingly:
case 0x201: // WM_LBUTTONDOWN — aim
fButton1Down = 1;
DisplayButton(1); // smiley goes "oh"
TrackMouse(...); // remember the cell in xCur/yCur
case 0x202: // WM_LBUTTONUP — fire
if (fButton1Down == 0) return DefWindowProc(...);
DoButton1Up(); // *now* open the cell
Down aims, up fires. Post only the down and the game sits there with a surprised face waiting for a release that never comes. So both go out:
func (a *app) postCell(col, row int, right bool) error {
down, up, button := WM_LBUTTONDOWN, WM_LBUTTONUP, MK_LBUTTON
if right {
down, up, button = WM_RBUTTONDOWN, WM_RBUTTONUP, MK_RBUTTON
}
lp := cellLParam(col, row)
if err := PostMessageW(a.hwnd, down, button, lp); err != nil {
return err
}
return PostMessageW(a.hwnd, up, 0, lp)
}
wParam says which buttons are currently held, and is 0 on release.
Two small asymmetries fall out of the decompilation. DoButton1Up reads xCur/yCur and never looks at lParam, so the coordinates only actually matter on the down message. And flagging, as we saw, happens on WM_RBUTTONDOWN alone — the up isn’t needed, but I post it rather than leave a phantom button held down in someone else’s process.
New game, without opening the menu
Restarting and switching difficulty live in the menu bar, and clicking through menus would mean knowing where they pop up. Fortunately menus don’t work that way: when you pick an item, Windows sends WM_COMMAND with the item’s id — no coordinates at all.
MainWndProc handles them in a plain switch, so a new expert game is one message:
PostMessageW(a.hwnd, WM_COMMAND, 0x20b, 0) // 0x1fe = new, 0x209/a/b = difficulties
No menu opens, nothing moves. This is arguably more faithful than clicking — it’s the exact message the menu itself would have sent.
The catch with posting
PostMessage returning doesn’t mean the click happened; it means the click is queued.
Read the board immediately afterwards and you get the state from before the move. So every tool waits a beat first:
// PostMessage is asynchronous: it returns as soon as the message is queued,
// not once the game has redrawn.
const settleDelay = 80 * time.Millisecond
80 ms covers a full expert-board cascade under Wine and nobody notices the pause. It’s a sleep, not a synchronisation primitive, and I’m not going to pretend otherwise — but the alternative is SendMessageTimeout and an afternoon thinking about deadlocks in a program whose job is entertaining a chatbot.
Every move therefore runs post → wait → read board, so the agent always sees the consequence of what it just did.
Handing it to the model: a stdio MCP server
Everything so far is a Go program that can read and drive the game. The last step is letting a model call it, and that’s what MCP is for: a small JSON-RPC protocol for exposing tools to an LLM. The whole server is one file and about 180 lines.
stdio is the transport, and it’s the boring one on purpose: the client spawns your binary and talks JSON-RPC over its stdin and stdout. No port, no auth, no server lifecycle — the process lives exactly as long as the conversation does.
func serveMCP() error {
server := mcp.NewServer(&mcp.Implementation{Name: "minesweeper", Version: "v0.1.0"}, nil)
mcp.AddTool(server, &mcp.Tool{Name: "read_board", Description: "..."}, readTool)
// click, flag, new_game, launch_game
return server.Run(context.Background(), &mcp.StdioTransport{})
}
One consequence worth remembering: stdout belongs to the protocol. A stray fmt.Println for debugging corrupts the message stream, which is why main writes errors to stderr.
Five tools, each a plain Go function whose argument and return types are the schema — the SDK reflects over them, so CellInput{Col, Row int} becomes the tool’s JSON schema without a hand-written spec:
| Tool | What it does |
|---|---|
read_board | current board, without spoiling mine positions |
click | left-click a cell, return the resulting board |
flag | right-click a cell, return the resulting board |
new_game | restart, optionally switching difficulty |
launch_game | start winmine.exe in the same Wine prefix |
Three details turned out to matter more than the plumbing:
Return the new board from every move. click and flag don’t answer “ok” — they run post → settle → read and hand back the full state. One round-trip per move instead of two, and the model can never act on a stale board.
Say no in the tool, not in the prompt. Clicking off the board or playing after a loss is rejected with an error that says what the valid range is ("(35,4) is off the board: valid columns are 1..30"). Models correct from that immediately; instructions in a system prompt they will cheerfully ignore.
Send the board twice, in two shapes. The tool returns both a rendered text grid and structured JSON:
1 2 3 4 5 6 7 8 9
+------------------
1 |# # 1 · · 1 # # #
2 |# # 2 1 1 2 # # #
3 |# # # F # # # # #
legend: # covered · empty (0) 1-8 adjacent mines F flag
The grid is for the model’s eyes; the JSON is so it never has to count columns to name a cell. That JSON also includes a frontier list — the covered cells adjacent to a number, which are the only ones a solver ever needs to think about. Everything else is derivable, but making it explicit removes the exact step models are worst at.
Wiring it up is one entry in the client’s MCP config, and because the binary is a Windows executable, the command is wine:
{
"mcpServers": {
"minesweeper": {
"command": "wine",
"args": ["/path/to/minesweeper-mcp.exe", "mcp"],
"env": {
"WINEPREFIX": "/path/to/prefix",
"MINESWEEPER_EXE": "C:\\winmine\\winmine.exe"
}
}
}
}
The same binary is also a normal CLI (read, click 4 7, expert), which is how all of it got debugged — the MCP layer is a thin wrapper over commands you can run by hand.
Closing
The code is at github.com/blazskufca/minesweeper-mcp.