Files
my-botty/CLAUDE.md
alexpolo1 81f160d400 docs: record Bug 22 (tesserocr libdeflate DLL chain) in bug reference
Includes the pefile import-chain technique that found it, since WinError
126 names the importing DLL and never the missing dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 19:00:01 +02:00

32 KiB
Raw Permalink 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: run_botty.bat (runs python src/main.py from the conda env — always current with source; no exe build exists/needed)

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_wpuse_wp_rofverify_rof_firstretry_open_wpretry_use_wp_rofverify_rof_retry
vizier open_wpuse_wp_rof
arcane open_wpuse_wp_arcane
shenk open_wpuse_wp_frigid
trav open_wpuse_wp_travincal
nihlathak open_wpuse_wp_halls_of_painverify_halls_of_pain
pindle go_to_act5traverse_to_portalretry_traverse_to_portalclick_red_portal
andariel go_to_act1traverse_to_wpopen_wpuse_wp_catacombs
countess go_to_act1traverse_to_wpopen_wpuse_wp_black_marsh
mephisto go_to_act3traverse_to_wpopen_wpuse_wp_durance
baal go_to_act5traverse_to_wpopen_wpuse_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_healinspect_inventoryidentify_itemsbuy_consumables / healstash_itemsrepairresurrect_mercgamble

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.pyidentify() 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.pyNTIP_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.pyopen_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.pyopen_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.420.47, 102 px from pose) never got clicked.

Fix (open_npc_menu build + hover loops):

  1. Renamed min_distmin_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.420.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.pyon_maintenance() lines 473475 and 502510

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_STARTA5_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.pyopen_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 23 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.pyopen_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=6051004).

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 36× overshoot

File: src/input_layer/win_input.pymouse_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 4350 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.

Bug 13: hotkey wait() no-arg returned on ANY key → silent process exit (2026-06-11)

File: src/input_layer/hotkey.py The reimplemented keyboard.wait() (no key) returned on any keypress; main.py relies on it blocking forever to keep the process alive (all bot threads are daemons). Pressing F11 both started the bot AND killed the process moments later, with no traceback. Fix: no-arg wait() now sleeps forever; the poll loop is also edge-triggered (one fire per physical press — held keys no longer refire every ~20ms) and callback exceptions print instead of being swallowed.

Bug 14: OCR dead — pytesseract never configured (2026-06-11)

File: src/d2r_image/ocr.py pytesseract only looks for plain tesseract on PATH; the PYTESSERACT_TESSERACT_CMD env var set by run_botty.bat is NOT a pytesseract feature and was never read. Fix: ocr.py applies that env var (fallback: PATH, then C:\Program Files\Tesseract-OCR\tesseract.exe) to pytesseract.pytesseract.tesseract_cmd at import.

Bug 15: NIP loader NameError + dropped rule (2026-06-11)

Files: src/bnip/utils.py, config/default.bnip:1714 find_unique_or_set_base raised undefined NipSyntaxError (→ BNipSyntaxError), and the underlying trigger was a typo ShaefershammerSchaefershammer. 474 expressions now load.

Bug 16: A5 WP death loop after Pindle returns (2026-06-11)

Files: src/town/town_manager.py, src/town/a5.py, src/bot.py After a Pindle return the believed location (a5_town_start) is wrong (char is at the stash); the direct WP node path failed 5/5, and outer maintenance retries re-ran the full anchor/sweep escalation from ever-worse positions (one 10-min wander onto the town wall). A5 templates also score marginally low at current D2R settings (stash ~0.51, WP needs ≤0.62). Fix (layered): per-game WP budget on TownManager (reset in bot.on_init): failure 1 = full escalation allowed; failure 2 = quick=True direct path only; failure 3+ = instant False so the caller's fatal path ends the game (~40s fresh spawn beats wandering). Sweep trimmed 10→6 steps, WP select timeout 4s, thresholds dropped (WP 0.62; stash 0.60→0.45 retry) — safe because every select is gated by a success_func (panel-open / WP-label check).

Bug 17: kill_diablo fought with Conviction + mid-fight Redemption (2026-06-11)

File: src/char/paladin/hammerdin.py kill_diablo() Conviction doesn't boost magic-damage hammers, and the interleaved 0.8s Redemption casts were pure downtime vs a solo boss with no corpses — long fights got the merc killed. Fix: attack with Concentration (also buffs the merc via party aura), Redemption only once post-kill.

Bug 18: stash only used personal + 3 shared (2026-06-11)

Files: src/inventory/personal.py, src/inventory/stash.py Gold used raw select_tab() clicks with a 4-tab rotation (% 4, > 3); items already paged. D2R 2.7+ has 5 shared pages. Fix: gold now navigates via select_stash_page() (OCR-verified page arrows, layout-proof), rotation % 6 / stop at > 5, shared-first starts at page 5. Also fixed a leftover > 3 bound in the item-stash loop (personal.py ~line 185) → > 5.

Bug 19: failed item transfer mistaken for "stash full" → taskkill D2R (2026-06-12)

