Files
my-botty/CLAUDE.md
alexpolo1 3942ad4cf5 docs: how to tell an AFK break from a stuck bot, and why break lengths lie
Two things that cost real time today and are invisible from the screen.

The bot sits at the D2R character-select menu during a NORMAL AFK break —
breaks happen between games, after save-and-exit. The stuck case looks
identical there, and `status` cannot separate them either, because it reports
the game controller rather than what the bot is doing. The tell is the log
filling with "select_char: Could not find online/offline tabs" and "Restarting
bot" every ~20s; a healthy break is simply quiet.

And the configured break length is not the real one. maybe_afk_break calls
wait(minutes*60, minutes*60*1.5) and wait() then applies its own jitter (up to
1.44x), so they compound:

    planned 11.9m -> took=1167.7s (19.5m)
    planned 20:56 -> took=1531.1s (25.5m)

afk_break_max_m = 12 therefore meant "up to ~26 minutes", and ~25m is what left
D2R unable to re-enter. Documented with the multiplier to apply before deciding
any break duration is safe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:42:12 +02:00

59 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-08-26):

  • Character: FoHdin (paladalla, lvl 32), Normal difficulty — profile config/profiles/paladalla/profile.ini
  • Runs: run_pindle (see config/params.ini)
  • OS: Windows 11 (desktop at 125% scaling), D2R 1280×720 windowed, forced to (5, 98) on init
  • Start bot: run_botty.bat (runs python src/main.py from the conda env — always current with source; no exe build exists/needed)
  • Control without the F11 hotkey: python scripts/hermes_bot_control.py start|pause|stop|status (TCP 127.0.0.1:18899)

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
Wanted to select A5_RED_PORTAL, but could not find it Red portal template stale/masked — see Bug 23
Loot from <run>: nothing picked up on every run Boss never actually fought — check XP delta, see Bug 24
already in Pindle area before portal click Should no longer exist; if seen, the fake-success shortcut is back — Bug 24
rejecting low-confidence .* implies reversal Working as intended — pather refused a phantom node match (Bug 25)
Found open panels .* Chickening to be safe If the char is at full HP, it walked onto the waypoint — Bug 26
Got stuck exit pather / char against town wall Fabricated node position from the low-confidence fallback — Bug 25
expected result .* not found in GEMS convert panel repeating Stash tab never switched — gems being handled in the wrong tab, Bug 27
could not activate the .* stash tab Tab bar geometry drifted; re-measure tab centres against the live client — Bug 27

Known Bugs and Fixes (permanent reference)

Narrative write-up of the 2026-08-26/27 run_pindle collapse (what happened, in what order, and the four times the obvious answer was wrong): docs/postmortem_pindle_2026-08-27.md. Bugs 23-30 below are the per-bug detail for that incident.

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

Bug 23: masked a5_red_portal.png → portal never clickable (2026-08-26)

File: assets/templates/a5_town/a5_red_portal.png

A5_RED_PORTAL peaked at 0.500.60 — under the 0.68 select_by_template needs — and its best match was always a false positive on bottom-right scenery near (934, 62x), never the portal. Every Pindle run died on Wanted to select A5_RED_PORTAL, but could not find it.

Root cause: it was the only masked template in a5_town/ (4-channel, 60.9% opaque). Every working A5 template is fully opaque:

a5_red_portal.png   4 chan   60.9% opaque   MASKED   <- the only one that failed
a5_town_1..12.png   3 chan  100.0% opaque   no mask  <- every one that works

The original capture had the "…thak's Te…" hover tooltip baked in, and alpha was used to hide it. alpha_to_mask therefore returned a mask, routing it to cv2.matchTemplate(..., TM_CCOEFF_NORMED, mask=...) — a combination OpenCV does not properly support (masks are only valid for TM_SQDIFF / TM_CCORR_NORMED). That produced both the depressed scores and the wandering match positions.

