docs: add design spec for runtime-observation MCP

Static Ghidra decompilation hit a wall on the alt-tab-while-not-paused
reset bug - state machine confirmed correct, but the actual render
function was never located and the cause couldn't be determined from
code alone. This is the trigger for building runtime-observation
tooling: a Windows-hosted debug agent (extending tools/dbg_path.py's
proven ctypes Win32 debug-loop approach) plus a thin MCP server
translating tool calls to it, reachable over the LAN from Caspian
(the machine that actually runs the game).
This commit is contained in:
pyr0ball 2026-07-03 23:26:46 -07:00
parent 763509e4bc
commit 70213dfa8e

View file

@ -0,0 +1,153 @@
# Havoc Runtime-Observation MCP -- Design Spec
**Date:** 2026-07-03
**Status:** approved by user, ready for implementation planning
## Why this exists
Static Ghidra decompilation of `HAVOC_NOCD.EXE` hit a wall investigating the alt-tab-while-
not-paused "resets to menu" bug (see `docs/FUNCTIONS.md` "Alt-tab / focus-loss investigation").
The state machine was confirmed correct by reading code; a candidate stale-object-pointer
hypothesis was ruled out by reading code; but the actual frame-render function was never
located, and the bug's real cause could not be determined from decompiled code alone. This is
the trigger condition noted in project memory for building runtime-observation tooling: when
static analysis can't answer a question, the next tool is watching the program actually run.
This spec covers **one** subsystem: an MCP server that lets an agent (this Claude Code
session, or a future one) attach a debugger to the running `HAVOC_NOCD.EXE` process, set
breakpoints/watchpoints, read/write memory and registers, and capture screenshots -- turning
"we can't tell what this does without running it" into an actual, drivable capability.
Two related ideas were raised and explicitly deferred, not designed here:
- An MCP/interaction layer for a future Godot remaster port (blocked: the Godot project is
still empty).
- A local-LLM agent on the muninn/sif cf-orch GPU nodes that plays the game autonomously (a
later automation layer that would sit on top of whichever MCP exists by then).
## Environment
- **Caspian** (`10.1.10.228`) is the Windows machine that actually runs the game, via
dgVoodoo2. It is the user's personal machine, not a CircuitForge/cf-orch homelab node.
- Caspian and the Linux RE box share the same NFS/SMB-mounted storage already used all
session for Ghidra work (`Z:\Development\devl\Havoc` on Windows, `/Library/Development/
devl/Havoc` on Linux) -- this already-proven bridge is why a new network/file-sharing
mechanism isn't needed for anything except the MCP protocol traffic itself.
- Claude Code can also run natively on Caspian via PowerShell (with SMB access to the same
share). This means the debug agent and MCP server can be built and iterated on directly by
an agent running on Caspian, rather than authored blind from Linux and round-tripped through
manual testing.
- There is existing precedent for exactly this kind of debugging: `tools/dbg_path.py`, a pure
`ctypes` Win32 debugger written during the original boot-crash investigation. It already
proves out `CreateProcess` with `DEBUG_ONLY_THIS_PROCESS`, `WaitForDebugEvent`, and reading
WOW64 (x86-on-x64) thread context -- including a `WOW64_CONTEXT` struct that already defines
`Dr0`-`Dr7` (the x86 hardware debug registers), which data breakpoints need. No admin rights
or third-party tooling (WinDbg/cdb) were required.
- **Future direction (informs the design, not required now):** the user intends to eventually
port Havoc to run under Wine on Heimdall (a CircuitForge homelab node), eliminating the need
for a separate Windows box entirely. Wine implements the same Win32 debug APIs `dbg_path.py`
already uses, so the debug agent's core logic should not need rewriting for that move --
only its host changes (Wine-on-Linux instead of native Windows).
## Architecture
Two separate processes on Caspian, both started manually alongside the game (no auto-launch):
```
Claude Code ──MCP over HTTP──▶ MCP server (0.0.0.0:<mcp_port>, LAN-reachable)
(this or a │
future session) │ localhost only
Debug agent (127.0.0.1:<agent_port>)
(extends tools/dbg_path.py)
│ ctypes Win32 debug APIs
HAVOC_NOCD.EXE (via dgVoodoo2)
```
- **Debug agent** owns all Win32-specific logic: the `ctypes` debug loop, breakpoints,
watchpoints, memory/register access, screenshot capture. Exposes a local JSON-over-TCP API
on `127.0.0.1` only -- never LAN-exposed directly.
- **MCP server** is a thin translation layer: implements the MCP protocol (Streamable HTTP
transport) and turns each MCP tool call into a call to the local debug agent. No debugging
logic or state of its own beyond what it forwards.
This split is deliberate for the future Wine-on-Heimdall move: only the debug agent needs to
run inside Wine (the only piece touching real Win32 debug APIs against the game process). The
MCP server could then run as a normal native Linux process on Heimdall, talking to the
Wine-hosted agent over the same local API -- no MCP/HTTP stack needs to work inside Wine at
all. Only the debug agent's host changes; its design and the MCP server's design do not.
## Components and tool surface
**Debug agent** (`tools/havoc_debug_agent.py`, extends `tools/dbg_path.py`):
| Operation | Purpose |
|---|---|
| `launch(path)` | Start the game under the debugger (or attach if already running) |
| `set_breakpoint(addr, condition=None)` | Software (`INT3`) breakpoint at a VA. Optional `condition` is a small declarative check evaluated on hit -- e.g. `{register: "eax", op: "!=", value: "0x47"}` or `{memory: "0x47eb48+0x18", size: 4, op: "!=", value: "0x47"}`. If false, silently resume; if true, actually stop and report |
| `set_watchpoint(addr, size, mode)` | Hardware data breakpoint (`read`/`write`/`readwrite`) using `DR0`-`DR3`/`DR7`. **Hard limit: 4 concurrent** (x86 has exactly 4 hardware debug address registers) -- callers must be told this limit explicitly, not discover it via a silent failure |
| `remove_breakpoint(addr)` / `remove_watchpoint(addr)` | |
| `list_breakpoints()` / `list_watchpoints()` | |
| `continue_execution(timeout)` | Resume until a breakpoint/watchpoint hits, the process exits, or `timeout` elapses (returns "still running" in that case -- never blocks forever) |
| `read_memory(addr, len)` / `write_memory(addr, bytes)` | |
| `get_registers()` | Full WOW64 register set |
| `screenshot()` | Capture the game window |
| `get_status()` | `running` / `stopped-at-breakpoint` (which one, why) / `exited` (with exit code) |
**MCP server** (`tools/havoc_mcp_server.py`): one MCP tool per debug-agent operation above,
plus `list_breakpoints`/`list_watchpoints` exposed directly. Binds `0.0.0.0:<mcp_port>` on
Caspian. Requires a shared-secret bearer token (see Security) on every request.
## Data flow (worked example: the alt-tab bug)
1. Agent calls `launch` (or the game is already running and this attaches to it)
2. Agent calls `set_watchpoint` on whatever memory holds current game/menu state, mode=write
3. User alt-tabs away and back on Caspian
4. The watchpoint trips the moment anything writes that memory; the debug agent reports which
instruction did it
5. Agent calls `get_registers()` and `read_memory()` around that address, plus `screenshot()`
to correlate with what's visually on screen at that instant
6. Agent calls `continue_execution()` to keep running, or sets a new breakpoint based on what
was just learned, repeating until the actual cause is found
## Error handling
- `continue_execution` requires a timeout; never blocks indefinitely
- Reads/writes to unmapped memory return a clear error, not a debug-agent crash
- Process exit (`EXIT_PROCESS_DEBUG_EVENT`) is detected and reported with exit code, not
treated as a hang or silent disconnect
- All tool calls are serialized (single lock) -- one game process, no concurrent-call races
- Requesting a 5th concurrent watchpoint returns a clear "limit is 4" error
## Security
The MCP server is LAN-reachable and exposes real remote influence over a running process
(memory read/write, execution control). Even on a trusted home LAN, this needs a lightweight
shared-secret bearer token (env var on Caspian, matching value in the MCP client config) so
an arbitrary other device on the LAN can't drive it. Nothing heavier than that is warranted
(no TLS, no rate limiting, no user accounts) -- this is a personal single-user dev tool, not a
production or multi-tenant system, and adding more would be overengineering for what it is.
## Testing
No meaningful way to unit-test real OS debug APIs against a live GUI game in isolation, so
validation is a manual acceptance checklist instead of an automated test suite:
1. Breakpoint at a known-good, already-understood address (e.g. `busywait_frame_limiter_60hz`
@ `0x40C8E0`) -- confirm register/memory values at the stop match what Ghidra's decompile
predicts.
2. Conditional breakpoint that's initially false (game running normally) then becomes true
(trigger the condition) -- confirm it only stops on the true case.
3. Watchpoint on `DAT_0047c48c` (the delta-time baseline, already fully understood from the
FPS-coupling investigation) -- confirm it fires exactly when
`compute_frame_delta_ticks_60hz_min1_clamp` writes it, and not otherwise.
4. Screenshot capture during a real gameplay session, confirmed to produce a viewable image.
5. End-to-end: reproduce the alt-tab bug live using the worked data-flow example above, and
confirm the tooling actually surfaces new information the static analysis couldn't.
## Build approach
Since Claude Code can run natively on Caspian via PowerShell with SMB access to the same
share, the recommended build path is to have an agent running there write and iterate on both
`havoc_debug_agent.py` and `havoc_mcp_server.py` directly against the real environment, rather
than authoring them blind from Linux and relying on manual round-trip testing by the user.