Implements set_watchpoint/remove_watchpoint/list_watchpoints via Wow64SetThreadContext on DR0-DR3/DR7. Skips 64-bit WOW64 helper threads (Wow64GetThreadContext returns ERROR_ACCESS_DENIED on those -- found against the real process). Propagates active watchpoints to new threads as they spawn. Re-armable breakpoints: on breakpoint hit, restores original byte and sets EFlags trap flag; the resulting single-step re-plants the 0xCC, making breakpoints persistent across multiple hits. EXCEPTION_SINGLE_STEP handler distinguishes watchpoint trips (Dr6 bits 0-3 set) from re-arm single-steps (Dr6 clear). Smoke test confirmed: re-armed breakpoint fires on second continue; watchpoint on 0x0047C48C set on slot 0 -- full watchpoint trip validation requires gameplay state==0x47 (deferred to Task 6 end-to-end run). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TN4Ytn3gdWRonNmHpisWQv
85 lines
3.8 KiB
Python
85 lines
3.8 KiB
Python
"""Manual acceptance smoke tests for the Win32 debug agent. NOT a pytest file -- these
|
|
launch the real HAVOC_NOCD.EXE under the debugger and need a live Windows session.
|
|
|
|
Run from the repo root: python -m tools.havoc_debug_agent_smoke_test
|
|
"""
|
|
|
|
from tools.havoc_debug_agent import DebugAgent
|
|
|
|
GAME = r"Z:\Development\devl\Havoc\HAVOC_NOCD.EXE"
|
|
CWD = r"Z:\Development\devl\Havoc"
|
|
|
|
|
|
def task2_breakpoint_smoke() -> None:
|
|
"""Task 2: breakpoint at busywait_frame_limiter_60hz (0x40C8E0) should trip during the
|
|
boot palette-fade / state transition and report eip == 0x40C8E0."""
|
|
agent = DebugAgent()
|
|
agent.launch(GAME, CWD)
|
|
agent.set_breakpoint(0x40C8E0)
|
|
result = agent.continue_execution(timeout=30)
|
|
print("continue_execution ->", result)
|
|
if result["status"] == "stopped":
|
|
regs = agent.get_registers()
|
|
print("registers ->", {r: hex(v) for r, v in regs.items()})
|
|
assert regs["eip"] == 0x40C8E0, f"expected eip 0x40C8E0, got {regs['eip']:#x}"
|
|
print("TASK 2 SMOKE: PASS")
|
|
else:
|
|
print(f"TASK 2 SMOKE: did not stop at breakpoint (status={result['status']})")
|
|
|
|
|
|
def task3_watchpoint_smoke() -> None:
|
|
"""Task 3: write-watchpoint on the delta-time baseline (0x0047c48c).
|
|
compute_frame_delta_ticks_60hz_min1_clamp is only called from player_cooldown_timers_tick
|
|
which only runs during gameplay (state==0x47). So: stop at the known boot breakpoint first
|
|
to confirm debugging works, set the watchpoint while paused, then continue. Requires the
|
|
user to start a race/level within the 90s window to trigger the write.
|
|
Expected EIP at watchpoint trip: inside 0x4092D0-0x409328."""
|
|
agent = DebugAgent()
|
|
agent.launch(GAME, CWD)
|
|
|
|
# Phase 1: confirm basic debugging is live by stopping at the boot-time frame limiter
|
|
agent.set_breakpoint(0x40C8E0)
|
|
r = agent.continue_execution(timeout=30)
|
|
if r["status"] != "stopped":
|
|
print(f"TASK 3 SMOKE FAIL: game did not hit boot breakpoint ({r})")
|
|
return
|
|
print("Phase 1: stopped at boot breakpoint OK")
|
|
|
|
# Phase 2: set the watchpoint while the thread is suspended (DR regs applied to known-live thread)
|
|
slot = agent.set_watchpoint(0x0047C48C, size=4, mode="write")
|
|
print(f"Phase 2: watchpoint set on 0x0047C48C -> slot {slot}")
|
|
|
|
# Phase 3: wait for gameplay to begin; user must start a level within the timeout window
|
|
print("Phase 3: continuing -- start a level to trigger compute_frame_delta_ticks_60hz_min1_clamp (90s window)")
|
|
result = agent.continue_execution(timeout=90)
|
|
print("continue_execution ->", result)
|
|
reason = result.get("reason", "")
|
|
if result["status"] == "stopped" and "watchpoint" in reason:
|
|
regs = agent.get_registers()
|
|
eip = regs["eip"]
|
|
print("EIP at watchpoint trip:", hex(eip))
|
|
assert 0x4092D0 <= eip <= 0x409328, (
|
|
f"expected eip inside compute_frame_delta_ticks_60hz_min1_clamp "
|
|
f"(0x4092D0-0x409328), got {eip:#x}"
|
|
)
|
|
print("TASK 3 SMOKE: PASS (watchpoint fired)")
|
|
elif result["status"] == "stopped" and "breakpoint" in reason:
|
|
# Re-armed breakpoint fired again -- confirms re-arming mechanism works.
|
|
# Watchpoint was set on slot 0 but needs gameplay (state==0x47) to trigger.
|
|
print(
|
|
f"TASK 3 SMOKE: re-armed breakpoint confirmed ({reason}), "
|
|
f"watchpoint set on slot {slot} but target requires gameplay state to be written. "
|
|
f"Re-run and start a level during the 90s window to validate watchpoint fire."
|
|
)
|
|
else:
|
|
print(
|
|
f"TASK 3 SMOKE: unexpected result (status={result['status']}, reason={reason})"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
if len(sys.argv) > 1 and sys.argv[1] == "task3":
|
|
task3_watchpoint_smoke()
|
|
else:
|
|
task2_breakpoint_smoke()
|