Fix: recaptured as a plain 3-channel opaque crop of the portal's upper arch (the lower ring is occluded by branches on most approaches, which wrecks matching). Verified by held-out validation and against portal-absent frames:

score position
portal present (5 frames + live) 0.949 1.000 correct every time
portal absent (6 real failure frames) 0.398 0.514 valid=False

Do not "fix" a template by masking part of it. Recapture it clean. See the asset conventions in ARCHITECTURE.md → Image Recognition.

Bug 24: Pindle "already in Pindle area" fired in town → silent zero-XP fake success (2026-08-26)

File: src/run/pindle.pyapproach() pre-click shortcut

The worst bug of the set, because it reported success. approach() opened with a "are we already there?" shortcut calling _verify_in_pindle_area() before clicking the portal. Harrogath scenery near the portal scores 0.760.79 on PINDLE_7 — well above its 0.62 bar — so the shortcut fired in town, returned Location.A5_PINDLE_START without ever clicking the portal, and the bot ran kill_pindle() in the middle of Harrogath.

Five consecutive games logged confirmed temple entry (PINDLE_7 @ 75.7%), End game, runs_failed_total: 0 — with zero loot and zero XP. The logs looked perfect.

Fix: the shortcut is deleted. Entry is proven only by the portal click's loading screen. Real temple entries score 85.888.7%, cleanly above the town false positives.

Two traps this bug teaches:

  1. failed:false in the event stream is the bot's opinion, not evidence. Verify a boss run with XP delta (log/stats/mini_stats_*.jsoncurrent_exp) and loot lines. Loot from run_pindle: nothing picked up on every run is a red flag, not bad luck.
  2. A "we might already be there" fast path is dangerous when its test can false-positive at the origin. It converts a loud failure into a silent one.

Do NOT try to fix this with a red-portal visibility check. The Nihlathak portal is visible from both sides — the return portal renders inside the temple too — so "portal on screen ⇒ still in town" is false and blocks every genuine entry.

Bug 25: pather 0.55 fallback fabricated node positions → char walked into the town wall (2026-08-26)

File: src/pather.pyfind_abs_node_pos(), traverse_nodes()

When the primary 0.68 ROI search missed, find_abs_node_pos fell back to a 0.55 first-match full-image search. That fallback invented node positions: A5_TOWN_1 scored 0.600.62 on unrelated scenery at (532,110), (857,522) and (1209,86) across three consecutive failure frames. The pather steered toward each phantom and wedged the character against the Harrogath battlements. Note the landmarks themselves were healthy where actually visible (A5_TOWN_6 @ 0.93, A5_TOWN_4 @ 0.85) — only the fallback was lying.

Fix (three parts):

  1. Fallback threshold 0.550.62 (Pather._FALLBACK_THRESHOLD).
  2. Fallback forced to best_match=True — it was best_match=False, so an ambiguous frame was decided by node-dict ordering rather than by score.
  3. New _heading_is_plausible() gate: a low-confidence match implying a >90° reversal from the current heading is rejected (Pather: rejecting low-confidence X — implies reversal) and the recovery sweep runs instead. Confident matches are never gated. Heading is tracked per node (node_last_dir, reset each node) because a >90° turn between nodes is normal.

Fired for real on the very first live session: two PINDLE_4 phantoms at 62.2%/62.9% were rejected, and that game completed (73 s instead of ~50 s) rather than being lost.

Bug 26: waypoint panel counted as a chicken → healthy games thrown away (2026-08-26)

File: src/health_manager.py — panel check in the monitor loop

A mis-steered pather walks the char over the Harrogath waypoint stone, which opens the WP panel. The panel check saw LeftPanel/RightPanel, counted twice, and killed the game: Found open panels (inv/quest/stats) twice. Chickening to be safe.3 of 6 games died this way at full health.

