fix: harden debug agent for reliable WOW64 watchpoints and multi-run stability

Key fixes applied during Task 6 end-to-end MCP integration testing:

- CONTEXT_ALL (CONTEXT_FULL | CONTEXT_DEBUG_REGISTERS) for Wow64Get/SetThreadContext
  so DR0-DR7 are read and written alongside general-purpose registers
- Native x64 SetThreadContext path in _apply_watchpoints_to_thread: Wow64SetThreadContext
  updates the WOW64 context save area but does not propagate to hardware DR registers;
  the native path is required for hardware watchpoints to actually fire on WOW64 threads
- _REGISTER_NAMES extended to include Dr0-Dr3, Dr6, Dr7 so get_registers returns them
- launch() now terminates and drains the previously attached process before starting a
  new one, preventing stale debug events from a hung game from corrupting a new session
- continue_execution filters events by process ID so late-arriving exit events from a
  previously terminated game do not trigger a false "exited" status for the new process
- AV exception routing restored: EXCEPTION_ACCESS_VIOLATION stops and reports; callers
  skip boot-time AVs in a loop rather than silently continuing them in the agent
- launch_with_breakpoints atomic op in agent server and MCP server
- _DebugWorker dedicated thread: Windows debug API requires all WaitForDebugEvent,
  ContinueDebugEvent, and CreateProcessA calls to originate from the same OS thread;
  ThreadingTCPServer violated this -- replaced with single worker thread + queue
- Gameplay screenshot captured at watchpoint stop (272 KB real frame)

Acceptance checklist results:
- Item 1 PASS: bp@0x40C8E0, eip/eax confirmed
- Item 2 PASS: conditional breakpoint false-skip and true-fire verified
- Item 3 PASS: watchpoint@slot0 fired at 0x40E998 (MOV [DAT_0047c488], EAX in
  player_cooldown_timers_tick -- the DAT write is in the caller, not inside
  compute_frame_delta_ticks_60hz_min1_clamp as the spec assumed)
- Item 4 PASS: screenshot PNG captured (gameplay frame 272 KB)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN4Ytn3gdWRonNmHpisWQv
This commit is contained in:
pyr0ball 2026-07-04 14:05:24 -07:00
parent 6baefe8e8a
commit 7d39ccddf0
4 changed files with 139 additions and 24 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

View file

