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>
59 KiB
Claude Code Working Guide — my-botty
This file is auto-read by Claude Code at session start. It covers what you need to debug failures, extend the bot, and avoid known pitfalls. For static architecture see ARCHITECTURE.md; for Gemini conventions see GEMINI.md.
Quick Orientation
my-botty is a Python bot that automates Diablo II: Resurrected boss runs using computer vision and native Windows API input. No kernel drivers — only SendInput, GetCursorPos, SetCursorPos via ctypes.
Active setup (as of 2026-08-26):
- Character: FoHdin (
paladalla, lvl 32), Normal difficulty — profileconfig/profiles/paladalla/profile.ini - Runs:
run_pindle(seeconfig/params.ini) - OS: Windows 11 (desktop at 125% scaling), D2R 1280×720 windowed, forced to
(5, 98)on init - Start bot:
run_botty.bat(runspython src/main.pyfrom 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_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 |
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.py — identify() method (~line 236)
The act-level identify() returns a Location enum on success. The wrapper previously returned curr_loc (the passed-in value) unchanged. If curr_loc was somehow True (Python bool), it propagated. True == 1 in Python, so pather's traverse_nodes((True, A5_QUAL_KEHK)) could match A5_TOWN_START (enum value 1), masking the bug.
Fix: Both return sites in identify() now return success if isinstance(success, Location) else curr_loc (primary) and success if isinstance(success, Location) else new_loc (A5 fallback).
Defensive guard in bot.py (~line 438):
self._curr_loc = self._town_manager.identify(self._curr_loc)
if self._curr_loc is True:
Logger.warning("identify() returned True — resetting to A5_TOWN_START")
self._curr_loc = Location.A5_TOWN_START
Bug 2: DAMAGED quality KeyError
File: src/d2r_image/bnip_data.py — NTIP_ALIAS_QUALITY_MAP
ItemQualityKeyword.Damaged = 'DAMAGED' exists but the map was missing an entry for it. Caused KeyError: 'DAMAGED' on 20+ items per session whenever Charsi repair triggered ground-item reads.
Fix: Added ItemQualityKeyword.Damaged.value: 1 to the map alongside LowQuality, Crude, Cracked.
Bug 3: NPC name tag templates stale → open_npc_menu always times out
File: src/npc_manager.py — open_npc_menu()
NPC body templates find the NPC correctly (Akara consistently at screen ~(649, 407)), but the name tag "AKARA"/"MALAH" templates score only ~0.28 on hover, just below the original 0.35 threshold. This caused a 20-second full-screen search that never clicked anyone.
Root causes:
- Name tag templates are slightly stale vs. current D2R rendering
- Name tag search used the full screen ROI, so false-positive text anywhere on screen could also score 0.28+ — raising threshold didn't help
- After click, the old code checked gold name tag template (also stale) instead of the NPC dialogue UI element
Fix: Three changes in open_npc_menu():
- Small ROI for name tag check: after hovering, search for the name tag only in a 240×140px box directly above the cursor (where D2R always renders name tags), preventing distant false positives
- Lower name tag threshold 0.35 → 0.26: Akara at 0.28 now passes; with the small ROI false-positive risk is contained
- NPCDialogue confirmation instead of gold tag: after clicking, check
is_visible(ScreenObjects.NPCDialogue)first — more reliable than matching a stale gold name tag template - Timeout 20 s → 8 s: fails faster when the NPC is genuinely off-screen
Symptom to grep for (before fix): NPC akara hover - White score: 0.28x spinning for 20 s
Bug 4: open_npc_menu body+pose scoping bug → false positive Akara/Malah clicks
File: src/npc_manager.py — open_npc_menu(), hover loop
User-added body+pose fallback click path had a scoping bug: min_dist was computed per template in the build loop but the variable was NOT stored in the result dict, so the hover loop's pose_confirmed = ... and min_dist < 100 used the stale value from the last template processed — not the current result's distance to a known pose. This caused false positives (e.g. Akara body template scoring 0.531 at screen (589, 560), far outside her ROI and 211 px from any pose) to pass pose_confirmed and be clicked, while the real Akara at (979, 443) (body 0.42–0.47, 102 px from pose) never got clicked.
Fix (open_npc_menu build + hover loops):
- Renamed
min_dist→min_dist_val, stored in result dict:results.append({..., "min_dist": min_dist_val}) - Hover loop uses
result["min_dist"]instead of stalemin_dist - Body threshold 0.50 → 0.40 (real Akara at 0.42–0.47)
- Pose tolerance 100 → 150 px (real Akara at 102 px from nearest pose; false positive at 211 px, correctly rejected)
- Removed
elif body_confident and attempts == 0blind-first-attempt path (was the direct enabler of false positive clicks)
Symptom to grep for (before fix): Clicking on akara at (589, 559) (body+pose confirmed, name tag score 0.225) followed by dialogue not open
Bug 5: _curr_loc becomes A1_TOWN_START when Cain ID fails → A1 pathing in A5 environment
Files: src/bot.py — on_maintenance() lines 473–475 and 502–510
When Cain identification fails, identify() returns False → _curr_loc was reset to Location.A1_TOWN_START as a fallback. But the character is physically still in A5 Harrogath (game just spawned there). The subsequent buy_consumables(A1_TOWN_START) call runs A1 pather navigation in the A5 environment: pather can't find A1 reference templates → moves character to a random A5 spot → Akara body templates fire false positives → clicks fail → fatal.
The A1 retry at line 505 also hardcoded Location.A1_TOWN_START without calling go_to_act first, so the character was never actually navigated to A1 before running A1 pathing.
Fix (both sites in bot.py on_maintenance()):
- Identify fallback:
A1_TOWN_START→A5_TOWN_START(character IS in A5 when Cain fails) - A1 retry: add
go_to_act(1, retry_start)beforebuy_consumablesso the bot actually opens the waypoint and travels to A1 first:retry_start = self._curr_loc or Location.A5_TOWN_START a1_loc = self._town_manager.go_to_act(1, retry_start) self._curr_loc, result_items = self._town_manager.buy_consumables(a1_loc or Location.A1_TOWN_START, items=items)
Symptom to grep for (before fix): TownManager buy_consumables: starting from a1_town_start immediately after Could not identify items (Cain not available)
Bug 6: open_npc_menu dialogue confirmation always False → retry click closes dialogue → loop
File: src/npc_manager.py — open_npc_menu() post-click confirmation block
After clicking Akara/Malah the bot checked is_visible(ScreenObjects.NPCDialogue). The npc_dialogue.png template is a narrow (~15px) gold vertical border strip from an old D2R rendering; the ROI 456,0,30,150 points at the top-center of the screen where no dialogue border renders. Result: NPCDialogue is always False even when the dialogue IS open.
Fallback was name_tag_gold template — also stale. Both checks failing triggered a retry click on the NPC body, which closes the already-open dialogue, creating an open→can't detect→close→retry loop until timeout.
Also: press_npc_btn had wait_until_visible(ScreenObjects.NPCDialogue, timeout=3.0) which wasted 3 seconds on every NPC button press.
Fix: Replace NPCDialogue + gold-tag checks with action button detection (_action_btns_visible). The TRADE/RESURRECT/IDENTIFY buttons appear when the dialogue opens and are reliably matched by the same templates used in press_npc_btn. Added helper _action_btns_visible(npc_key, img) that mirrors press_npc_btn's white→blue→grayscale search. Also added a fast path at the top of the hover loop: if action buttons are already visible at the start of an iteration, return True immediately without hovering.
Symptom to grep for (before fix): NPC akara - dialogue not open, retrying click on body repeating 2–3 times then NPC akara - clicked but neither dialogue nor gold tag found
Bug 7: High-score false-positive name tags at positions outside NPC ROI bypass pose check
File: src/npc_manager.py — open_npc_menu() click decision (was line 327)
White text in the game world (ground items, skill effects, UI elements) could produce name-tag white-template scores of 0.98+ at positions far outside the NPC's known ROI. Because name_tag_confirmed = res_w.valid or res_g.valid was unconditional, these positions bypassed the pose_confirmed check entirely and got clicked (e.g. (200, 539) while Akara's ROI is x=605–1004).
Fix: Gate name_tag_confirmed on an ROI boundary check when attempts == 0 and the NPC has a defined ROI:
if "roi" in npcs[npc_key] and attempts == 0:
npc_roi = npcs[npc_key]["roi"]
in_npc_roi = (npc_roi[0] <= hover_screen[0] <= npc_roi[0] + npc_roi[2] and
npc_roi[1] <= hover_screen[1] <= npc_roi[1] + npc_roi[3])
else:
in_npc_roi = True
name_tag_confirmed = (res_w.valid or res_g.valid) and in_npc_roi
After the first pass (attempts > 0), ROI restriction is lifted so the wide fallback search can still find the NPC if it wandered.
Symptom to grep for (before fix): Clicking on akara at (200, 539) (name tag confirmed) with x < 605
Bug 8: Win11 mouse 3–6× overshoot
File: src/input_layer/win_input.py — mouse_move() (~line 270)
Win11 relative SendInput deltas are amplified by Windows Enhanced Pointer Precision (pointer acceleration). A 209px delta became 678px. Affected every NPC click, waypoint click, and template interaction.
Fix: On Win11 (_USE_ABSOLUTE_MOUSE == False), use SetCursorPos(x, y) for accurate positioning, then send a zero-delta MOUSEEVENTF_MOVE event so D2R's hover/cursor pipeline fires at the new position.
# Win11 path in mouse_move():
user32.SetCursorPos(target_x, target_y)
_send_input(_make_mouse_input(MOUSEEVENTF_MOVE, 0, 0))
The OS mode is detected at import time via utils.os_detect.detect_os() → _USE_ABSOLUTE_MOUSE.
Bug 9: Act-state desync → char respawns in wrong act → every subsequent game fails (2026-06-10)
Files: src/bot.py, src/town/town_manager.py
The bot's believed location (_curr_loc) and the character's PHYSICAL act diverge after any town
failure: retries hardcoded acts without traveling (buy_consumables(A1_TOWN_START) even when
go_to_act(1) FAILED, repair(A4_TOWN_START), heal(A1_TOWN_START)) and failure fallbacks blindly
set _curr_loc = A5_TOWN_START/A1_TOWN_START. D2R respawns the char in the act it save+exited
from, so one desync poisons EVERY following game: A5 pathing runs in A1/A4 town → A5_WP never
found → 42 consecutive open_wp approach failures in the 2026-06-09 session.
Fix:
TownManager.detect_current_act()— searchesTOWN_MARKERSto find the physical act town.TownManager.open_wp()/go_to_act()— on failure/early-return, verify the physical act and retry with the detected act's pather.Bot._verify_town_location(assumed)— used at EVERY retry/fallback site inon_maintenance()andon_end_run()instead of hardcoded town starts. Never run act-X pathing without confirmed presence in act X.
Symptom to grep for: Wanted to select A5_WP, but could not find it repeated across games;
A1 open_trade_menu: navigating from a1_town_start + Pather: taking a random guess while
physically elsewhere.
Bug 10: Duplicate log_end_game → phantom 0s "successful" games reset the fail circuit breaker (2026-06-10)
File: src/game_stats.py
bot.on_end_game() and game_controller.run_bot() can BOTH call log_end_game for the same game.
The second call emitted a phantom game_ended failed:false elapsed 0 event and reset
_consecutive_runs_failed to 0 — so max_consecutive_fails=5 never triggered during the 3-hour
death spiral. Fix: log_end_game returns early if self._timer is None (already logged).
Also: _last_failure_reason is now cleared in log_start_game so events can't inherit a stale
reason from a previous game (games 43–50 were blamed on open_wp when they actually failed in
maintenance).
Bug 11: Chickens mislabeled "Bot stopped (F12 or crash)" (2026-06-10)
Files: src/health_manager.py, src/game_controller.py
_do_chicken() called bot.stop() (callback) FIRST and only set _did_chicken = True several
seconds later (after save/exit + screenshot). The controller poll loop saw _stopping before the
chicken flag and labeled the failure "Bot stopped (F12 or crash)". Fix: set _did_chicken = True at the top of _do_chicken() before the callback; controller re-checks the flag before
resetting it.
Bug 12: A4 Halbu repair trip = act-desync trigger (2026-06-10)
File: config/params.ini
repair_npc=a4_halbu sent the bot A5→A4 via WP every 5 runs. Halbu detection failed 100% in the
2026-06-09 session (body score ~0.39, name tag ~0.19), wasting ~60s per attempt and leaving the
char in A4 (see Bug 9). Fix: repair_npc=a5_larzuk — stays in-act; the Larzuk flow has a
direct-template fallback and still falls back to Halbu (with proper travel) if Larzuk fails.
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 Shaefershammer → Schaefershammer. 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.py — stash_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 4–9 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:
- A
::comment line inside a( )block is a parse error. Put comments above the block. - Any unescaped
(or)in anechoinside 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.50–0.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.py — approach() 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.76–0.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.8–88.7%, cleanly above the town false positives.
Two traps this bug teaches:
failed:falsein the event stream is the bot's opinion, not evidence. Verify a boss run with XP delta (log/stats/mini_stats_*.json→current_exp) and loot lines.Loot from run_pindle: nothing picked upon every run is a red flag, not bad luck.- 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.py — find_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.60–0.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):
- Fallback threshold
0.55→0.62(Pather._FALLBACK_THRESHOLD). - Fallback forced to
best_match=True— it wasbest_match=False, so an ambiguous frame was decided by node-dict ordering rather than by score. - 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 = 63–78 (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:
TAB_Y = 70, plus named X constants for all five tabs.stash_tabs=5.- 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_gemsaborts 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 ≈ 67–68, inactive ≈ 38–39). 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.py — detect_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:
buy_consumablestravels A5 → A4 for Jamella (in A5 — traveling to A4 Jamella (Malah unreliable)) — 32 times in one session.- Repair then runs in A4 too (
TownManager repair: starting from a4_town_start, 32×). Run Pindlecallsgo_to_act(5, a4_town_start), which asksdetect_current_act()to verify. It answered A5 while in Act 4, sogo_to_act"corrected" the assumption and skipped the travel entirely.- 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 atBot._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.py — open_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.98–1.00, false ones at 0.42–0.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.py — on_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.skipstates why, so a step that did nothing is distinguishable from one that never ran. - took=: emitted on every terminating line.
startstamps the clock inBot._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 importBotwithout a circular import.utils/stealth.pyandinventory/personal.pyboth 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 noBotis 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
- Create
src/run/my_run.py— classMyRunwithname = "run_my_run" - Add
self.approach_fail_step: str | None = Nonein__init__ - In
approach(): setself.approach_fail_step = Noneat top, then set step name before everyreturn False - Register in
bot.py: instantiate in__init__, add toself._do_runs, add state + transition, addon_run_my_run()handler - Add route toggle to
config/params.iniunder[routes] - Add to
Config().routes_orderlist
The _run_wrapper() in bot.py handles approach failure reporting automatically — it calls getattr(run_obj, "approach_fail_step", None) so no changes needed there.
How Town Maintenance Works
Sequence in bot.py on_maintenance() (called between every run):
- town_heal — drink belt potions if HP < 95%
- inspect_inventory — open inventory, count items, update TP/ID/key needs
- identify_items — go to Cain if any
item.need_idis True - buy_consumables — visit vendor for HP/mana pots, TP scrolls, ID scrolls, keys; sell flagged items (fatal if fails twice)
- heal — visit healer NPC if HP/mana below threshold (alternative to buy_consumables branch)
- stash_items — put kept items / gold in stash; run transmutes after (fatal if fails twice)
- repair — repair gear + sell via Halbu (A4) or Larzuk (A5); non-fatal, bot continues
- resurrect_merc — revive dead merc; non-fatal
- gamble — buy gamble items if Jamella has stock; non-fatal
TownManager methods (buy_consumables, stash, repair, etc.) return (new_loc, items) tuples or (False, False) on failure. On failure, bot.py retries once from a fallback location before giving up.
Coordinate Systems (quick ref)
| Name | Origin | Notes |
|---|---|---|
| Monitor | Top-left of first monitor | screen.grab() output |
| Screen | D2R client area top-left | UI detection |
| Absolute | Character at screen center | Pathing targets |
| Relative | Template match position | NPC interaction |
Convert via screen.py: convert_monitor_to_screen(), convert_screen_to_abs(), convert_abs_to_monitor(), convert_screen_to_monitor().
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:
- Saves a timestamped screenshot to
log/screenshots/error/ - Calls
messenger.send_error(run_name, reason, screenshot_path)ifdiscord_log_errors=1
Enable in params.ini:
discord_log_errors=1
discord_hook_url=https://discord.com/api/webhooks/...
Threading Safety Rules
- Never call
input_layerfrom the health_manager or death_manager threads. Those threads only read screen state. All input goes through the bot thread. set_panel_check_paused(True)must be called before opening any UI panel (vendor, stash, WP). Forgetting it causes health_manager to misread HP through the panel overlay and false-chicken._stash_mutexonBotmust be held during transmutes (stash is open). Acquired inon_maintenance()after stash opens.
Debugging Tips
Template match failures — Add save_debug=True to template_finder.search_and_wait(...) to dump the failed match image to log/screenshots/debug/.
Path node failures — pather.py traverse failures usually mean the bot is in the wrong position. Check the log for the node sequence — the last successful node before the failure shows where the bot got lost.
Verify D2R window — screen.find_and_set_window_position(force=True) re-detects the window. Call this before template searches in flaky areas (Pindle portal approach does this).
Adding step tracking to a new failure point — just set self.approach_fail_step = "descriptive_name" before return False. No other wiring needed.
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