Fix: if ScreenObjects.WaypointLabel is visible the panel is self-inflicted, not a threat: send esc without incrementing _count_panel_detects. Bounded by _MAX_WP_PANEL_ESCAPES = 6 so a WP panel that genuinely will not close still falls through to the normal chicken path, and _count_wp_panel_detects resets as soon as a poll sees no panel at all.

Bug 27: gem conversion ran in the PERSONAL tab — tab-switch click missed the tab bar (2026-08-26)

Files: src/transmute/transmute.py, config/params.ini

convert_all_gems ran its entire routine in whichever stash tab happened to be open — PERSONAL — never the GEMS tab. It logged [819/999] … expected result INVENTORY_TOPAZ_PERFECT not found in GEMS convert panel; trying first slot fallback on every one of 800+ iterations and kept going, ctrl+shift+clicking blind into the personal stash grid.

Root cause: GEMS_TAB_Y = 100. Measured off the live client, the stash tab labels occupy the row y = 6378 (selected-tab underline at y=80) and the stash grid starts at y≈87. So every _switch_to_gems_tab() click at y=100 landed on a stash slot, not a tab. The X values were already correct — only Y was wrong, by ~30px.

Measured tab centres on this client (5 tabs: PERSONAL SHARED GEMS MATERIALS RUNES):

PERSONAL SHARED GEMS MATERIALS RUNES
(68, 70) (144, 70) (220, 70) (295, 70) (370, 70)

Second, independent bug found alongside it: params.ini had stash_tabs=6 with only 5 tabs on screen. inventory/common.tab_properties() divides the tab bar by that number, so it computed centres of 63/127/192/256/320/384 against real centres of 68/144/220/295/370 — tabs 2, 3 and 4 clicked the gaps between tabs. Set to 5.

Fix:

  1. TAB_Y = 70, plus named X constants for all five tabs.
  2. stash_tabs=5.
  3. Tab switches are now verified, not fire-and-forget. _switch_to_tab() clicks, confirms the tab actually became active, retries up to 3×, and returns False. convert_all_gems aborts on False instead of converting in the wrong tab.

How to detect the active tab — measure the cell background, not the glyphs: np.percentile(gray_label_cell, 30) > 52 (active ≈ 6768, inactive ≈ 3839). A brightness-of-text test does not work: label brightness scales with label length, so an active GEMS peaks at 167 while an active PERSONAL hits 215 — any glyph threshold that catches PERSONAL misreads GEMS as inactive. This was a real wrong turn during the fix.

The recurring lesson (same as Bug 24): an unverified click that "should" have worked will fail silently forever. Any click that changes UI mode — a tab, a page, a panel — must be confirmed before the code acts as if it took effect.

Bug 28: detect_current_act decided the act on a 1.7pp margin → A5 pathing inside Act 4 (2026-08-27)

File: src/town/town_manager.pydetect_current_act()

The single biggest cause of run failures: 25% of games failed, dominated by Approach failed for run_pindle [step: click_red_portal]. The character was running A5 pathing while physically standing in Act 4.

The chain:

  1. buy_consumables travels A5 → A4 for Jamella (in A5 — traveling to A4 Jamella (Malah unreliable)) — 32 times in one session.
  2. Repair then runs in A4 too (TownManager repair: starting from a4_town_start, 32×).
  3. Run Pindle calls go_to_act(5, a4_town_start), which asks detect_current_act() to verify. It answered A5 while in Act 4, so go_to_act "corrected" the assumption and skipped the travel entirely.
  4. A5 node pathing ran in Act 4 → portal never found → failure. 11 failures traced directly to step 1 in one session.

Root cause: detect_current_act used search_and_wait(TOWN_MARKERS, best_match=True) and committed to whatever cleared 0.68 first. Measured on a real Act 4 failure frame:

A5_TOWN_1   0.636   <- phantom, always at (1046, 40), the top-right corner
A4_TOWN_5   0.619   <- the genuine marker for the act actually occupied