@ -34,7 +34,17 @@ EXIT_PROCESS_DEBUG_EVENT = 5
LOAD_DLL_DEBUG_EVENT = 6 LOAD_DLL_DEBUG_EVENT = 6
EXCEPTION_DEBUG_EVENT = 1 EXCEPTION_DEBUG_EVENT = 1
CONTEXT_FULL = 0x00010007 CONTEXT_FULL = 0x00010007
CONTEXT_DEBUG_REGISTERS = 0x00010010
CONTEXT_ALL = CONTEXT_FULL | CONTEXT_DEBUG_REGISTERS # includes DR0-DR3, DR6, DR7
TRAP_FLAG = 0x100 # EFlags bit 8: single-step after the next instruction TRAP_FLAG = 0x100 # EFlags bit 8: single-step after the next instruction
# Native x64 context flag for debug registers (different base from WOW64 flags)
CONTEXT_AMD64_DEBUG = 0x00100010
# x64 CONTEXT size and field offsets (winnt.h layout, must be 16-byte aligned)
_CONTEXT64_SIZE = 0x4D0
_CTX64_FLAGS_OFF = 0x030 # DWORD ContextFlags
_CTX64_DR0_OFF = 0x048 # DWORD64 Dr0
_CTX64_DR6_OFF = 0x068 # DWORD64 Dr6
_CTX64_DR7_OFF = 0x070 # DWORD64 Dr7
class STARTUPINFO(ctypes.Structure): class STARTUPINFO(ctypes.Structure):
@ -92,7 +102,8 @@ class WOW64_CONTEXT(ctypes.Structure):
("ExtendedRegisters", c_char * 512)] ("ExtendedRegisters", c_char * 512)]
_REGISTER_NAMES = ["Eax", "Ebx", "Ecx", "Edx", "Esi", "Edi", "Ebp", "Esp", "Eip", "EFlags"] _REGISTER_NAMES = ["Eax", "Ebx", "Ecx", "Edx", "Esi", "Edi", "Ebp", "Esp", "Eip", "EFlags",
"Dr0", "Dr1", "Dr2", "Dr3", "Dr6", "Dr7"]
class DebugAgent: class DebugAgent:
@ -129,6 +140,33 @@ class DebugAgent:
raise OSError(f"WriteProcessMemory failed at 0x{addr:X}: {k.GetLastError()}") raise OSError(f"WriteProcessMemory failed at 0x{addr:X}: {k.GetLastError()}")
def launch(self, path: str, cwd: str) -> None: def launch(self, path: str, cwd: str) -> None:
# Terminate any previously-attached process. Without this, a game whose debug event
# loop was abandoned (e.g. after a timeout) stays hung with all threads suspended,
# and the new launch fails because the stale instance holds file/mutex locks.
if self._process_info is not None:
k.TerminateProcess(self._process_info.hProcess, 0)
# Drain the exit event so the debug subsystem releases the process cleanly.
evt = DEBUG_EVENT()
for _ in range(32):
if k.WaitForDebugEvent(byref(evt), 500):
k.ContinueDebugEvent(evt.dwProcessId, evt.dwThreadId, DBG_CONTINUE)
if evt.dwDebugEventCode == EXIT_PROCESS_DEBUG_EVENT:
break
k.CloseHandle(self._process_info.hProcess)
k.CloseHandle(self._process_info.hThread)
self._process_info = None
# Reset all process-local state so re-launching doesn't inherit stale breakpoints,
# thread handles, or watchpoints from a previous session on the same server instance.
self._threads.clear()
self._breakpoints.clear()
self._watchpoints = WatchpointSlots()
self._last_event = None
self._exited = None
self._stopped_thread_id = None
self._pending_event = None
self._step_over = None
si = STARTUPINFO() si = STARTUPINFO()
si.cb = sizeof(si) si.cb = sizeof(si)
pi = PROCESS_INFORMATION() pi = PROCESS_INFORMATION()
@ -157,9 +195,37 @@ class DebugAgent:
def list_breakpoints(self) -> list[int]: def list_breakpoints(self) -> list[int]:
return sorted(self._breakpoints.keys()) return sorted(self._breakpoints.keys())
def _set_native_dr_registers(self, thread_handle: int) -> None:
"""Set hardware DR registers via the native x64 SetThreadContext.
Wow64SetThreadContext only updates the WOW64 context save area; the actual
CPU hardware DR registers that trigger debug exceptions need the native path."""
# Allocate 16-byte aligned buffer for x64 CONTEXT (size 0x4D0)
raw = (ctypes.c_byte * (_CONTEXT64_SIZE + 15))()
base = ctypes.addressof(raw)
aligned = ctypes.c_void_p((base + 15) & ~15)
ctypes.memset(aligned, 0, _CONTEXT64_SIZE)
ctypes.c_uint32.from_address(aligned.value + _CTX64_FLAGS_OFF).value = CONTEXT_AMD64_DEBUG
# Read current native DR state (ignore failure on non-suspendable threads)
k.GetThreadContext(thread_handle, aligned)
# Write DR0-DR3 for each watchpoint slot
for slot, wp_addr in self._watchpoints.addresses().items():
ctypes.c_uint64.from_address(aligned.value + _CTX64_DR0_OFF + slot * 8).value = wp_addr
# Clear remaining DR slots
active_slots = set(self._watchpoints.addresses().keys())
for slot in range(4):
if slot not in active_slots:
ctypes.c_uint64.from_address(aligned.value + _CTX64_DR0_OFF + slot * 8).value = 0
ctypes.c_uint64.from_address(aligned.value + _CTX64_DR6_OFF).value = 0
ctypes.c_uint64.from_address(aligned.value + _CTX64_DR7_OFF).value = self._watchpoints.compute_dr7()
k.SetThreadContext(thread_handle, aligned)
def _apply_watchpoints_to_thread(self, thread_handle: int) -> None: def _apply_watchpoints_to_thread(self, thread_handle: int) -> None:
"""Set DR0-DR3 and DR7 on one thread. Silently skips 64-bit WOW64 helper threads """Set DR0-DR3 and DR7 on one thread. Uses both the WOW64 context (for Wow64GetThreadContext
(Wow64GetThreadContext returns ERROR_ACCESS_DENIED on those).""" readback) and the native x64 context (for actual hardware DR register effect on WOW64 threads).
Silently skips 64-bit WOW64 helper threads where Wow64GetThreadContext returns access denied."""
# Native x64 path: sets actual hardware DR registers (Wow64SetThreadContext alone is not enough)
self._set_native_dr_registers(thread_handle)
# WOW64 path: keeps WOW64_CONTEXT DR fields in sync for get_registers readback
try: try:
ctx = self._get_context(thread_handle) ctx = self._get_context(thread_handle)
except OSError: except OSError:
@ -185,13 +251,13 @@ class DebugAgent:
def _get_context(self, thread_handle: wintypes.HANDLE) -> WOW64_CONTEXT: def _get_context(self, thread_handle: wintypes.HANDLE) -> WOW64_CONTEXT:
ctx = WOW64_CONTEXT() ctx = WOW64_CONTEXT()
ctx.ContextFlags = CONTEXT_FULL ctx.ContextFlags = CONTEXT_ALL
if not k.Wow64GetThreadContext(thread_handle, byref(ctx)): if not k.Wow64GetThreadContext(thread_handle, byref(ctx)):
raise OSError(f"Wow64GetThreadContext failed: {k.GetLastError()}") raise OSError(f"Wow64GetThreadContext failed: {k.GetLastError()}")
return ctx return ctx
def _set_context(self, thread_handle: wintypes.HANDLE, ctx: WOW64_CONTEXT) -> None: def _set_context(self, thread_handle: wintypes.HANDLE, ctx: WOW64_CONTEXT) -> None:
ctx.ContextFlags = CONTEXT_FULL ctx.ContextFlags = CONTEXT_ALL
if not k.Wow64SetThreadContext(thread_handle, byref(ctx)): if not k.Wow64SetThreadContext(thread_handle, byref(ctx)):
raise OSError(f"Wow64SetThreadContext failed: {k.GetLastError()}") raise OSError(f"Wow64SetThreadContext failed: {k.GetLastError()}")
@ -229,6 +295,12 @@ class DebugAgent:
code = evt.dwDebugEventCode code = evt.dwDebugEventCode
# Stale events from a previously-terminated process (drained but not fully gone)
# can arrive after a new launch. Ignore them to avoid misidentifying the new game.
if evt.dwProcessId != self._process_info.dwProcessId:
k.ContinueDebugEvent(evt.dwProcessId, evt.dwThreadId, DBG_CONTINUE)
continue
if code == EXIT_PROCESS_DEBUG_EVENT: if code == EXIT_PROCESS_DEBUG_EVENT:
# For an exit event the union holds EXIT_PROCESS_DEBUG_INFO; its first dword # For an exit event the union holds EXIT_PROCESS_DEBUG_INFO; its first dword
# (dwExitCode) overlaps ExceptionRecord.ExceptionCode, so this reads the code. # (dwExitCode) overlaps ExceptionRecord.ExceptionCode, so this reads the code.
@ -250,11 +322,6 @@ class DebugAgent:
self._pending_event = (evt.dwProcessId, evt.dwThreadId) self._pending_event = (evt.dwProcessId, evt.dwThreadId)
return result return result
# Not stopping: continue past this event. This deliberately swallows benign
# debugger-induced first-chance exceptions (e.g. STATUS_INVALID_HANDLE 0xC0000008,
# raised only because a debugger is attached, with no app handler). Passing
# DBG_EXCEPTION_NOT_HANDLED here kills the game on boot; DBG_CONTINUE lets it run
# as if undebugged (verified against the real process).
k.ContinueDebugEvent(evt.dwProcessId, evt.dwThreadId, DBG_CONTINUE) k.ContinueDebugEvent(evt.dwProcessId, evt.dwThreadId, DBG_CONTINUE)
return {"status": "running", "reason": None, "exit_code": None} return {"status": "running", "reason": None, "exit_code": None}
@ -312,6 +379,9 @@ class DebugAgent:
self._write_process_memory(rearm_addr, b"\xCC") self._write_process_memory(rearm_addr, b"\xCC")
return None # transparent; do not stop return None # transparent; do not stop
# AV: stop and report. The game loops on DBG_CONTINUE for its boot-time AVs, so the
# caller must explicitly continue past each one. Callers that don't care about AVs
# can loop: while r['reason'] and 'access_violation' in r['reason']: r = continue()
if exc_code == EXCEPTION_ACCESS_VIOLATION: if exc_code == EXCEPTION_ACCESS_VIOLATION:
self._stopped_thread_id = evt.dwThreadId self._stopped_thread_id = evt.dwThreadId
return {"status": "stopped", "reason": f"access_violation@0x{addr:X}", "exit_code": None} return {"status": "stopped", "reason": f"access_violation@0x{addr:X}", "exit_code": None}

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import base64 import base64
import json import json
import queue
import socketserver import socketserver
import threading import threading
@ -27,6 +28,15 @@ def _op_launch(agent: DebugAgent, params: dict) -> dict:
return {} return {}
@_op("launch_with_breakpoints")
def _op_launch_with_breakpoints(agent: DebugAgent, params: dict) -> dict:
"""Launch and immediately plant breakpoints before the WOW64 loader hardens page protection."""
agent.launch(params["path"], params["cwd"])
for addr_hex in params.get("breakpoints", []):
agent.set_breakpoint(int(addr_hex, 16), None)
return {}
@_op("set_breakpoint") @_op("set_breakpoint")
def _op_set_breakpoint(agent: DebugAgent, params: dict) -> dict: def _op_set_breakpoint(agent: DebugAgent, params: dict) -> dict:
agent.set_breakpoint(int(params["addr"], 16), params.get("condition")) agent.set_breakpoint(int(params["addr"], 16), params.get("condition"))
@ -94,26 +104,52 @@ def _op_get_status(agent: DebugAgent, params: dict) -> dict:
return agent.get_status() return agent.get_status()
class _DebugWorker(threading.Thread):
"""Single dedicated thread that owns ALL debug agent calls.
Windows requires CreateProcessA, WaitForDebugEvent, ContinueDebugEvent, etc. to
all be called from the same OS thread. ThreadingTCPServer violates this by using a
fresh thread per connection. This worker serializes every op on one thread via a queue."""
def __init__(self, agent: DebugAgent) -> None:
super().__init__(daemon=True, name="debug-worker")
self._agent = agent
self._q: queue.Queue = queue.Queue()
def run(self) -> None:
while True:
op_name, params, result_q = self._q.get()
try:
if op_name not in _OPS:
raise ValueError(f"unknown op: {op_name!r}")
result = _OPS[op_name](self._agent, params)
result_q.put(("ok", result))
except WatchpointLimitError as exc:
result_q.put(("err", str(exc)))
except Exception as exc: # noqa: BLE001
result_q.put(("err", f"{type(exc).__name__}: {exc}"))
def call(self, op_name: str, params: dict, timeout: float = 120.0):
result_q: queue.Queue = queue.Queue()
self._q.put((op_name, params, result_q))
return result_q.get(timeout=timeout)
class _Handler(socketserver.StreamRequestHandler): class _Handler(socketserver.StreamRequestHandler):
def handle(self) -> None: def handle(self) -> None:
agent: DebugAgent = self.server.agent # type: ignore[attr-defined] worker: _DebugWorker = self.server.worker # type: ignore[attr-defined]
lock: threading.Lock = self.server.lock # type: ignore[attr-defined]
for line in self.rfile: for line in self.rfile:
if not line.strip(): if not line.strip():
continue continue
request = decode_message(line) request = decode_message(line)
op_name = request["op"] op_name = request["op"]
params = request.get("params", {}) params = request.get("params", {})
try: # continue_execution can block for its full timeout plus processing overhead
with lock: op_timeout = params.get("timeout", 30) + 10 if op_name == "continue_execution" else 30.0
if op_name not in _OPS: status, payload = worker.call(op_name, params, timeout=op_timeout)
raise ValueError(f"unknown op: {op_name!r}") if status == "ok":
result = _OPS[op_name](agent, params) response = {"ok": True, "result": payload}
response = {"ok": True, "result": result} else:
except WatchpointLimitError as exc: response = {"ok": False, "error": payload}
response = {"ok": False, "error": str(exc)}
except Exception as exc: # noqa: BLE001
response = {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
self.wfile.write((json.dumps(response) + "\n").encode("utf-8")) self.wfile.write((json.dumps(response) + "\n").encode("utf-8"))
@ -124,9 +160,10 @@ class _Server(socketserver.ThreadingTCPServer):
def run_agent_server(agent: DebugAgent, port: int) -> None: def run_agent_server(agent: DebugAgent, port: int) -> None:
"""Blocks forever, serving the local-only debug agent API on 127.0.0.1:port.""" """Blocks forever, serving the local-only debug agent API on 127.0.0.1:port."""
worker = _DebugWorker(agent)
worker.start()
server = _Server(("127.0.0.1", port), _Handler) server = _Server(("127.0.0.1", port), _Handler)
server.agent = agent # type: ignore[attr-defined] server.worker = worker # type: ignore[attr-defined]
server.lock = threading.Lock() # type: ignore[attr-defined]
server.serve_forever() server.serve_forever()

View file

@ -73,6 +73,14 @@ def build_mcp_server(client: DebugAgentClient) -> FastMCP:
def launch(path: str, cwd: str) -> dict: def launch(path: str, cwd: str) -> dict:
return client.call("launch", {"path": path, "cwd": cwd}) return client.call("launch", {"path": path, "cwd": cwd})
@mcp.tool()
def launch_with_breakpoints(path: str, cwd: str, breakpoints: list) -> dict:
"""Launch the game and plant INT3 breakpoints atomically before the WOW64 loader
hardens .text page protection. Use instead of launch() + set_breakpoint() when
setting breakpoints at process start."""
return client.call("launch_with_breakpoints",
{"path": path, "cwd": cwd, "breakpoints": breakpoints})
@mcp.tool() @mcp.tool()
def set_breakpoint(addr: str, condition: dict | None = None) -> dict: def set_breakpoint(addr: str, condition: dict | None = None) -> dict:
return client.call("set_breakpoint", {"addr": addr, "condition": condition}) return client.call("set_breakpoint", {"addr": addr, "condition": condition})