File: src/inventory/personal.pystash_all_items() stash loop When transfer_items("stash") fails for any reason OTHER than fullness (e.g. the equipped-area click guard, a transient UI hiccup), keep items stay in inventory. The loop interpreted ANY remaining keep item as "this tab is full", paged through all stash tabs, and on the last page called stash.stash_full()taskkill /f /im D2R.exe + a false Discord "stash full" alert. Surfaced by tools/testbed.py stash all (charms in cols 49 hit the equipped-area guard, so every transfer was cancelled and the loop nuked the game). Fix: before declaring the tab full, check is_visible(ScreenObjects.EmptyStashSlot). If a slot IS free but the transfer still failed, count it as a transfer failure (cap 2) and bail out, leaving items in inventory — never advance tabs / call stash_full() on a non-full page. Symptom to grep for (before fix): repeated transfer_items: inventory unchanged after attempting to stash followed by Wanted to stash item ... Assumes full stash across rising page numbers, ending in All stash is full, quitting. Test tip: tools/testbed.py stash exercises the gold + open-stash path live; add all to scan all 10 inventory columns and exercise the keep-item transfer branch on existing charms.

Bug 20: unescaped ) in an echo killed the conda direct-download path (2026-08-05)

File: install.bat — Miniforge install error branch echo ERROR: Miniforge3 installer failed (exit code %errorlevel%). sat inside a parenthesised if (...) block. An unescaped ) inside a block terminates the block, leaving . as a stray token. cmd parses the whole block when it reaches it, so this aborted the script even when the installer succeeded and the branch was never taken — verified with a minimal repro (unescaped form exits 255 on a false condition; escaped form exits 0). Effect: the direct-download fallback — the only path on a machine without winget — installed conda and then died before creating the botty env, leaving the bot unusable. Fix: escape as ^(exit code %errorlevel%^), the convention already used elsewhere in the file (^(fast path^)). Symptom to grep for: . was unexpected at this time. right after Installing Miniforge3.

Two batch pitfalls that keep recurring in install.bat — check both when editing it:

  1. A :: comment line inside a ( ) block is a parse error. Put comments above the block.
  2. Any unescaped ( or ) in an echo inside a block breaks it. Escape as ^( / ^).

Audit both across every .bat with:

awk '{ if ($0 ~ /^[ \t]*::/) { if (d>0) print FILENAME": "NR": "$0; next }
       t=$0; gsub(/\^[()]/,"",t); d += gsub(/\(/,"(",t) - gsub(/\)/,")",t); if (d<0) d=0 }' *.bat

Bug 21: installer needed admin, so it silently failed on a normal double-click (2026-08-05)

Files: install.bat, src/d2r_image/ocr.py, run_botty.bat winget install defaulted to machine scope, putting conda in %ProgramData%\miniforge3 — which requires elevation. Double-clicking install.bat without admin failed silently and conda never installed. The same bug applied to the Tesseract install. Fix: --scope user (conda now lands in %USERPROFILE%\miniforge3, no admin), plus re-scanning for conda.exe/tesseract.exe after winget instead of trusting its exit code — winget returns non-zero when a package is already installed. Tesseract additionally falls back to a direct NSIS download. ocr.py and run_botty.bat now also look in %LOCALAPPDATA%\Programs\Tesseract-OCR. Known limitation: the official Tesseract installer self-elevates and discards /D=, so it always installs machine-wide and does require admin/UAC. There is no per-user Tesseract install. Conda has no such limitation. Note: deleting a conda folder without running its uninstaller leaves stale Add/Remove-Programs entries, which make winget treat the next install as an upgrade instead of a fresh install.

Bug 22: tesserocr never loaded → bot silently ran on the slow OCR fallback (2026-08-06)

File: install.bat — OCR backend 1 section install.bat always printed tesserocr: not available (DLL issue) and the bot logged OCR backend: pytesseract (fallback). pytesseract spawns tesseract.exe as a subprocess per OCR call; tesserocr uses the in-process C++ API, so this was a permanent, silent performance loss on every item hover.

Root cause (found by walking the PE import table with pefile, not by guessing):

tesserocr.pyd → tesseract52.dll → leptonica-1.78.0.dll → tiff.dll → libdeflate.dll  ← MISSING

Current conda-forge libdeflate (>=1.20) installs the library as deflate.dll, but the older tiff.dll from the tesseract=4.* stack still imports the previous name libdeflate.dll. Nothing provided that name, so tiff.dll failed to load and every DLL above it failed with WinError 126 — "The specified module could not be found". That message made it look like a missing module, which is why earlier fixes chased os.add_dll_directory / PATH instead. Adding every DLL directory does NOT help; the file genuinely does not exist under that name.

Fix: install libdeflate explicitly next to tesseract=4.*, then copy deflate.dll to the legacy name when it is absent:

if not exist "%BOTTY_ENV_DIR%\Library\bin\libdeflate.dll" (
    if exist "%BOTTY_ENV_DIR%\Library\bin\deflate.dll" (
        copy /y "...\deflate.dll" "...\libdeflate.dll" >nul
    )
)

Verify: install.bat should print tesserocr: OK (fast path) and the bot should log OCR backend: tesserocr (primary). Deleting libdeflate.dll reproduces the failure exactly.

Debugging tip for any future "DLL load failed" error: don't assume it's a search-path problem. Walk the real import chain and try loading each DLL directly — WinError 126 names the importer, never the missing dependency:

import pefile, ctypes
pe = pefile.PE(r"...\some.dll", fast_load=True)
pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_IMPORT"]])
print([e.dll.decode() for e in pe.DIRECTORY_ENTRY_IMPORT])

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 failurespather.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 windowscreen.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
scripts/stash_inventory.py      scan all 6 stash pages → log/stash_inventory.json
scripts/make_stash_csv.py       convert stash_inventory.json → stash_list.csv (trade list)
stash_list.csv                  deduplicated item list (name, page, stats); auto-updated by bot
log/log.txt                     current session log
log/stats/events_*.jsonl        per-run event stream