A 1.7 percentage point gap decided which act the bot believed it was in. Pure coin flip.

Fix: the winner must now beat the best marker from any other act by _ACT_DETECT_MARGIN (0.05) as well as clearing 0.68. Below that it logs detect_current_act: ambiguous … refusing to guess the act and returns None.

Why returning None is the safe answer: every caller treats None as "keep the assumed act". In go_to_act that means curr_act stays a4_town_start, which no longer equals the target, so it actually travels via waypoint. Refusing to answer produces correct behaviour; guessing wrong does not. Never make this function more willing to commit.

Result: failure rate 25% → 9% over the validation run. The first game of that run was the exact failing case (repair started from a4_town_start) and completed successfully.

Residual, different cause: the failures that remain are NPC-detection flakiness — e.g. open_npc_menu: timed out finding qual_kehk during merc resurrect burns ~40s and strands the char. Same family as Bugs 3/4/6/7, not act desync. See Bug 29.

Bug 29: undetectable resurrect NPC burned ~80s in EVERY game (2026-08-27)

Files: src/bot.py, src/game_stats.py

In nightmare the merc dies most games, so resurrect_merc runs constantly — and Qual-Kehk detection was failing 100% of the time (5 timeouts in 5 attempts, 107 hover attempts across 12 games). Each failed hunt costs ~40s, and the code retried once, so a dead merc cost ~80s per game, forever. Game length blew out to 185-250s versus a normal ~60s.

Why the existing guard didn't help: GameStats._merc_resurrect_failed is reset in log_start_game, so it only suppresses a second attempt within one game. Nothing carried the knowledge across games.

CORRECTED 2026-08-27 — the template was NOT the problem. The identical 0.424 score at unrelated positions looked like a degenerate template, but QUAL_NAME_TAG_WHITE scores 1.000 on a frame where the tag is actually rendered, and 0.997 through the color_filter path npc_manager really uses. The template is fine. The real cause was SWEEP_TAG_THRESHOLD — see Bug 30. The lesson stands but points the other way: a repeated identical score means the search is settling for noise, so check the threshold before blaming the asset.

Fix (cost containment, not detection): a cross-game circuit breaker on GameStats, deliberately NOT reset by log_start_game:

  • _merc_resurrect_fail_streak — consecutive failed resurrects
  • _merc_resurrect_skip_until — game number to resume trying at
  • Bot._MERC_RESURRECT_FAIL_LIMIT = 2, _MERC_RESURRECT_SKIP_GAMES = 15
  • the retry is skipped once the streak is ≥1 (a second guaranteed-futile 40s hunt)
  • both counters reset on any successful resurrect, so a transient failure can't permanently disable resurrecting

Over 30 games with an undetectable NPC: 60 hunts → 4 (~40 min of waste → ~2.7 min). Measured live: game times went 250s / 185s → 14s, 14s, 43s, 71s, 111s once the breaker engaged. The bot runs mercless for 15 games, then tries again.

Resolved by Bug 30. The breaker stays as a safety net, but resurrect should now succeed.

Bug 30: NPC grid sweep settled for noise — SWEEP_TAG_THRESHOLD too low (2026-08-27)

File: src/npc_manager.pyopen_npc_menu() grid sweep

SWEEP_TAG_THRESHOLD = 0.4. A rendered name tag matches almost perfectly, so anything mediocre is noise — and at 0.4 the noise won. Every sweep stopped at the first thing over 0.4, clicked empty ground, and gave up. Scores measured across a full day of sweeps:

NPC score outcome
akara 0.980 real — dialogue opened
halbu 0.995 real
malah 0.990 real
larzuk 0.996 real
qual_kehk 0.424 false — clicked nothing
malah 0.501 false
larzuk 0.494 false

Real hits cluster at 0.981.00, false ones at 0.420.50. Raised to 0.7, which sits in the gap with margin on both sides. This is why qual_kehk failed 100% (5 timeouts in 5 attempts) while akara succeeded 177 times — nothing was wrong with the Qual-Kehk asset.

