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.

speed

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:

AddressSymbolWhat it is
0x1005000fStatus1 = playing, 0x10 = game over
0x1005160iButtonCurwhich smiley is drawn: 2 = dead, 3 = shades
0x1005194cBombLeftmines minus flags, the number in the LED counter
0x1005330cBombStarttotal mines
0x1005334xBoxMacboard width
0x1005338yBoxMacboard height
0x1005340rgBlkthe board itself
0x100579ccSecelapsed 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;
Game over function
scroll = zoom · drag = pan · double-click = reset

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)
ClearField function
scroll = zoom · drag = pan · double-click = reset

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;
}
Count bombs function
scroll = zoom · drag = pan · double-click = reset

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 here
  • 0x40 — the player has opened this cell
SetBlk function
scroll = zoom · drag = pan · double-click = reset

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);
DrawBlk function
scroll = zoom · drag = pan · double-click = reset

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 bitsPicture
0x000x08opened, showing that many adjacent mines
0x0aa mine, revealed because you lost
0x0ba mine with a red X — a flag in the wrong place
0x0cthe mine you clicked, on a red background
0x0dquestion mark
0x0eflag
0x0fplain covered square
0x10the 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
StepSquare function
scroll = zoom · drag = pan · double-click = reset

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;
StartGame function
scroll = zoom · drag = pan · double-click = reset

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);
}
StepXY function
scroll = zoom · drag = pan · double-click = reset

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:

  1. You click. The OS works out which window is under the cursor.
  2. It puts a message on that window’s queue: a number for what happened (WM_LBUTTONDOWN), plus two parameters, wParam and lParam, for the details.
  3. 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);
MainWndProc function
scroll = zoom · drag = pan · double-click = reset

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:

ToolWhat it does
read_boardcurrent board, without spoiling mine positions
clickleft-click a cell, return the resulting board
flagright-click a cell, return the resulting board
new_gamerestart, optionally switching difficulty
launch_gamestart 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.