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:
parent
763509e4bc
commit
70213dfa8e
1 changed files with 153 additions and 0 deletions
|
|
@ -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.
|
||||||
Loading…
Reference in a new issue