How the template was cleared of suspicion: walked the char to the NPC with the project's own Pather, hovered a grid of positions capturing full-res frames, found the one where QUAL-KEHK renders, and scored the stored template against it — 1.000 raw, 0.997 through color_filter. Worth repeating for any "stale template" claim: prove the template fails on a frame where the subject is definitely visible before recapturing anything.

Note NAME_TAG_THRESHOLD = 0.26 (the hover path, line ~289) is deliberately much lower and was NOT changed — Akara genuinely hovers at ~0.28 (Bug 3). The two thresholds serve different paths; don't unify them.

Bug 31: merc panel check left the CHRONICLE panel open for the whole game (2026-08-27)

File: src/bot.pyon_maintenance(), merc-alive confirmation

resurrect_merc confirms a live merc by pressing o and looking for MercPanelText. It then closed the panel only when that check passed:

keyboard.send("o")
merc_panel_open = is_visible(ScreenObjects.MercPanelText)
if merc_panel_open:
    keyboard.send("o")     # the ONLY path that closed anything

On this client o (skill slot 54) opens the CHRONICLE collection panel, not the merc panel. So MercPanelText never matched, the closing keypress was never sent, and Chronicle stayed open for the rest of the game — a large centred panel that blanks every subsequent template match. The run then died on click_red_portal after ~66s of clicking at a covered screen.

Why nothing caught it: the health manager's panel guard looks for LeftPanel / RightPanel. Chronicle is centred and matches neither, so it was never auto-escaped.

Fix: always dismiss whatever appeared. If MercPanelText is not found, send esc, then re-check LeftPanel/RightPanel and send esc again if something is still up.

The general rule: any keypress that may open a panel must be paired with an unconditional dismiss. Closing only on the happy path leaves the UI wedged on every other path — and here that cost a whole run each time.

Symptom to grep for: FAIL> records showing step=resurrect_merc followed by run.approach! with a long duration, or an error screenshot with a UI panel covering the map.


The run timeline — grep "TL>"

One fixed-width, machine-readable line per step, covering the whole cycle from spawn to loot. Every step is timed.

grep "TL>" log/log.txt
TL> g2 r1 | game  | start             | start | char=fohdin difficulty=nightmare routes=['run_pindle']
TL> g2 r1 | game  | spawn             | ok    | at a5_town_start (act a5_town_start)
TL> g2 r1 | town  | maintenance       | start | at a5_town_start
TL> g2 r1 | town  | inspect_inventory | ok    | took=3.0s   | in pack=2 keep=0 sell=2 gold_full=False
TL> g2 r1 | town  | stash_items       | skip  | nothing kept and gold not full
TL> g2 r1 | town  | repair            | ok    | took=19.3s  | at a5_larzuk
TL> g2 r1 | town  | item_sell         | ok    | SOUL IMPALER @ (928, 465)
TL> g2 r1 | town  | resurrect_merc    | fail  | took=113.6s | NPC not reachable — continuing mercless
TL> g2 r1 | town  | maintenance       | ok    | took=137.6s | at a5_larzuk
TL> g2 r1 | run   | run_pindle        | start | from a5_larzuk
TL> g2 r1 | run   | approach          | fail  | took=71.9s  | step=click_red_portal
TL> g2 r2 | game  | end               | fail  | Approach failed for run_pindle [step: click_red_portal]

Columns: game rN | phase | step | status | took= | detail

  • phase: game | town | run | stlth
  • status: start | ok | skip | fail. skip states why, so a step that did nothing is distinguishable from one that never ran.
  • took=: emitted on every terminating line. start stamps the clock in Bot._tl_starts, keyed by (phase, step). This is what makes the slow phase findable — the example above shows a failed game spending 113.6s of its 137.6s town visit on a merc resurrect that failed.

