Root causes from the 2026-06-09 session (51 games, 45 failed): - Act desync: add TownManager.detect_current_act() and Bot._verify_town_location(); every maintenance/end-run retry and fallback now verifies the physical act instead of hardcoding town starts. open_wp/go_to_act self-heal act mismatches. Never run A1 pathing when travel to A1 failed. - NPC dialogue: poll action buttons up to 2.5s after click instead of a single-frame check (premature retry click was closing the dialog). - Stats integrity: log_end_game skips duplicate calls (phantom 0s "successful" games were resetting the consecutive-fail breaker); clear stale failure reason at game start; set chicken flag before bot.stop() so chickens are no longer labeled "Bot stopped". - Repair: prefer in-act Larzuk over cross-act Halbu trip (Halbu detection failed 100% last session and desynced the act state). Documented as Bugs 9-12 in CLAUDE.md. Co-Authored-By: Claude Fable 5 <[email protected]>
22 KiB
Claude Code Working Guide — my-botty
This file is auto-read by Claude Code at session start. It covers what you need to debug failures, extend the bot, and avoid known pitfalls. For static architecture see ARCHITECTURE.md; for Gemini conventions see GEMINI.md.
Quick Orientation
my-botty is a Python bot that automates Diablo II: Resurrected boss runs using computer vision and native Windows API input. No kernel drivers — only SendInput, GetCursorPos, SetCursorPos via ctypes.
Active setup (as of 2026-06-08):
- Character: Hammerdin (
fistman), Hell difficulty - Runs:
run_diablo,run_pindle(seeconfig/params.ini) - OS: Windows 11, D2R 1280×720 windowed
- Start bot:
python src/main.pyor runmain.exe
Always start a session by reading logs first:
log/log.txt ← current session (DEBUG level)
log/stats/events_*.jsonl ← per-run event stream (JSON lines)
log/stats/stats_*.log ← human-readable summary
Use /check-logs slash command to get an instant summary.
How to Read a Failure
Startup line (every session)
=== BOT START === char=hammerdin | difficulty=hell | routes=['run_diablo', 'run_pindle']
If this is missing, the bot crashed before on_init().
Approach failure (run couldn't get to the boss area)
ERROR Approach failed for run_diablo [step: use_wp_rof]
The [step: X] tells you exactly which sub-step failed. Also sent to Discord via _save_error_screenshot. Step names per run:
| Run | Step names (in order) |
|---|---|
diablo |
open_wp → use_wp_rof → verify_rof_first → retry_open_wp → retry_use_wp_rof → verify_rof_retry |
vizier |
open_wp → use_wp_rof |
arcane |
open_wp → use_wp_arcane |
shenk |
open_wp → use_wp_frigid |
trav |
open_wp → use_wp_travincal |
nihlathak |
open_wp → use_wp_halls_of_pain → verify_halls_of_pain |
pindle |
go_to_act5 → traverse_to_portal → retry_traverse_to_portal → click_red_portal |
andariel |
go_to_act1 → traverse_to_wp → open_wp → use_wp_catacombs |
countess |
go_to_act1 → traverse_to_wp → open_wp → use_wp_black_marsh |
mephisto |
go_to_act3 → traverse_to_wp → open_wp → use_wp_durance |
baal |
go_to_act5 → traverse_to_wp → open_wp → use_wp_worldstone |
Implementation: Each run class has self.approach_fail_step: str | None = None. Set to step name just before return False. Read in bot.py _run_wrapper() via getattr(run_obj, "approach_fail_step", None).
Maintenance failure (town routine broke)
ERROR Maintenance failed [step: buy_consumables] — vendor NPC not found after retry
Also sent to Discord. Maintenance steps in execution order:
town_heal → inspect_inventory → identify_items → buy_consumables / heal → stash_items → repair → resurrect_merc → gamble
Tracked in self._maintenance_step on the Bot instance (bot.py). Non-fatal failures (repair, resurrect) just log a warning with the step name. Fatal failures (buy_consumables, stash_items) call _save_error_screenshot("maintenance", reason) then trigger_or_stop("end_game", failed=True).
Common error patterns to grep for
ERROR|WARNING|failed|Approach failed|starting from True|DAMAGED|SendInput.*missed
| Pattern | Cause |
|---|---|
starting from True |
_curr_loc became Python True instead of a Location enum — see TownManager.identify() bug below |
failed on item.*DAMAGED |
NTIP_ALIAS_QUALITY_MAP missing ItemQualityKeyword.Damaged.value — fixed in bnip_data.py |
SendInput.*missed target |
Win11 pointer acceleration amplifying relative mouse moves — fixed in win_input.py |
Repair/vendor interaction failed |
Flaky NPC detection, best-effort — bot continues |
Could not identify act from location |
_curr_loc is None or True, not a Location string |
Known Bugs and Fixes (permanent reference)
Bug 1: _curr_loc = True propagation
File: src/town/town_manager.py — identify() method (~line 236)
The act-level identify() returns a Location enum on success. The wrapper previously returned curr_loc (the passed-in value) unchanged. If curr_loc was somehow True (Python bool), it propagated. True == 1 in Python, so pather's traverse_nodes((True, A5_QUAL_KEHK)) could match A5_TOWN_START (enum value 1), masking the bug.
Fix: Both return sites in identify() now return success if isinstance(success, Location) else curr_loc (primary) and success if isinstance(success, Location) else new_loc (A5 fallback).
Defensive guard in bot.py (~line 438):
self._curr_loc = self._town_manager.identify(self._curr_loc)
if self._curr_loc is True:
Logger.warning("identify() returned True — resetting to A5_TOWN_START")
self._curr_loc = Location.A5_TOWN_START
Bug 2: DAMAGED quality KeyError
File: src/d2r_image/bnip_data.py — NTIP_ALIAS_QUALITY_MAP
ItemQualityKeyword.Damaged = 'DAMAGED' exists but the map was missing an entry for it. Caused KeyError: 'DAMAGED' on 20+ items per session whenever Charsi repair triggered ground-item reads.
Fix: Added ItemQualityKeyword.Damaged.value: 1 to the map alongside LowQuality, Crude, Cracked.
Bug 3: NPC name tag templates stale → open_npc_menu always times out
File: src/npc_manager.py — open_npc_menu()
NPC body templates find the NPC correctly (Akara consistently at screen ~(649, 407)), but the name tag "AKARA"/"MALAH" templates score only ~0.28 on hover, just below the original 0.35 threshold. This caused a 20-second full-screen search that never clicked anyone.
Root causes:
- Name tag templates are slightly stale vs. current D2R rendering
- Name tag search used the full screen ROI, so false-positive text anywhere on screen could also score 0.28+ — raising threshold didn't help
- After click, the old code checked gold name tag template (also stale) instead of the NPC dialogue UI element
Fix: Three changes in open_npc_menu():
- Small ROI for name tag check: after hovering, search for the name tag only in a 240×140px box directly above the cursor (where D2R always renders name tags), preventing distant false positives
- Lower name tag threshold 0.35 → 0.26: Akara at 0.28 now passes; with the small ROI false-positive risk is contained
- NPCDialogue confirmation instead of gold tag: after clicking, check
is_visible(ScreenObjects.NPCDialogue)first — more reliable than matching a stale gold name tag template - Timeout 20 s → 8 s: fails faster when the NPC is genuinely off-screen
Symptom to grep for (before fix): NPC akara hover - White score: 0.28x spinning for 20 s
Bug 4: open_npc_menu body+pose scoping bug → false positive Akara/Malah clicks
File: src/npc_manager.py — open_npc_menu(), hover loop
User-added body+pose fallback click path had a scoping bug: min_dist was computed per template in the build loop but the variable was NOT stored in the result dict, so the hover loop's pose_confirmed = ... and min_dist < 100 used the stale value from the last template processed — not the current result's distance to a known pose. This caused false positives (e.g. Akara body template scoring 0.531 at screen (589, 560), far outside her ROI and 211 px from any pose) to pass pose_confirmed and be clicked, while the real Akara at (979, 443) (body 0.42–0.47, 102 px from pose) never got clicked.
Fix (open_npc_menu build + hover loops):
- Renamed
min_dist→min_dist_val, stored in result dict:results.append({..., "min_dist": min_dist_val}) - Hover loop uses
result["min_dist"]instead of stalemin_dist - Body threshold 0.50 → 0.40 (real Akara at 0.42–0.47)
- Pose tolerance 100 → 150 px (real Akara at 102 px from nearest pose; false positive at 211 px, correctly rejected)
- Removed
elif body_confident and attempts == 0blind-first-attempt path (was the direct enabler of false positive clicks)
Symptom to grep for (before fix): Clicking on akara at (589, 559) (body+pose confirmed, name tag score 0.225) followed by dialogue not open
Bug 5: _curr_loc becomes A1_TOWN_START when Cain ID fails → A1 pathing in A5 environment
Files: src/bot.py — on_maintenance() lines 473–475 and 502–510
When Cain identification fails, identify() returns False → _curr_loc was reset to Location.A1_TOWN_START as a fallback. But the character is physically still in A5 Harrogath (game just spawned there). The subsequent buy_consumables(A1_TOWN_START) call runs A1 pather navigation in the A5 environment: pather can't find A1 reference templates → moves character to a random A5 spot → Akara body templates fire false positives → clicks fail → fatal.
The A1 retry at line 505 also hardcoded Location.A1_TOWN_START without calling go_to_act first, so the character was never actually navigated to A1 before running A1 pathing.
Fix (both sites in bot.py on_maintenance()):
- Identify fallback:
A1_TOWN_START→A5_TOWN_START(character IS in A5 when Cain fails) - A1 retry: add
go_to_act(1, retry_start)beforebuy_consumablesso the bot actually opens the waypoint and travels to A1 first:retry_start = self._curr_loc or Location.A5_TOWN_START a1_loc = self._town_manager.go_to_act(1, retry_start) self._curr_loc, result_items = self._town_manager.buy_consumables(a1_loc or Location.A1_TOWN_START, items=items)
Symptom to grep for (before fix): TownManager buy_consumables: starting from a1_town_start immediately after Could not identify items (Cain not available)
Bug 6: open_npc_menu dialogue confirmation always False → retry click closes dialogue → loop
File: src/npc_manager.py — open_npc_menu() post-click confirmation block
After clicking Akara/Malah the bot checked is_visible(ScreenObjects.NPCDialogue). The npc_dialogue.png template is a narrow (~15px) gold vertical border strip from an old D2R rendering; the ROI 456,0,30,150 points at the top-center of the screen where no dialogue border renders. Result: NPCDialogue is always False even when the dialogue IS open.
Fallback was name_tag_gold template — also stale. Both checks failing triggered a retry click on the NPC body, which closes the already-open dialogue, creating an open→can't detect→close→retry loop until timeout.
Also: press_npc_btn had wait_until_visible(ScreenObjects.NPCDialogue, timeout=3.0) which wasted 3 seconds on every NPC button press.
Fix: Replace NPCDialogue + gold-tag checks with action button detection (_action_btns_visible). The TRADE/RESURRECT/IDENTIFY buttons appear when the dialogue opens and are reliably matched by the same templates used in press_npc_btn. Added helper _action_btns_visible(npc_key, img) that mirrors press_npc_btn's white→blue→grayscale search. Also added a fast path at the top of the hover loop: if action buttons are already visible at the start of an iteration, return True immediately without hovering.
Symptom to grep for (before fix): NPC akara - dialogue not open, retrying click on body repeating 2–3 times then NPC akara - clicked but neither dialogue nor gold tag found
Bug 7: High-score false-positive name tags at positions outside NPC ROI bypass pose check
File: src/npc_manager.py — open_npc_menu() click decision (was line 327)
White text in the game world (ground items, skill effects, UI elements) could produce name-tag white-template scores of 0.98+ at positions far outside the NPC's known ROI. Because name_tag_confirmed = res_w.valid or res_g.valid was unconditional, these positions bypassed the pose_confirmed check entirely and got clicked (e.g. (200, 539) while Akara's ROI is x=605–1004).
Fix: Gate name_tag_confirmed on an ROI boundary check when attempts == 0 and the NPC has a defined ROI:
if "roi" in npcs[npc_key] and attempts == 0:
npc_roi = npcs[npc_key]["roi"]
in_npc_roi = (npc_roi[0] <= hover_screen[0] <= npc_roi[0] + npc_roi[2] and
npc_roi[1] <= hover_screen[1] <= npc_roi[1] + npc_roi[3])
else:
in_npc_roi = True
name_tag_confirmed = (res_w.valid or res_g.valid) and in_npc_roi
After the first pass (attempts > 0), ROI restriction is lifted so the wide fallback search can still find the NPC if it wandered.
Symptom to grep for (before fix): Clicking on akara at (200, 539) (name tag confirmed) with x < 605
Bug 8: Win11 mouse 3–6× overshoot
File: src/input_layer/win_input.py — mouse_move() (~line 270)
Win11 relative SendInput deltas are amplified by Windows Enhanced Pointer Precision (pointer acceleration). A 209px delta became 678px. Affected every NPC click, waypoint click, and template interaction.
Fix: On Win11 (_USE_ABSOLUTE_MOUSE == False), use SetCursorPos(x, y) for accurate positioning, then send a zero-delta MOUSEEVENTF_MOVE event so D2R's hover/cursor pipeline fires at the new position.
# Win11 path in mouse_move():
user32.SetCursorPos(target_x, target_y)
_send_input(_make_mouse_input(MOUSEEVENTF_MOVE, 0, 0))
The OS mode is detected at import time via utils.os_detect.detect_os() → _USE_ABSOLUTE_MOUSE.
Bug 9: Act-state desync → char respawns in wrong act → every subsequent game fails (2026-06-10)
Files: src/bot.py, src/town/town_manager.py
The bot's believed location (_curr_loc) and the character's PHYSICAL act diverge after any town
failure: retries hardcoded acts without traveling (buy_consumables(A1_TOWN_START) even when
go_to_act(1) FAILED, repair(A4_TOWN_START), heal(A1_TOWN_START)) and failure fallbacks blindly
set _curr_loc = A5_TOWN_START/A1_TOWN_START. D2R respawns the char in the act it save+exited
from, so one desync poisons EVERY following game: A5 pathing runs in A1/A4 town → A5_WP never
found → 42 consecutive open_wp approach failures in the 2026-06-09 session.
Fix:
TownManager.detect_current_act()— searchesTOWN_MARKERSto find the physical act town.TownManager.open_wp()/go_to_act()— on failure/early-return, verify the physical act and retry with the detected act's pather.Bot._verify_town_location(assumed)— used at EVERY retry/fallback site inon_maintenance()andon_end_run()instead of hardcoded town starts. Never run act-X pathing without confirmed presence in act X.
Symptom to grep for: Wanted to select A5_WP, but could not find it repeated across games;
A1 open_trade_menu: navigating from a1_town_start + Pather: taking a random guess while
physically elsewhere.
Bug 10: Duplicate log_end_game → phantom 0s "successful" games reset the fail circuit breaker (2026-06-10)
File: src/game_stats.py
bot.on_end_game() and game_controller.run_bot() can BOTH call log_end_game for the same game.
The second call emitted a phantom game_ended failed:false elapsed 0 event and reset
_consecutive_runs_failed to 0 — so max_consecutive_fails=5 never triggered during the 3-hour
death spiral. Fix: log_end_game returns early if self._timer is None (already logged).
Also: _last_failure_reason is now cleared in log_start_game so events can't inherit a stale
reason from a previous game (games 43–50 were blamed on open_wp when they actually failed in
maintenance).
Bug 11: Chickens mislabeled "Bot stopped (F12 or crash)" (2026-06-10)
Files: src/health_manager.py, src/game_controller.py
_do_chicken() called bot.stop() (callback) FIRST and only set _did_chicken = True several
seconds later (after save/exit + screenshot). The controller poll loop saw _stopping before the
chicken flag and labeled the failure "Bot stopped (F12 or crash)". Fix: set _did_chicken = True at the top of _do_chicken() before the callback; controller re-checks the flag before
resetting it.
Bug 12: A4 Halbu repair trip = act-desync trigger (2026-06-10)
File: config/params.ini
repair_npc=a4_halbu sent the bot A5→A4 via WP every 5 runs. Halbu detection failed 100% in the
2026-06-09 session (body score ~0.39, name tag ~0.19), wasting ~60s per attempt and leaving the
char in A4 (see Bug 9). Fix: repair_npc=a5_larzuk — stays in-act; the Larzuk flow has a
direct-template fallback and still falls back to Halbu (with proper travel) if Larzuk fails.
How to Add a New Run
- Create
src/run/my_run.py— classMyRunwithname = "run_my_run" - Add
self.approach_fail_step: str | None = Nonein__init__ - In
approach(): setself.approach_fail_step = Noneat top, then set step name before everyreturn False - Register in
bot.py: instantiate in__init__, add toself._do_runs, add state + transition, addon_run_my_run()handler - Add route toggle to
config/params.iniunder[routes] - Add to
Config().routes_orderlist
The _run_wrapper() in bot.py handles approach failure reporting automatically — it calls getattr(run_obj, "approach_fail_step", None) so no changes needed there.
How Town Maintenance Works
Sequence in bot.py on_maintenance() (called between every run):
- town_heal — drink belt potions if HP < 95%
- inspect_inventory — open inventory, count items, update TP/ID/key needs
- identify_items — go to Cain if any
item.need_idis True - buy_consumables — visit vendor for HP/mana pots, TP scrolls, ID scrolls, keys; sell flagged items (fatal if fails twice)
- heal — visit healer NPC if HP/mana below threshold (alternative to buy_consumables branch)
- stash_items — put kept items / gold in stash; run transmutes after (fatal if fails twice)
- repair — repair gear + sell via Halbu (A4) or Larzuk (A5); non-fatal, bot continues
- resurrect_merc — revive dead merc; non-fatal
- gamble — buy gamble items if Jamella has stock; non-fatal
TownManager methods (buy_consumables, stash, repair, etc.) return (new_loc, items) tuples or (False, False) on failure. On failure, bot.py retries once from a fallback location before giving up.
Coordinate Systems (quick ref)
| Name | Origin | Notes |
|---|---|---|
| Monitor | Top-left of first monitor | screen.grab() output |
| Screen | D2R client area top-left | UI detection |
| Absolute | Character at screen center | Pathing targets |
| Relative | Template match position | NPC interaction |
Convert via screen.py: convert_monitor_to_screen(), convert_screen_to_abs(), convert_abs_to_monitor(), convert_screen_to_monitor().
Config System
Singleton Config() merges in priority order: custom.ini > params.ini > game.ini > shop.ini > transmute.ini. First instantiation loads; subsequent calls return the same object. User overrides go in custom.ini (not tracked in git).
Key sections in params.ini:
[char]— character type, keybinds, difficulty, runs_per_repair, etc.[general]— discord webhook, auto_login, info_screenshots, difficulty[routes]— boolean flags for each run (run_diablo=1, run_pindle=1, etc.)[routes_order]— execution order
Discord Notifications
src/messages/messenger.py wraps a Discord webhook. Error events are sent via _save_error_screenshot(run_name, reason) in bot.py, which:
- Saves a timestamped screenshot to
log/screenshots/error/ - Calls
messenger.send_error(run_name, reason, screenshot_path)ifdiscord_log_errors=1
Enable in params.ini:
discord_log_errors=1
discord_hook_url=https://discord.com/api/webhooks/...
Threading Safety Rules
- Never call
input_layerfrom the health_manager or death_manager threads. Those threads only read screen state. All input goes through the bot thread. set_panel_check_paused(True)must be called before opening any UI panel (vendor, stash, WP). Forgetting it causes health_manager to misread HP through the panel overlay and false-chicken._stash_mutexonBotmust be held during transmutes (stash is open). Acquired inon_maintenance()after stash opens.
Debugging Tips
Template match failures — Add save_debug=True to template_finder.search_and_wait(...) to dump the failed match image to log/screenshots/debug/.
Path node failures — pather.py traverse failures usually mean the bot is in the wrong position. Check the log for the node sequence — the last successful node before the failure shows where the bot got lost.
Verify D2R window — screen.find_and_set_window_position(force=True) re-detects the window. Call this before template searches in flaky areas (Pindle portal approach does this).
Adding step tracking to a new failure point — just set self.approach_fail_step = "descriptive_name" before return False. No other wiring needed.
File Quick Reference
src/bot.py main state machine, maintenance loop
src/run/*.py one file per boss run
src/town/town_manager.py orchestrates all town NPC interactions
src/town/a1.py .. a5.py per-act NPC/WP/stash implementations
src/input_layer/win_input.py mouse_move(), key_press(), SendInput wrappers
src/input_layer/mouse_impl.py humanized Bezier mouse paths
src/pather.py node-based pathfinding
src/template_finder.py OpenCV template matching
src/screen.py window detection, screenshot, coord conversion
src/d2r_image/bnip_data.py NTIP alias maps (quality, stat, flag)
src/item/pickit.py item pickup decision logic
src/health_manager.py background HP/mana potion auto-drinker
src/death_manager.py death detection + recovery
src/config.py Config singleton
config/params.ini user config (routes, difficulty, Discord)
config/game.ini D2R UI coordinates, template ROIs
log/log.txt current session log
log/stats/events_*.jsonl per-run event stream