Files
my-botty/CLAUDE.md
T
alexandClaude Fable 5 6301490cc4 fix: act-state verification, NPC dialogue confirm wait, stats integrity
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]>
2026-06-10 08:23:10 +02:00

22 KiB
Raw Blame History

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 (see config/params.ini)
  • OS: Windows 11, D2R 1280×720 windowed
  • Start bot: python src/main.py or run main.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:

  1. Name tag templates are slightly stale vs. current D2R rendering
  2. 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
  3. 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():

  1. 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
  2. Lower name tag threshold 0.35 → 0.26: Akara at 0.28 now passes; with the small ROI false-positive risk is contained
  3. NPCDialogue confirmation instead of gold tag: after clicking, check is_visible(ScreenObjects.NPCDialogue) first — more reliable than matching a stale gold name tag template
  4. 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):

  1. Renamed min_dist → min_dist_val, stored in result dict: results.append({..., "min_dist": min_dist_val})
  2. Hover loop uses result["min_dist"] instead of stale min_dist
  3. Body threshold 0.50 → 0.40 (real Akara at 0.42–0.47)
  4. Pose tolerance 100 → 150 px (real Akara at 102 px from nearest pose; false positive at 211 px, correctly rejected)
  5. Removed elif body_confident and attempts == 0 blind-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()):

  1. Identify fallback: A1_TOWN_START → A5_TOWN_START (character IS in A5 when Cain fails)
  2. A1 retry: add go_to_act(1, retry_start) before buy_consumables so 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:

  1. TownManager.detect_current_act() — searches TOWN_MARKERS to find the physical act town.
  2. TownManager.open_wp() / go_to_act() — on failure/early-return, verify the physical act and retry with the detected act's pather.
  3. Bot._verify_town_location(assumed) — used at EVERY retry/fallback site in on_maintenance() and on_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

  1. Create src/run/my_run.py — class MyRun with name = "run_my_run"
  2. Add self.approach_fail_step: str | None = None in __init__
  3. In approach(): set self.approach_fail_step = None at top, then set step name before every return False
  4. Register in bot.py: instantiate in __init__, add to self._do_runs, add state + transition, add on_run_my_run() handler
  5. Add route toggle to config/params.ini under [routes]
  6. Add to Config().routes_order list

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):

  1. town_heal — drink belt potions if HP < 95%
  2. inspect_inventory — open inventory, count items, update TP/ID/key needs
  3. identify_items — go to Cain if any item.need_id is True
  4. buy_consumables — visit vendor for HP/mana pots, TP scrolls, ID scrolls, keys; sell flagged items (fatal if fails twice)
  5. heal — visit healer NPC if HP/mana below threshold (alternative to buy_consumables branch)
  6. stash_items — put kept items / gold in stash; run transmutes after (fatal if fails twice)
  7. repair — repair gear + sell via Halbu (A4) or Larzuk (A5); non-fatal, bot continues
  8. resurrect_merc — revive dead merc; non-fatal
  9. 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:

  1. Saves a timestamped screenshot to log/screenshots/error/
  2. Calls messenger.send_error(run_name, reason, screenshot_path) if discord_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_layer from 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_mutex on Bot must be held during transmutes (stash is open). Acquired in on_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