Emitters:

  • Bot.tl() — the instance method (src/bot.py).
  • Bot.timeline() — a static entry point for modules that cannot import Bot without a circular import. utils/stealth.py and inventory/personal.py both use it via a lazy guarded import, so item sells/stashes/drops and stealth decisions land in the same stream. It is a no-op when no Bot is live.

Stealth appears as phase stlth: afk_break (start/ok around the sleep, so the break duration is timed), skip_run, and wrong_waypoint.

Keep the format stable — it is meant to be grepped, not read as prose. The > in the prefix matters: a bare TL/TOWN also matches template names like A5_TOWN_0.


Is it AFK, or is it stuck?

The bot sits at the D2R character-select menu during a normal AFK break — breaks happen between games, after save-and-exit, so the menu is expected. The stuck case looks identical on screen. Do not judge by the menu.

LAST=$(grep -n "control socket listening" log/log.txt | tail -1 | cut -d: -f1)
tail -n +$LAST log/log.txt | grep -cE "select_char|Restarting bot|Uncaught exception"
Normal AFK break Stuck
At character select yes, by design yes
status running=True paused=False the same
select_char: Could not find online/offline tabs none present
Restarting bot none every ~20s
Log quiet a new process, repeatedly

The tell is the log filling with restart lines, not the menu. status cannot distinguish them — it reports the game controller, not what the bot is doing.

The configured break length is not the real one

maybe_afk_break calls wait(minutes*60, minutes*60*1.5) and wait() then applies its own jitter (up to 1.44x). The two compound:

planned actual
3.9m ~5m+
11.9m 19.5m (took=1167.7s)
20:56 scheduled 25.5m (took=1531.1s)

So afk_break_max_m = 12 really meant "up to ~26 minutes". A ~25 minute idle is what left D2R unable to re-enter (select_char), and 19.5m resumed fine — the tolerated limit is between them. Capped at 7 (=> ~15m worst case) on 2026-08-28.

When changing any break duration, multiply by 1.5 x 1.44 before deciding whether it is safe.

Diagnosing a failure — grep "FAIL>"

Each failed game emits a self-contained record. Use this before opening screenshots.

FAIL> g7 r5 | Approach failed for run_pindle [step: click_red_portal]
FAIL> g7 r5 | at=a5_larzuk | step=resurrect_merc | shot=./log/screenshots/error/...png
FAIL> g7 r5 | slowest: town.resurrect_merc=114s, run.approach=72s, town.repair=19s
FAIL> g7 r5 | trail: game.spawn > town.repair(19s) > town.resurrect_merc!(114s) > run.approach!(72s)
FAIL> g7 r5 | note: 2 failing steps this game: town.resurrect_merc, run.approach

Read the trail, not just the reason. The step named in the reason is the one that blew up; it is frequently not the one that caused the problem. In the example the approach failed only after a merc resurrect had already burned 114s and stranded the character. ! marks a failing step; the trail holds the last 12 timed steps of that game.

note: appears when more than one step failed in the same game — a strong hint the first failure caused the second rather than them being independent.


Using the timing data to tune runs

The 2-hourly Discord digest (general.discord_timing_report_h, 0 disables) aggregates the same timeline, so log and report cannot disagree.

Games: 92 (81 ok, 11 failed - 12.0%)
Avg town 31s | approach 46s | battle 21s | cycle ~98s
__Slowest steps (avg)__
`town.resurrect_merc     `   103s  x7  (5 fail)
`run.approach            `    46s  x92  (9 fail)
__Failures by step__
`run.approach            ` 9

How to read it:

Signal What it means
High avg + low count + high fail A broken step retrying into a timeout. Worst kind — pure waste. resurrect_merc at 103s x7 with 5 fails is the textbook case.
High avg + high count The real cost centre. Tuning this moves runs/hour more than anything else.
Fail count climbing between reports A regression, or something drifting (template scores, NPC positions). Compare consecutive digests.
A step that vanishes from the list It stopped running at all — check for a skip reason in TL> before assuming it was fixed.

Rankings deliberately exclude umbrella entries (maintenance, the run_<name> step) because they contain the others and would always come first, and the stlth phase because an AFK break is intentional idling — it gets its own section.

Cross-check a suspicious step with:

grep "TL>" log/log.txt | grep "resurrect_merc"

Verifying a boss run actually worked

Learned the hard way (Bug 24). Log lines and failed:false are not proof of a kill.

Signal Trustworthy?
End game / failed:false No — the bot's own belief; a false-positive area check fakes it
confirmed temple entry (X @ nn%) No on its own — town scenery hit 0.78 on PINDLE_7
Loot from <run>: <item> Yes — items only drop from real kills
current_exp delta in log/stats/mini_stats_*.json Yes — but a gain only proves something died (minions, merc kills), not that the boss did
A frame from a screen recording Yes — decisive; costs one screenshot

Record before/after XP around a verification run:

ls -t log/stats/mini_stats_*.json | head -1 | xargs python -c "import json,sys;d=json.load(open(sys.argv[1]));print(d['current_exp'],d['runs_failed_total'])"

A running bot does not pick up source edits. Python loads modules at process start, so after changing src/, kill and restart main.py — otherwise you are testing the old code and the log will show messages that no longer exist in the source.


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().


Pickit / loot rules — which file is actually live

The active pickit set is config/bnip/Den gode.bnip, which is gitignored (.gitignore:89 = config/bnip/*). config/default.bnip is only a fallback and is NOT in use — editing it has no effect on runs. Resolution order is in bnip/actions.py::_resolve_bnip_dir():

config/profiles/<active>/pickit/  >  config/pickit_profiles/<general.pickit_profile>/  >  config/bnip/  >  default.bnip

Confirm which file loaded by the startup line Loaded N nip files with M total expressions and re-check M after editing — if the count doesn't move, you edited the wrong file.

How the three decisions interact (bnip/actions.py):

Function Evaluates Notes
should_pickup only the part before # so a rule with stat conditions still picks the item up unidentified
should_id returns False only if a rule without a # matches any rule carrying # leaves the item to be identified
should_keep full expression, returns on the first match so ordering matters: specific rules must sit above any catch-all

That combination is what lets a catch-all work: [Quality] == Rare # [Strength] >= 999 placed last picks up and identifies every rare, can never itself keep one, and lets every specific rare rule above it win. Anything falling through is vendored (sell_junk=1).

Current setup (2026-08-26): gem transmuting is disabled ([transmute] transmute= empty in params.ini — this also defeats force=True, unlike transmute_every_x_game=0). Flawless and Perfect gems are picked up and stashed; chipped/flawed/standard are ignored.


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.

Screen-record a failure instead of guessing. A 1 fps DPI-aware grab of the D2R client rect is cheap and settles "where is the character actually standing?" in one frame — that is what proved the wall-walk (Bug 25) and the in-town fake kill (Bug 24). Two gotchas: make the recorder DPI-aware (shcore.SetProcessDpiAwareness(2)) or a 125%-scaled desktop silently crops your capture to 1024×576 of a 1280×720 window; and re-read the window rect after the bot starts, since it resizes D2R to 1280×720 at (5, 98) on init.

Score templates offline against saved frames rather than re-running the bot. The error screenshots in log/screenshots/error/ are full-res PNGs — load them and call template_finder.search(...) directly to compare primary vs fallback thresholds. Note import discord currently fails in the botty env with an ssl.SSLError, so a standalone diagnostic must stub sys.modules["discord"] before importing src/ modules.

Only one bot instance at a time. Two main.py processes both bind the Hermes socket and fight over the start/pause toggle — one will pause the other mid-run and the logs become nonsense. Check with tasklist before starting.


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