69 commits since v0.9.1. Most of what landed was found by measuring which
behaviours FIRED, not by reading code: AFK breaks had gone 0-for-225 against a
configured 5%/game, eight [stealth] settings were declared in params.ini and
never loaded into Config(), and several functions had no callers at all.
Nightmare failure rate ~11% -> 1.0% over 100 games; hell went from dying on
game 4 to 0.0% over 59.
Co-Authored-By: Claude Opus 5 <[email protected]>
walk() carried the identical unguarded click that move() had and was missed
because the tests were written against move() alone. This asserts the invariant
across the movement methods, so a future one is caught without anyone
remembering to extend the tests.
Deliberately NOT covered, because relocating these breaks what they do:
pick_up_item must click the item itself
_remap_skill_hotkey deliberately clicks the UI
cast_in_arc aims a cast direction, not a destination
Only clicks that choose a DESTINATION may be moved off the HUD. A first draft
of this test flagged all of the above and was wrong to; the distinction is
between "go here" and "hit that".
Verified by falsification: reverting walk() to randomize=5 fails with
"walk: mouse.move(x, y, randomize=5, ...)".
Co-Authored-By: Claude Opus 5 <[email protected]>
walk() carried the identical unguarded click that move() had:
x, y = convert_abs_to_monitor(pos_abs)
mouse.move(x, y, randomize=5, ...)
Same fix, same ordering: jitter first via _hud_safe_target, then click with
randomize=0. walk() is reached from bot.py's walk-back-to-town path and from
poison_necro, so it was a live second route to the same loot-filter toggle.
Found while verifying PR #35 had landed — grepping main for randomize= showed a
third mouse.move in i_char.py that the move()-scoped tests did not cover.
session_budget_h 10 -> 8 for a >=5h run. The value is rolled at 0.65-1.35x, so
setting 5 would AVERAGE five hours but could stop after 3.25. 8 gives a
5.2-10.8h window, which guarantees the five while keeping the variation.
Co-Authored-By: Claude Opus 5 <[email protected]>
Review catch on the previous commit — the guard was real but leaky.
get_closest_non_hud_pixel returns the NEAREST unmasked pixel, which by
construction sits exactly on the mask boundary. mouse.move(randomize=3) then
offsets each axis by randrange(-3, 3) = -3..+2, so the cursor can land back
inside the masked region before the right-click. The loot-filter click was made
intermittent, not fixed.
The order is now: jitter -> guard -> move with randomize=0. The human-like
offset is preserved; the guarantee is no longer given away. Same treatment for
the walk branch (randomize=5).
My test missed this because it only checked the guard's OUTPUT, never the point
finally clicked — it passed while the bug was live. The new test samples 400
jittered targets per filter button and asserts every FINAL point is unmasked,
plus a source check that no mouse.move in move() randomizes after the guard.
Verified by falsification: restoring guard-then-randomize makes the suite fail
with "move() still randomizes after the guard, which can re-enter the HUD".
Note on the test itself: an intermediate version compared string indexes over
the whole function source and reported the ordering backwards, because the
DOCSTRING mentions both names. It now compares code with the docstring and
comments stripped — the third time today a source-order assertion was fooled by
prose rather than code.
Co-Authored-By: Claude Opus 5 <[email protected]>
Reported after Larzuk trips: the bot pressed escape, then flipped the loot
filter. Larzuk stands on the left of Harrogath, so moves to and from him aim at
the bottom-left corner — where D2R puts the seven loot-filter category buttons
(screen x 395-560, y 692-712). A right-click there toggles a filter, which
changes what renders and therefore what every later template match can see.
IChar.move() applied NO HUD avoidance in either branch:
# teleport
mouse.move(pos_monitor[0], pos_monitor[1], randomize=3, ...)
mouse.click(button="right")
# walk
x, y = convert_abs_to_monitor(pos_abs)
mouse.move(x, y, randomize=5, ...)
The pather's anti-stuck path already called get_closest_non_hud_pixel; move()
never did, and assets/hud_mask.png covers those buttons correctly — the mask
was simply not consulted.
Latent for a long time and surfaced by Enigma. The walk branch shrinks its
target toward centre via adjust_factor, which mostly kept clicks off the HUD by
accident; the teleport branch clicks the raw target, so any low aim point lands
on the interface.
The escape that precedes it is unrelated and correct — common.close() dismissing
the repair panel.
Verified: filter-row targets are moved from y=700 to y=508, a centre target is
returned unchanged.
Co-Authored-By: Claude Opus 5 <[email protected]>
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 <[email protected]>
afk_break_max_m is not the ceiling it looks like. maybe_afk_break calls
wait(minutes*60, minutes*60*1.5)
and wait() then applies its own jitter (up to 1.44x), so the two compound. A
configured 12 minutes can idle for roughly 26.
Measured today:
planned 11.9m -> took=1167.7s (19.5m)
planned 20:56 -> took=1531.1s (25.5m) [scheduled break]
A ~25m idle is what left D2R at character select on 2026-08-28, unable to
re-enter, with the bot spawning a replacement process every ~20s until four
instances were stacked. 19.5m resumed cleanly, so the tolerated limit sits
somewhere between.
12 -> 7 puts the real worst case at 7 x 1.5 x 1.44 = 15.1m, inside the range
proven to resume, while keeping the 2m floor and the variation intact.
Left the compounding itself alone deliberately: the double randomisation is
good cover, and only its unbounded tail was the problem.
Co-Authored-By: Claude Opus 5 <[email protected]>
restart_or_exit spawned a replacement process and exited, with no attempt limit
and no backoff:
subprocess.Popen([sys.executable, os.path.abspath(sys.argv[0])])
os._exit(0)
On 2026-08-28 a 25-minute scheduled break left D2R on a screen the bot could
not re-enter:
=== BOT START ===
select_char: Could not find online/offline tabs
Restarting bot — game kept running
Because the failure was persistent, this span up a new process roughly every 20
seconds. Instances stacked (4 observed) and then refused taskkill.
The counter has to survive the exec — each restart is a NEW PROCESS, so an
in-memory counter cannot bound the chain. It lives in log/.restart_count,
is checked BEFORE spawning a replacement, and after 5 consecutive restarts the
bot stops with a Discord alert instead of looping, telling the user to return
D2R to the main menu.
A 5s-per-attempt backoff (capped at 60s) stops a fast failure spinning CPU or
stacking processes faster than they exit.
The count is cleared on reaching town, not at game end: the loop failed at
select_char, well before town, so reaching town is what proves recovery — and a
healthy bot never accumulates toward the cap.
Verified by falsification: restoring the unbounded restart makes the suite fail
with "restart loop is unbounded".
Co-Authored-By: Claude Opus 5 <[email protected]>
Bug 31 fixed the keypress that opened CHRONICLE but explicitly left the real
gap open: the panel guard matches only LeftPanel / RightPanel, and a centred
panel matches neither. Nothing closed it, and a centred panel blanks EVERY
later template search.
Recurred 2026-08-28 with the merc key never pressed at all
(resurrect_merc | skip | merc alive), so something else opened it and it simply
stayed. The error screenshot shows Chronicle filling the screen while the bot
hunted A5_RED_PORTAL for 66s:
Traverse from a5_larzuk to a5_nihlathak_portal
Wanted to select A5_RED_PORTAL, but could not find it
random guess towards (96, -115)
Wanted to select A5_RED_PORTAL, but could not find it
Why the existing guard missed it, measured on that frame: the Chronicle's close
button scores 1.000 against CLOSE_PANEL_2 at (952, 56). That x IS inside
right_panel_header (830,0,455,56) — but the ROI is 56px tall and the button's
centre sits exactly at y=56, so the match falls outside. Both header ROIs
scored 0.409 and 0.373.
Adds a center_panel_header ROI (400,0,620,92) and a CenterPanel ScreenObject,
and escapes it WITHOUT counting toward a chicken — a centred panel is
self-inflicted UI state like the waypoint panel, and chickening on it throws
away a healthy game. Bounded by the same _MAX_WP_PANEL_ESCAPES so one that
genuinely will not close still falls through.
Verified on the failure frame: CenterPanel True, LeftPanel False, RightPanel
False.
Co-Authored-By: Claude Opus 5 <[email protected]>
afk_break fired 0 times in 9 games at a 50% test rate — odds of roughly 1 in
500, so not variance.
The four call sites added earlier all sit in on_end_run's town-return branches,
and with one route configured the bot never reaches them. After the run it goes
straight to end_game:
Loot from run_pindle: ...
TL> g8 r8 | game | end | ok
Clicking SAVE_AND_EXIT ... End game. Elapsed time: 69.80s
Starting game #9
No return_to_town or tp_town line appears anywhere in the log. Maintenance runs
at game START, not after the run, so those branches are dead code for this
configuration.
The roll now sits in on_end_game beside the scheduled-break check — the same
path that demonstrably works, since scheduled_break has fired and resumed.
Between games is also the right moment semantically: the game is closed, so
idling there is safe.
Worth recording why the guard missed it. The STEALTH> manifest reported
"afk_break 5% wired - 4 call sites" throughout, which was true and useless: a
call site EXISTING is not the same as a call site being REACHED. Static
reachability is not something the manifest can decide. The digest's "NEVER
FIRED" line is the check that actually catches this class, and it is why that
line exists.
Co-Authored-By: Claude Opus 5 <[email protected]>
Three fixes from running it against the live game:
1. It blind-pressed the open key. The grid was ALREADY open, so the press
CLOSED it and the scan read grass. Now it detects the grid via its bottom
hint and toggles only when needed, restoring the state it found. The hint
match is fuzzy because Tesseract renders it "PRESS FI-F8 TO BIRD A SKIN"
(BIND->BIRD, SKILL->SKIN).
2. The tooltip ROI was fixed, but the tooltip renders ABOVE the hovered cell
and moves with the row, so it was reading the game world. Now taken relative
to the cell. Skill identification is a fuzzy match against a known list
rather than a demand for clean OCR: real reads included "XI BLESSED HAMMER"
and one frame OCR'd Fist of the Heavens as "LNMEPULE".
3. Hotkey labels are read by TEMPLATE MATCH, not OCR. They are ~20x12px of
white glyph over whatever icon is behind them; brightness thresholding
cannot separate the two when the icon is also bright (F4's swirl, the row-4
weapons) and OCR managed 4/8. A top-hat isolates small bright features
regardless of background: 8/8 with zero false positives.
Matching a same-SIZED crop is 1px-brittle — int() rounding in the grid
geometry lands a pixel off the measured centre and the score collapses from
~1.0 to ~0.4. Exactly the three cells where truncation differed failed. A
4px search slack fixes it; 8/8 held at every threshold 0.60-0.78.
Adds assets/templates/skill_binds/f1..f8.png, cut from a frame with all eight
labels visible.
Verified live: correctly reported conviction=f5 as casting TELEPORT and
concentration=f8 as casting CONVICTION — both confirmed by hand beforehand —
and found Concentration sitting unbound.
Co-Authored-By: Claude Opus 5 <[email protected]>
Answers "which key is each skill actually on?" with screenshots and clicks, no
external service. For each cell of the bind grid it hovers, OCRs the tooltip
for the skill name, OCRs the icon's upper-right corner for the bound F-key, and
optionally saves the icon as a template with that corner blanked.
The corner is excluded from the saved template on purpose. D2R draws the hotkey
label there, so an icon captured with it only matches while the skill stays on
that key — rebind it and the template silently stops matching, looking like
template rot rather than a bind change. Same pixels, read separately as data.
Why it exists: on 2026-08-28 an Enigma put Teleport on F5, displacing
Conviction, while config still said conviction=f5 — every attack-aura cast
would have teleported the character mid-fight. F7 was Vengeance, not Holy Bolt;
F8 was Conviction, not Concentration. The startup preflight reported this
correctly and it was dismissed as a marginal template.
Two things learned building it, both encoded here:
- The tooltip renders ABOVE the hovered cell and moves with the row, so a fixed
ROI reads the game world. The first version returned "YEW Y" and "PET". The
band is now taken relative to the cell.
- Identification is a fuzzy match against a known-skill list rather than a
demand for clean OCR. Real reads included "XI BLESSED HAMMER" and "HOLY
SHIELD L", and one frame OCR'd Fist of the Heavens as "LNMEPULE" while the
full text still contained the name. Validated 7/7 offline against saved
frames.
It refuses to compare when it clearly could not read the grid (<3 skills or 0
bound keys) and exits 2. The first version scanned a closed grid, identified
nothing, and then reported all seven configured keys as unbound — presenting
its own blindness as findings.
Exits 1 on a real mismatch so it can gate a run. Menu entry:
python tools/testbed.py spellbook --assets
Co-Authored-By: Claude Opus 5 <[email protected]>
New tools:
- tools/diablo2io_price_scraper.py: scrapes public diablo2.io trade
listings (browsetrades.php), extracts item/WTS/WTB/desc/price
mentions, merges into daily_prices.json as third source
- tools/discord_price_report.py: posts daily price summary embed to
Discord webhook (top FG, top trade, movers vs last report)
- tools/discord_price_scraper.py: Discord channel price scraper
(needs bot token; not wired into pipeline yet)
Fix:
- tools/improve_fg_estimates.py: parse post date from page HTML
instead of file mtime (fixes day-bucketing on re-downloads)
Data (2026-08-28):
- d2jsp: 808 topics, 21 with parseable dates
- FG: day_6 (Cham 7.5), day_7 (Aldur 20, Gul 65, Ist 70, Anni 575)
- Traderie: 500 listings, 157 items
- diablo2.io: 150 listings, 28 items
- Pushed 229 prices to .96, prices.alw.dk rebuilt 08:14
select() reports only that a connection is PENDING; the client's bytes may not
have arrived when accept() returns. The accepted socket inherits the listener's
non-blocking mode, so conn.recv() raised
BlockingIOError: [WinError 10035] A non-blocking socket operation could not
be completed immediately
which `except Exception: pass` swallowed. The connection was never closed
(observable as CLOSE_WAIT in netstat) and the caller saw "no response from bot
(socket timeout)".
This is the root cause of the control-socket flakiness throughout 2026-08-27/28
— roughly half of all status/start/stop calls — and therefore of the retry
loops written to work around it. One of those retry loops sent 'start' three
times to what was then a toggle and paused the bot for 4h50m of an overnight
run.
Fixes:
- accepted connections are set blocking with a 2s timeout
- handler exceptions are LOGGED instead of silently swallowed, and the
connection is closed on the error path
The logging is what found this in one restart, after the silent swallow had
made the same failure undiagnosable all night. Verified: 6 consecutive status
calls now succeed where they previously timed out intermittently, and the bot
reaches "=== BOT START ===" on a single idempotent start.
Co-Authored-By: Claude Opus 5 <[email protected]>
'start' was a plain alias for 'pause' — both branches called the same toggle:
if data == 'start' or data == 'pause':
start_or_pause_bot(controllers)
So a caller retrying a timed-out 'start' PAUSED the bot. On 2026-08-28 a
restart routine sent it three times and the bot sat frozen for 4h50m of an
overnight run, stopping at the next state change because trigger_or_stop blocks
while _pausing is set.
The verification that should have caught it failed too: 'status' returned
controllers.game.is_running, which tracks the game controller and not
Bot._pausing, so a paused bot answered running=True.
Both fixed:
- 'start' is idempotent — starts a stopped bot, resumes a paused one, and is a
no-op on a healthy one. 'pause'/'toggle' remain the toggle.
- 'status' reports "running=X paused=Y".
The handler was extracted from an inline closure into handle_hermes_command()
so this is testable behaviourally rather than by asserting on source text.
Verified by falsification: restoring the original semantics makes the suite
fail with "repeated 'start' paused a healthy bot" and "status hides the pause
state: 'running=True'"; the fix makes all five pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
Bug 25 added a heading gate that refuses a low-confidence node match implying a
>90 degree reversal. The gate works, but rejection returned None and the
traverse loop fell through to its anti-stuck path, which force-moves along
last_direction — and when the character is already wedged against the Harrogath
wall, that shoves it further in.
Observed 2026-08-28 01:22:
rejecting low-confidence A5_TOWN_1 (65.3%) for node 3 - implies reversal
rejecting low-confidence A5_TOWN_1 (66.3%) for node 3 - implies reversal
rejecting low-confidence A5_TOWN_1 (67.0%) for node 3 - implies reversal
rejecting low-confidence A5_TOWN_1 (67.7%) for node 3 - implies reversal
Pather: taking a random guess towards (-423, 247)
Wanted to select A5_RED_PORTAL, but could not find it
The error screenshot shows the character outside the battlements in the dark
void with the portal a faint occluded glow. Declaring a position untrustworthy
and then moving on an arbitrary 488px vector are contradictory.
Three consecutive rejections now abort the traverse so the caller re-anchors
from a fresh game (~40s) rather than wedging the character somewhere that
poisons the rest of the run. A confident match clears the counter, and it
resets per traverse so a stale count cannot abort the next one.
The abort MUST precede the anti-stuck block; a test asserts that ordering.
Nothing about the thresholds or the gate itself changed.
Note on the test: its first version searched the source for "random guess" and
matched the explanatory COMMENT above the abort, reporting the ordering
backwards. It now strips comments — the same mistake as slicing source on a
branch name and matching a comment that merely mentioned it. Verified in both
directions: removing the abort makes it fail.
Co-Authored-By: Claude Opus 5 <[email protected]>
Measured across the whole log:
A5 Qual-Kehk 0 successful resurrects, 49 hunt timeouts (111 clicks)
A4 Tyrael 3 successful resurrects, 8-25s each
When the A5->A4 trip for Tyrael fails, resurrect() fell back to the current
act — Qual-Kehk — which has never once succeeded. That fallback is not a second
chance, it is a guaranteed ~100s loss, and bot.py retries it once, doubling the
cost.
Observed 2026-08-28 01:18:
town.repair ok 106.5s (larzuk timed out twice)
town.resurrect_merc fail 212.8s (two Qual-Kehk hunts)
FAIL> Maintenance timeout after 328s before [gamble]
That pair took the town visit past the 240s maintenance timeout and killed a
game that was otherwise healthy. The trigger was A5_WP not being found, so the
Tyrael travel never happened.
Now it returns False immediately and runs mercless for the game; the merc is
re-checked next game and the existing cross-game breaker still applies.
Co-Authored-By: Claude Opus 5 <[email protected]>
Self-inflicted, from the session-rhythm commit. pick_up_items increments
item_count at the END of its loop body, so the `continue` I added after the
skip roll jumped past it and the loop re-evaluated the SAME item until
self.timeout expired. A 2% roll therefore burned the entire pickup window.
Symptom that gave it away: two pickup_skip events in nine games, far above the
configured 2% — each fire was consuming a whole phase rather than skipping one
item.
Second defect in the same code: the roll ran on every EVALUATION, and this loop
re-locates items after each pickup, so a single item is evaluated repeatedly.
That compounded the real skip rate well past the configured value and could
re-roll an item already skipped.
Now decided once per item id and memoised, with the counter advanced before
continuing. Tests pin both: that the counter advances (or the loop hangs) and
that the decision is memoised (or the rate compounds).
Co-Authored-By: Claude Opus 5 <[email protected]>
The same defect has now appeared twice: detect_current_act was fixed in Bug 28,
wait_for_town_spawn kept it, and nothing linked the two. These tests assert the
invariant for both, so a future fix to one cannot silently leave the other
behind:
- both must require _ACT_DETECT_MARGIN over the best rival-act marker
- both must score own-act and rival markers on ONE image (scores from two
different grabs are not comparable — the character can move between them)
- ambiguous detection must return None rather than guess
Audited every other TOWN_MARKERS consumer: bot.py:561 and main_menu.py:40/61
use best_match only as a boolean "are we in town?" check and never derive an
act, so they need no margin.
Co-Authored-By: Claude Opus 5 <[email protected]>
Bug 28 fixed detect_current_act to require the winning town marker to beat the
best marker from any OTHER act by 5pp before committing to an act. Its twin,
wait_for_town_spawn, was left committing to whatever search_and_wait_stable
returned with best_match=True — the exact defect, in the function that runs
FIRST every game and sets the act for everything after it.
Observed live 2026-08-28: the character spawned in Harrogath and this reported
"at a4_town_start" every game. The bot then ran A4 logic in Act 5 — repaired at
"a4_halbu", hunted an A4 waypoint that was not there
("Wanted to select ['A4_WP','A4_WP_2'], but could not find it"), and failed
go_to_act(5) while already standing in Act 5. The error screenshot is
unambiguously Harrogath: wooden roofs, braziers, Larzuk's forge.
Scored on that frame: A5_TOWN_1 0.644 vs A4_TOWN_5 0.548 — a 9.6pp margin for
A5. The old code had no margin requirement at all.
Returning None on an ambiguous spawn is the safe outcome: the caller falls
through to detect_current_act and then to the route's home act, whereas a
wrong act here poisons every subsequent step of the game.
Both sides are scored on ONE image. The first version compared the winner's
score from the original match against a rival scored on a later grab, which is
not a comparison — the character can move between them.
Co-Authored-By: Claude Opus 5 <[email protected]>
Measured over the full log (264 maintenance cycles):
town.buy_consumables 74.1s avg 33 runs 8 fails (24%)
town.repair 16.8s avg 158 runs 0 fails
buy_consumables from A5 is an A5->A4 waypoint round trip. Jamella herself is
not the problem — 267 clicks against 2 timeouts — the navigation is, and it
also leaves the character in Act 4, where the return trip has been producing
click_red_portal failures.
Selling never justified that trip. The repair step a few entries below already
sells (sell_items is one of its triggers) and it runs at Larzuk IN ACT 5, with
158 runs and zero failures. Ordering is buy -> stash -> repair, and sell_items
is recomputed after stash, so pending sales still reach Larzuk in the same
maintenance pass; anything missed is still pending next game.
Checked the obvious alternative first and rejected it on evidence: switching
buy_consumables to A5 Malah to avoid the trip entirely. Malah measures 54
clicks against 24 timeouts (31% failure) with hover scores topping out at 0.675
— never reaching the 0.98-1.00 cluster real hits produce. The "Malah
unreliable" comment predates the Bug 30 threshold fix but is still true, and
switching would have traded a 24% failure for a 31% one.
Consumables still trigger the trip; only sales no longer do.
Co-Authored-By: Claude Opus 5 <[email protected]>
The repo already strips personal profile references (35aa134); a real character
name does not belong in a committed test fixture.
Co-Authored-By: Claude Opus 5 <[email protected]>
The chicken threshold roll was committed but never present in bot.py. The patch
script that was supposed to add it raised SystemExit on an unrelated anchor
assertion and exited before writing the edit; the failure was visible in its
output and went unnoticed.
Caught by its own symptom: 0 "Chicken threshold this game" lines across a
session with games in it.
The manifest reported chicken_variance as "wired" throughout, because that row
was built from the CONFIG VALUE rather than from a call-site check like every
other row. So the guard against configured-but-inert behaviour was itself
configured but inert — the exact defect it exists to catch, one level up.
Both fixed: the roll is wired at game start on the bot thread, and the manifest
row now counts set_game_chicken_threshold( call sites. Verified in both
directions — removing the call site flips the row to
"UNREACHABLE - no call site in bot.py".
Tests: 22.
Co-Authored-By: Claude Opus 5 <[email protected]>
Per-action jitter cannot reach the strongest remaining signals. Averaged over a
session jitter converges; what does not converge is a player who starts at the
same hour, plays the same length, and never does anything without a purpose.
Session budget (session_budget_h, default 6). Stops the bot after roughly N
hours, rolled per run at 0.65-1.35x so consecutive days differ in length. NOTE:
this genuinely stops the bot — set to 0 for unlimited.
Scheduled breaks were already implemented at bot.py:1140 and simply switched
off (break_length_m=0). Enabled at 120m/15m, and the interval and duration are
now RE-ROLLED after every break: a break at exactly 120 minutes every time is
still a pattern, just a slower one than no break at all.
Per-game chicken threshold. A fixed 0.40 every game is a precise tell, but the
randomisation is deliberately one-directional: it only ever RAISES the
threshold, capped at base+spread. Lowering it would cost deaths, and an
uncapped Gaussian tail reached 0.55 on a 0.40 base, which throws away healthy
games. Rolled on the BOT thread and handed to health_manager through a setter —
that thread is a read-only monitor by design and must not roll it itself.
Idle cursor drift during long idles; between actions the cursor otherwise sits
exactly where the last click left it. Bot thread only, screen-bounds clamped.
Occasional unproductive town action (open inventory, close it) and occasional
walking past an item the filter wanted. Both are rolled behaviours the bot has
never had — it otherwise picks up exactly what the rules say, instantly, every
single time. The town action is strictly best-effort and can never fail a
maintenance step.
All seven appear in the STEALTH> manifest, so any of them going inert is
visible at startup rather than after 225 games.
DELIBERATELY NOT IMPLEMENTED: pathing node jitter and route variation. Both
would be good cover, and both are the system that produced Bugs 25 and 28,
where a fabricated node position walked the character into the town wall.
Nothing here touches the health manager's potion path or the attack sequences
either — today demonstrated that cost twice.
Tests: 20.
Co-Authored-By: Claude Opus 5 <[email protected]>
Regression from the previous commit. Blanking vigor/holy_shield/cleansing
stopped the stray F4 presses, but exposed that paladin.cast_buffs sends the
holy_shield key with no check:
keyboard.send(self._skill_hotkeys["holy_shield"]) # -> ValueError: Unknown key:
which killed the bot thread mid-run:
Uncaught exception in thread Thread-8 (start)
...
File "src/char/paladin/paladin.py", line 33, in cast_buffs
ValueError: Unknown key:
There are 90+ `keyboard.send(self._skill_hotkeys[x])` call sites across the
paladin classes and almost none check first, so the guard belongs at the
boundary: an empty or None key logs at debug and returns. A genuinely unknown
key still raises — the guard is for unbound optional skills, not for typos.
cast_buffs additionally returns early when holy_shield is unbound: send() now
tolerates the empty key, but the right-click after it would still fire and cast
whatever sits on the right slot instead.
Co-Authored-By: Claude Opus 5 <[email protected]>
skill_mistake printed 'wired - skill keys only' while its chance was 0. A
behaviour that can never roll is OFF whatever its call sites look like, and a
status line that overstates coverage is the exact defect this manifest exists
to catch.
Co-Authored-By: Claude Opus 5 <[email protected]>
The profile blanks vigor/holy_shield/cleansing in its [fohdin] section, but
config.py builds the paladin skill config from params.ini [paladin] and only
accepts overrides from a profile [paladin] SECTION. A blank in [fohdin] never
reaches it, so vigor=f4, holy_shield=f2 and cleansing=f9 survived on a
character that has none of those skills.
paladin.pre_move() then does:
should_cast_vigor = self._skill_hotkeys["vigor"] and not is_right_skill_selected(["VIGOR"])
if should_cast_vigor and not can_teleport:
keyboard.send(self._skill_hotkeys["vigor"])
is_right_skill_selected(["VIGOR"]) can never be true without the skill, and
this character cannot teleport, so F4 was sent on EVERY move. On this client F4
is the loot filter toggle — the bot flipped it continuously all session, and an
error screenshot caught the skill box showing F4 as the active skill.
Fixed with a [paladin] section in the profile blanking all three. The miscast
candidate list narrows itself as a result (f1,f3,f5,f6,f7,f8) since blanks no
longer look like bound skills.
Also disables skill_mistake_chance. Even with a correct candidate list, a
miscast swaps the active skill or aura mid-fight, and the attack sequences rely
on ending in a known skill state. Small stealth value against a real risk of a
stray press landing on something that is not a skill at all — which is exactly
what happened here. One line to re-enable.
Co-Authored-By: Claude Opus 5 <[email protected]>
Phase 1 — stop the harm (safety-critical)
The hesitation/miscast gate was `vk in range(ord('1'), ord('0')+1)` plus a
digit-string fallback. ord('1')=49 and ord('0')=48, so that range is EMPTY and
never matched; only the string test fired, and it listed the digits — the
POTION keys. The gate was aimed at the exact inverse of its intent: every
potion press carried 80-300ms of hesitation and a 1.5% chance of pressing a
different potion first, while skill casts (f1-f12) were never touched. The
"wrong key" fell through the same empty range into an except branch choosing
from ['1'..'5'] — potion keys again.
Potion presses come from health_manager at low HP, so this was a stealth
feature that delayed emergency healing and could drink mana instead of health
mid-death. Replaced with explicit key classification: skill hotkeys may carry
stealth, potion/belt keys are exempt by construction, and a miscast now presses
another BOUND SKILL or does nothing.
Phase 2 — delete the dead code
stealth_move, endpoint_wobble, randomize_click_position, human_key_press,
human_keyboard_send: zero callers. stealth_move also referenced `variance` on
its success path where the name is never assigned, so it would NameError on
first execution — independent proof it never ran, and that the three functions
reachable only through it never ran either. Deleted rather than wired: the
per-call-site randomize= values are tuned to measured button geometry (2-3px
NPC, +/-9px inside a 47px waypoint button) and stacking a global offset on top
is what produces the NPC-detection failures of Bugs 3/4/6/7. click_variance
removed with its last consumer.
Phase 3 — coverage
Added IChar.atk_len(), the single correct way to read an atk_len_* value, and
migrated fohdin's 7 boss windows onto it. 15 other char modules still read
Config().char["atk_len_*"] directly; rather than sweep 173 call sites in
untested classes, the manifest REPORTS the gap (1/16) so it cannot stay silent.
Replaced the personality stub. get_personality_seed used builtin hash(), which
Python randomizes per process for str — it returned a different "stable" seed
every session, the opposite of its docstring. Now sha256-based. Added
get_session_bias(): one timing multiplier held constant for the whole run, so
the session MEAN differs between runs. Per-action jitter alone cannot do this —
averaged over hundreds of actions it converges to the same mean every session,
which is itself a signature.
The bias is applied BEFORE the jitter clamp. Applying it after let a 0.92x
session push waits under the floor, silently undoing the wait_jitter_min fix
from the previous commit.
Phase 4 — make inertness impossible to miss
Every defect here was invisible for one reason: a behaviour that never fires
looks identical to one whose roll has not come up. AFK breaks sat dead for 225
games behind that ambiguity.
- STEALTH> manifest at startup reports each behaviour's configured value AND
whether it has a reachable call site; UNREACHABLE logs at WARNING. Logged
once per process, since === BOT START === fires per game.
- The 2-hourly digest now compares observed against configured rates and
prints "NEVER FIRED" for anything absent from a statistically meaningful
window, instead of omitting the row.
Tests: 12, covering each phase's invariant — potion keys can never route
through stealth, miscast candidates are never potions, kill windows never
shorten, the wait floor holds under any session bias, deleted functions stay
deleted, and the manifest reports nothing unreachable.
Co-Authored-By: Claude Opus 5 <[email protected]>
Most of the stealth surface was configured but inert. Nothing failed and
nothing logged, so the config advertised far more behaviour than executed.
AFK breaks never fired: 0 in 225 games against a configured 5%/game.
maybe_afk_break() sat only in the tp_town() branch of on_end_run, but a
character with no teleport (Pindle red-portal exit) returns from an earlier
branch and never reached it. Now rolled on every way home. baal_xp is
excluded on purpose — it arrives already in town, so there is no
run-just-finished moment for a break to belong to.
Eight [stealth] settings were declared in params.ini and never loaded into
Config(). utils.stealth read them as cfg.get(key, <hardcoded>), so the
hardcoded value always won and editing params.ini did nothing. They only
looked correct because the fallbacks matched the shipped values:
click_delay_{min,max}_ms, key_press_{min,max}_ms,
skill_hesitation_{min,max}_ms, wrong_waypoint_chance, skill_mistake_chance.
Behaviours that move WHERE or WHEN a click lands are now opt-in and default
OFF (click_delay_enabled, click_variance_enabled). The per-call-site
randomize= values in npc_manager/waypoint are tuned against real button
geometry (2-3px for NPCs, +/-9px inside a 47px waypoint button); stacking a
global offset on top is what starts missing NPCs. click_delay's ceiling also
drops 800ms -> 250ms, since it applies to every click.
vary_kill_time is wired into the fohdin boss windows and made LENGTHEN-ONLY
(1.0-1.4x). A shortened attack window leaves the boss alive, which is a
failed run rather than convincing behaviour — do not restore the 0.7 floor.
Verified over 4000 samples on an 8s window: min 8.00, max 11.20, mean 8.95.
wait_jitter_min 0.85 -> 0.95. The floor is clamped to jitter_min*0.8, so
0.85 let waits come out 32% SHORT — the one place jitter stole time from an
action instead of adding it between actions. Waits now run at-or-longer and
cannot expire before the UI they were waiting on has settled.
Also closes the resurrect_merc timeline step on the merc-alive path. It
emitted TL> start with no terminator, leaking an entry in _tl_starts so the
common case never appeared in FAIL> trails or the Discord digest.
test/test_stealth_config.py asserts the invariant (no path to maintenance
skips the AFK roll) rather than naming branches. An earlier draft sliced
source from a branch NAME and was fooled by a comment mentioning tp_town();
the invariant version is what found the two extra unguarded exits.
Co-Authored-By: Claude Opus 5 <[email protected]>
Three linked failures that together took the nightmare run from ~8% to ~37% failures.
1. A5_WP matched the HUD. main's recaptured a5_wp.png (14f5876, pulled in by my merge)
scores 0.966 on the belt/mana-orb area at (980, 656) — above threshold and higher than
the real stone — so every search locked onto the HUD, clicked it, and open_wp burned
~83s escalating through anchors before giving up. buy_consumables hit 222-253s and
tripped the 240s maintenance timeout.
Fix: search the waypoint within ui_roi[cut_skill_bar] so the HUD can never compete
(select_by_template gained an optional roi for this), and search BOTH captures with
best_match — main's new one and the pre-merge a5_wp_2.png, which reaches 0.73-0.98 here
against the new one's 0.50-0.58. Neither setup has to lose. 222s -> 33s.
2. My own regression, introduced an hour earlier. The merc check pressed 'o' and closed
the panel only when MercPanelText matched, leaving the CHRONICLE panel (what 'o' opens
on this client) up all game. I "fixed" that with an unconditional esc — which is worse:
with nothing open, esc opens the GAME MENU, which LeftPanel/RightPanel do not match, so
nothing closed that either and every later search saw a menu instead of the town.
Fix: always toggle 'o' back. 'o' opened it, so 'o' closes it, and it cannot open
something new. Symmetry, not detection.
3. resurrect_npc=a4_tyrael (new config, mirrors repair_npc). Measured today:
A4 Tyrael ok 8.2s, ok 24.9s - 0 errors, ever
A5 Qual-Kehk fail 113.6s / 52.0s / 163.1s / 72.6s - 43 errors
Tyrael stands on a fixed spot by the A4 waypoint; Qual-Kehk is the least reliable NPC
in the route. Falls back to the current act if the trip to A4 fails.
Verified live: 4 games, 0 failures, town 17-23s (was 178-230s), approach 11-15s.
Co-Authored-By: Claude Opus 5 <[email protected]>
resurrect_merc confirms a live merc by pressing 'o' and looking for MercPanelText, 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.
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 later template match. The
run then died on click_red_portal after ~66s of clicking at a covered screen.
The health manager's guard did not catch it either: it looks for LeftPanel/RightPanel, and
Chronicle is centred and matches neither.
Fix: always dismiss whatever appeared. When MercPanelText is absent, send esc, then re-check
LeftPanel/RightPanel and esc again if something is still up.
Diagnosed straight from the new FAIL> record, which named the culprit without any log
archaeology:
FAIL> g90 r83 | at=False | step=resurrect_merc | shot=...
FAIL> g90 r83 | trail: town.repair(12s) > town.maintenance(18s) > run.approach!(66s)
The step named in the failure reason was click_red_portal; the step that actually caused it
was resurrect_merc, three entries earlier in the trail. That is exactly the case the trail
was added for.
Co-Authored-By: Claude Opus 5 <[email protected]>
Every test step passed; only 'Upload coverage' failed with
Failed to CreateArtifact: Artifact storage quota has been hit.
Unable to upload any new artifacts. Usage is recalculated every 6-12 hours.
That is an account storage condition, not a broken build, and it was marking the whole run
red. The artifact is a convenience, not a gate, so the step is now continue-on-error.
The underlying quota still needs clearing (Settings -> Billing -> Storage, or let old
artifacts age out) for coverage reports to reappear.
Co-Authored-By: Claude Opus 5 <[email protected]>
The webhook scrub rewrote the 18 stable-only commits, so the hashes quoted in the
post-mortem no longer resolved. Updated, and the rewrite itself is noted inline so the
change of hashes is not a mystery later.
Co-Authored-By: Claude Opus 5 <[email protected]>
main carried 19 commits of separate feature work (baal_xp, cold_plains, melee_hunt,
launch/tools) while stable carried the run_pindle repair. One real conflict, in
_run_wrapper, where both branches added a line at the same point:
stable self.tl("run", "approach", "start") <- run timeline
origin/main self._current_run = run_obj <- baal_xp run tracking
Independent, so both are kept. config/params.ini and src/config.py auto-merged.
Co-Authored-By: Claude Opus 5 <[email protected]>
Narrative record of the incident: failure rate 100% -> ~8%, character 32 -> 69, cycle time
185-250s -> ~52s across 17 commits. CLAUDE.md already carries the per-bug detail as Bugs
23-30; this is the part that does not fit a bug entry — the order things happened in, why
the silent failures cost far more than the loud ones, and the four times my own obvious
answer was wrong.
Two files:
docs/postmortem_pindle_2026-08-27.md canonical, diffable
docs/postmortems/pindle-2026-08-27.html source of the published artifact, kept in-repo
so it survives the scratchpad and stays editable
The corrections section is the reason this is worth committing rather than leaving as a
list of fixes. Each wrong turn looked correct in isolation:
- the red-portal guard: the portal renders on BOTH sides, so "portal visible => still in
town" blocked every genuine entry
- XP as proof of a boss kill: it only proves something died (minions, merc kills)
- "the template is degenerate": the Qual-Kehk asset scored 1.000 against a frame where
the tag actually renders; the THRESHOLD was wrong
- selling the charms: 673 blocked-sell lines were the guard working, not a clog, and
disabling it vendored five resistance charms
Also records the durable lesson: every one of these cost hours because the log held events
rather than structure. That is what the TL>/FAIL>/digest instrumentation exists to fix.
Co-Authored-By: Claude Opus 5 <[email protected]>
Documents the two additions from b8158c0 in the terms they are actually used for.
FAIL>: read the trail, not just the reason. The step named in the reason is the one that
blew up and is frequently not the one that caused the problem — the worked example shows
an approach failing only after a merc resurrect had already burned 114s and stranded the
character. Also explains the "note:" line (multiple failing steps in one game usually
means the first caused the second).
Timing digest: a table for reading the signal rather than the numbers — high avg + low
count + high fail is a broken step retrying into a timeout (the worst kind, pure waste);
high avg + high count is the real cost centre worth tuning; a rising fail count between
consecutive reports is a regression or a drifting template; a step vanishing from the list
means it stopped running, so check for a skip reason before assuming it was fixed.
Records why umbrella entries and the stlth phase are excluded from rankings, so the
exclusion is not "fixed" later by someone who reads it as a bug.
Co-Authored-By: Claude Opus 5 <[email protected]>
Two additions on top of the TL> timeline.
1. FAIL> records. A bare "Approach failed [step: click_red_portal]" names the step that
blew up, but that is frequently not the step that cost the time or caused the problem.
Each failed game now emits a self-contained record:
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
grep "FAIL>" log/log.txt. The trail is the last 12 timed steps of that game with "!"
marking failures, so a failure is diagnosable from the log without replaying it.
2. Periodic Discord digest, default every 2h (general.discord_timing_report_h, 0 disables).
Aggregated from the same timeline the log uses, so the report and the log cannot drift:
**Timing report** - last 2.0h
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
__Stealth__
`afk_break ` x2 10m total
Sent at game end (a natural boundary; games are ~60s so granularity is fine) and the
window resets on each send, so every report covers exactly the period since the last.
Ranking excludes umbrella entries ("maintenance", the run_name step) since they contain
the others and would always top the list, and excludes the stlth phase since an AFK break
is deliberate idling — it gets its own section instead. Report failures are caught and
logged non-fatally; nothing here can end a run.
Verified by rendering both formats against synthetic data rather than waiting for a live
failure to be the first test.
Co-Authored-By: Claude Opus 5 <[email protected]>
Extends the town timeline to the whole cycle and times every step:
grep "TL>" log/log.txt
TL> g2 r1 | game | spawn | ok | at a5_town_start
TL> g2 r1 | town | inspect_inventory | ok | took=3.0s | in pack=2 keep=0 sell=2
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
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 [step: click_red_portal]
- phases: game (start / spawn / end), town (all maintenance steps), run (approach /
battle / loot), stlth.
- every terminating line carries took=Ns; "start" stamps the clock in Bot._tl_starts
keyed by (phase, step). The sample above pays for itself immediately: a failed game
spent 113.6s of its 137.6s town visit on a merc resurrect that failed.
- stealth decisions are tracked: afk_break (timed across the sleep), skip_run and
wrong_waypoint.
Bot.timeline() is a static entry point so utils/stealth.py and inventory/personal.py can
emit without importing Bot at module level (that would be circular). Both use a lazy
guarded import and it no-ops when no Bot is live. Item sells/stashes/drops now route
through it too, so they share the game/run counters and column widths instead of being a
separately formatted line.
Prefix moved TOWN> -> TL> now that it spans more than town.
Co-Authored-By: Claude Opus 5 <[email protected]>
Town maintenance was only traceable by piecing together scattered messages, and a step
that silently did nothing was indistinguishable from one that never ran. Every step now
emits the same stable, machine-readable line:
grep "TOWN>" log/log.txt
TOWN> g2 r2 | maintenance | start | at a5_town_start
TOWN> g2 r2 | town_heal | start
TOWN> g2 r2 | inspect_inventory | ok | in pack=0 keep=0 sell=0 gold_full=False
TOWN> g2 r2 | buy_consumables | start | needs id=0 tp=0 hp=4 mana=0 rejuv=0 | sell_pending=0
TOWN> g2 r2 | buy_consumables | ok | at a4_jamella | after: Consumables(...)
TOWN> g2 r2 | stash_items | skip | nothing kept and gold not full
TOWN> g2 r2 | repair | skip | no repair due and nothing to sell
TOWN> g2 r2 | resurrect_merc | start
TOWN> g2 r2 | gamble | skip | stash not full / gambling not configured
TOWN> g2 r2 | maintenance | ok | done in 21s | at a4_jamella
Covers shop, id, stash, repair, sell, resurrect and gamble. status is start|ok|skip|fail,
and skip states the reason. Steps carry useful detail: consumable needs before and after
buying, pack contents and keep/sell counts, repair trigger, items left in the pack after
stashing, and total maintenance duration.
Individual item transfers mirror into the same stream from transfer_items() as
item_sell / item_stash / item_drop, so vendoring a rare or stashing a rune appears inline
with the steps around it.
_step() also sets _maintenance_step, so the existing failure-reporting path is unchanged.
The ">" in the prefix is deliberate: a bare "TOWN" collides with template names such as
A5_TOWN_0.
Immediately useful — the first two games after this landed showed "done in 225s" with
buy_consumables failing against "done in 21s" with it succeeding.
Co-Authored-By: Claude Opus 5 <[email protected]>
SWEEP_TAG_THRESHOLD was 0.4. A rendered name tag matches almost perfectly, so anything
mediocre is noise — and at 0.4 the noise won: the sweep stopped at the first match over
threshold, clicked empty ground, and gave up. Scores measured across a full day:
akara 0.980 halbu 0.995 malah 0.990 larzuk 0.996 <- real, dialogue opened
qual_kehk 0.424 malah 0.501 larzuk 0.494 <- false, clicked nothing
Real hits cluster at 0.98-1.00, false ones at 0.42-0.50. Raised to 0.7, in the gap with
margin either side. This is why qual_kehk failed 100% (5 timeouts in 5 attempts) while
akara succeeded 177 times.
Also corrects Bug 29 in CLAUDE.md, which blamed the Qual-Kehk asset. That was wrong. I
walked the char to the NPC with the project's own Pather, hovered a grid capturing
full-res frames, found the one where QUAL-KEHK renders, and scored the stored template
against it: 1.000 raw and 0.997 through the color_filter path npc_manager actually uses.
The template was never the problem.
NAME_TAG_THRESHOLD (0.26, the hover path) is deliberately left alone — Akara genuinely
hovers at ~0.28 per Bug 3. The two thresholds serve different paths.
Co-Authored-By: Claude Opus 5 <[email protected]>
The final input() that holds a console build open is guarded by stdin.isatty() inside a
try, but the except clause was (OSError, ValueError). EOFError is exactly what input()
raises when stdin is closed or non-interactive, and isatty() can still report True in
detached or redirected launches. Every shutdown therefore ended with:
ERROR Uncaught exception:
Traceback (most recent call last):
File "src\main.py", line 345, in <module>
input()
EOFError: EOF when reading a line
which reads like a crash while the process was in fact exiting normally after a Force
Exit. Added EOFError to the caught tuple.
Co-Authored-By: Claude Opus 5 <[email protected]>
A protected item the pickit rejects could never leave the pack. sell and drop are both
blocked by _is_protected(), and the stash filter was "keep == True" only — so it stayed
in whatever slot it landed in, permanently. Measured: 673 blocked sells across just 7
charms in one session, and one vendor trip had 5 of 6 items blocked.
The stash filter now also takes protected items, but ONLY from the loot columns. A
charm's bonus applies from the inventory, and the pickit cannot distinguish a wanted res
charm from junk — LAPIS SMALL CHARM OF VITA (+20 life, cold res 7%) logs "Discarding"
purely because the rule wants coldresist >= 11. So position is the intent signal: charms
parked in the RESERVED columns are treated as deliberate keepers and left alone (the
click guard makes them untouchable anyway), while freshly looted ones in the loot columns
get stashed and free their slot. Nothing is sold or dropped.
Also reverts two config changes from earlier today that were wrong:
- protect_charms_from_sell back to 1. Setting it to 0 vendored LARGE CHARM OF FIRE,
STOUT SMALL CHARM, SMALL CHARM OF FLAME, STOUT SMALL CHARM OF STRENGTH and LAPIS
SMALL CHARM OF VITA before it was caught. Charms give resistances from the inventory;
the blocked-sell log lines are the guard working, not a bug to fix by selling.
- num_loot_columns back to 4. Raising it to 6 shrinks restricted_inventory_area, which
is where both tomes must live (common.tome_state only searches there) — the wrong
direction when the books need room.
protect_shields_from_sell stays 0: shields give no inventory bonus and the equipped one
is protected positionally.
Co-Authored-By: Claude Opus 5 <[email protected]>
protect_charms_from_sell=1 refused to vendor or drop anything with "charm" in its name,
regardless of the pickit verdict. With shields unblocked (4bbd43d) this became the sole
remaining clog: 673 blocked sells across just 7 charms in one session — LAPIS SMALL CHARM
OF VITA 233x, LARGE CHARM OF FIRE 226x, STOUT SMALL CHARM OF STRENGTH 126x — the same
items re-judged and re-blocked every game. One vendor trip had 5 of 6 items blocked, so
those slots were permanently occupied and the cube had nowhere to go.
Config-only change: the guard in inventory/personal.py::_is_protected() is UNCHANGED and
still reads this flag, so setting it back to 1 restores the old behaviour with no code
edit. Kept deliberately for later.
Risk accepted and noted in the config comment: unlike shields there is no positional
safety net for charms (the equipped-area click guard does not apply), so a pickit misread
will sell a good charm. Charms worth keeping belong in the stash, not the inventory.
Verified live after restart: the exact five charms that had been blocked all session sold
on the first vendor trip — LARGE CHARM OF FIRE, STOUT SMALL CHARM, SMALL CHARM OF FLAME,
STOUT SMALL CHARM OF STRENGTH, LAPIS SMALL CHARM OF VITA — with 0 blocked sells.
Co-Authored-By: Claude Opus 5 <[email protected]>
"Wanted to select A5_WP, but could not find it" fired 321 times in one 362-game session —
by volume the single largest error in the log — and it was self-inflicted, not a bad
template.
open_wp starts with two speculative direct scans, for the case where the char spawns next
to the stone after a Pindle TP-back. _try_click_wp searched for A5_WP and, when the search
found nothing, called select_by_template ANYWAY. That spins out its full 4s timeout and
logs the ERROR before the code has even tried walking to the waypoint. Measured:
15:26:41 Health Manager pausing
15:26:46 ERROR Wanted to select A5_WP <- 4.8s, char still at town start
15:26:51 ERROR Wanted to select A5_WP <- 4.6s
15:26:51 Traverse from a5_town_start to a5_wp
15:26:55 Select A5_WP (73.4% confidence) <- works fine once it walks there
~9.4s wasted per waypoint use. Raising the ID-scroll threshold (ef15ccf) pushed A4 vendor
trips to 158 in that session, each needing the waypoint both ways, which is why this
became the dominant log line.
Fix: _try_click_wp takes require_visible, and the two speculative pre-scans pass it — no
stone on screen means return False immediately instead of timing out. The post-traverse
calls keep the old behaviour, since the char should be standing on the waypoint by then
and deserves the full timeout.
Verified live: 8 waypoint uses, 0 "Wanted to select A5_WP" errors, waypoint step down to
~4s. 6 games, 0 failures since restart.
Co-Authored-By: Claude Opus 5 <[email protected]>
should_buy("id", min_remaining=3) means "buy when 3 or fewer remain", so the bot ran the
20-scroll Tome of Identify down to 2 before making a vendor trip. That threshold was set
when only a handful of item types were picked up; the pickit's rare catch-all now spends
a scroll on every rare, so the tome drains far faster.
Running dry is not cosmetic: an unidentified rare is never sold. inspect_items marks it
need_id, and the sell branch requires "not (box.keep or box.need_id)" — so it is carried
instead, occupying a slot until an ID is possible.
Raised to 8, matching the tp threshold already evaluated on the same trip, so the top-up
piggybacks on a vendor visit that was happening anyway. The A5->A4 trip this can trigger
is also materially safer now that detect_current_act refuses to guess the act (Bug 28),
which was the original reason for keeping the threshold low.
Verified live: the trip fired at id=13 (7 remaining) where the old threshold would have
waited for id=17, completed at Jamella, and the need went 13 -> 0.
Co-Authored-By: Claude Opus 5 <[email protected]>
protect_shields_from_sell=1 never vendored any item with "shield" in its detected name.
That is redundant safety: the EQUIPPED shield is already protected positionally by
mouse._is_clicking_safe(), which cancels any click landing in
ui_roi[equipped_inventory_area] while the inventory is open. The name rule only ever hit
shields sitting in the inventory grid, which the pickit had already judged.
Cost measured over one day: 130 blocked sells — FIEND SHIELD 64, AERIN SHIELD 41,
HERALDIC SHIELD 23, plus DRAGON/ANCIENT. The same shields were re-evaluated and
re-blocked every game ("Discarding FIEND SHIELD." immediately followed by "Blocked sell
for protected item: FIEND SHIELD"), so they could never leave the pack and permanently
occupied slots. The rare catch-all added in the pickit made this much worse, since rare
shields are now picked up.
Verified live after restart: "Confirmed sell 'FIEND SHIELD'" on the very item that had
been blocked 64 times, followed by HATCHET HANDS, LONG SWORD, LIGHT GAUNTLETS,
DEMONHIDE BOOTS and FLAIL — six sales in five minutes with zero blocks.
Charm protection (protect_charms_from_sell) is left ON: charms live in the inventory by
design and a misread charm cannot be un-sold.
Co-Authored-By: Claude Opus 5 <[email protected]>
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 over
12 games). Each failed hunt costs ~40s and the code retried once, so a dead merc cost
~80s in EVERY game. Game length blew out to 185-250s against a normal ~60s.
GameStats._merc_resurrect_failed did not help: log_start_game resets it, so it only ever
suppressed a second attempt within one game. Nothing carried across games.
The name tag template is degenerate rather than merely stale — every grid-sweep "hit"
reported the identical score at unrelated positions:
found name tag at (255, 227) (score 0.424)
found name tag at (1110, 100) (score 0.424)
found name tag at (930, 310) (score 0.424)
so "found" is meaningless; it is matching uniform background.
Fix is cost containment, not detection: a cross-game circuit breaker on GameStats that
log_start_game deliberately does NOT reset — _merc_resurrect_fail_streak and
_merc_resurrect_skip_until, with Bot._MERC_RESURRECT_FAIL_LIMIT=2 and
_MERC_RESURRECT_SKIP_GAMES=15. The retry is also skipped once the streak is >=1, since
that is a second guaranteed-futile 40s hunt. Both counters clear on any successful
resurrect so a transient failure cannot permanently disable resurrecting.
Simulated over 30 games with an undetectable NPC: 60 hunts -> 4 (~40 min -> ~2.7 min).
Measured live: the breaker engaged on game 2 and game times went 250s / 185s -> 14s, 14s,
43s, 71s, 111s.
Still open: recapturing qual_name_tag_white.png is the actual fix for detection.
Co-Authored-By: Claude Opus 5 <[email protected]>
Biggest single cause of run failures: 25% of games failed, dominated by
"Approach failed for run_pindle [step: click_red_portal]", because the character was
running A5 pathing while physically in Act 4.
Chain: buy_consumables travels A5 -> A4 for Jamella ("Malah unreliable", 32x in one
session), repair then runs in A4 too, and Run Pindle's go_to_act(5, a4_town_start) asked
detect_current_act() to verify. It answered A5 while standing in Act 4, so go_to_act
"corrected" the assumption and skipped the travel entirely. 11 failures traced directly
to that trip in a single session.
Root cause: detect_current_act committed to whatever marker cleared 0.68 first. On a real
Act 4 failure frame:
A5_TOWN_1 0.636 <- phantom, always at (1046,40), top-right corner
A4_TOWN_5 0.619 <- the genuine marker for the act actually occupied
A 1.7 percentage point gap decided the act.
Fix: the winning marker must now also beat the best marker from ANY OTHER act by
_ACT_DETECT_MARGIN (0.05). Below that it logs "ambiguous ... refusing to guess the act"
and returns None. That is the safe answer: every caller treats None as "keep the assumed
act", so go_to_act keeps a4_town_start, sees it differs from the target, and actually
travels. Refusing to answer yields correct behaviour; guessing wrong does not.
Validated live: failure rate 25% -> 9% (10 successes, 1 failure). The first game of the
validation run was the exact failing case — repair starting from a4_town_start — and it
recovered and completed. Verified offline against 4 real Act 4 failure frames: all now
refuse instead of claiming A5.
The one remaining failure is a different cause: open_npc_menu timing out on qual_kehk
during merc resurrect (Bugs 3/4/6/7 family), not act desync.
Co-Authored-By: Claude Opus 5 <[email protected]>
The retry path I added treated detect_current_act() returning None as "we are not in
act 5" and aborted the run. None only means no TOWN_MARKERS template is on screen, which
happens routinely by the red portal in Harrogath's NE corner — the same blind spot that
makes on_init bail there.
All 6 occurrences in the 2026-08-26 19:0x session were "detected None"; not one was an
actual wrong act. Five of them landed consecutively, tripped the 5-strike circuit breaker,
disabled run_pindle and stopped an otherwise healthy 31-game session (level 32 -> 43,
526k gold stashed, 26/31 runs successful).
Fix: None now falls back to `loc`, the act-5 location already confirmed by go_to_act(5) at
the top of the same approach() call — a failed portal click cannot move the character
between acts. A genuinely different detected act still travels to act 5 as before, so the
Bug 9 desync protection is unchanged.
Co-Authored-By: Claude Opus 5 <[email protected]>
Gem conversion is off: [transmute] transmute= is now empty, which makes run_transmutes()
bail at "No gem tiers configured". That path also defeats force=True
(tools/gem_transmute.py), unlike transmute_every_x_game=0. Rejuv potion conversion is
unaffected — it runs independently via town_manager.convert_rejuv_potions().
Docs: record that the ACTIVE pickit set is config/bnip/Den gode.bnip and that it is
gitignored, so config/default.bnip edits have no effect on runs — an easy hour to lose.
Also document how should_pickup / should_id / should_keep interact, since that is what
makes a trailing catch-all rule work (pickup ignores the '#' clause, should_keep returns
on first match, so specific rules above still win).
Loot rule changes themselves live in the gitignored pickit file and are not in this commit:
- catch-all "[Quality] == Rare # [Strength] >= 999" so every rare is picked up and
identified, kept only if a specific rule matches, otherwise vendored for gamble gold.
Previously only 7 rare types had any rule, so rare weapons/armour/helms/shields were
never picked up and never sold.
- Flawless and Perfect gem lines uncommented (all gem rules had been commented out, so
no gems were being collected at all).
Co-Authored-By: Claude Opus 5 <[email protected]>
convert_all_gems never reached the GEMS tab. It logged "expected result ... 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
y=63..78 and the stash GRID starts at y~87 — so every tab-switch click landed on a stash
slot, not a tab. The X constants were already correct; only Y was wrong, by ~30px.
Measured centres: PERSONAL 68 | SHARED 144 | GEMS 220 | MATERIALS 295 | RUNES 370, y=70.
Second, independent bug found alongside it: params.ini had stash_tabs=6 with only 5 tabs
on screen. tab_properties() divides the bar by that count, giving centres of
63/127/192/256/320/384 against real centres of 68/144/220/295/370 — tabs 2, 3 and 4 were
clicking the gaps between tabs. Set to 5.
Tab switches are no longer fire-and-forget: _switch_to_tab() confirms the tab actually
became active, retries up to 3x, and returns False; convert_all_gems now aborts rather
than converting in the wrong tab. Active-tab detection measures the cell BACKGROUND
(p30 > 52; active ~67, inactive ~38) rather than glyph brightness — text brightness
scales with label length, so an active "GEMS" peaks at 167 while "PERSONAL" hits 215 and
any glyph threshold misreads the short label as inactive.
Verified live: four consecutive PERSONAL<->GEMS switches, each confirmed, using the real
class constants and detector.
Co-Authored-By: Claude Opus 5 <[email protected]>
run_pindle had a 100% failure rate, and worse, was reporting success while doing
nothing. Five root causes, found by recording the client and replaying the pather's
own matching against the captured failure frames.
1. a5_red_portal.png was the only MASKED template in a5_town/ (4-chan, 60.9% opaque)
because a hover tooltip had been baked into the capture and hidden with alpha.
That routed it to cv2.matchTemplate(TM_CCOEFF_NORMED, mask=...), which OpenCV only
supports for TM_SQDIFF/TM_CCORR_NORMED — hence 0.50-0.60 scores and match positions
that wandered onto unrelated scenery. Recaptured as a plain 3-channel opaque crop of
the portal's upper arch (the lower ring is occluded by branches).
Present 0.949-1.000 / absent 0.398-0.514, straddling the 0.68 threshold.
2. pindle.approach() opened with an "already in Pindle area?" shortcut. Harrogath
scenery scores 0.76-0.79 on PINDLE_7, over its 0.62 bar, so it fired in town and
returned A5_PINDLE_START without ever clicking the portal — the bot "killed Pindle"
in town for five straight games with zero loot and zero XP while logging
runs_failed_total: 0. Shortcut removed; entry is proven by the loading screen.
3. pather.find_abs_node_pos fell back to a 0.55 first-match search that fabricated node
positions (A5_TOWN_1 at 0.60-0.62 on scenery, three different frames, three different
phantom positions), steering the char into the town wall. Raised to 0.62, forced
best_match, and added a per-node heading gate that rejects a low-confidence match
implying a >90 deg reversal. Confident matches are never gated.
4. Walking onto the waypoint opened the WP panel, which health_manager counted toward a
chicken — 3 of 6 games died at full health. WP panels are now escaped without
counting, bounded at 6 attempts.
5. pindle retry re-pathed from a hardcoded A5_TOWN_START; it now verifies the act first.
Verified live: +349,890 XP over baseline, loot drops (Ring, gold), 6 games,
runs_failed_total 0. Docs updated with all four bugs, template asset conventions, and
how to verify a boss run actually killed something (XP delta + loot, never failed:false).
Co-Authored-By: Claude Opus 5 <[email protected]>
D2R UI hitboxes are tight; a 1px offset on the Join Game tab click was
enough to miss the button. Final SetCursorPos pass after the existing
verification check.
exception safety, on_end_run town shortcut, config enabled parse
- bot.py: GameRecovery(None) -> GameRecovery(DeathManager()) (None crashed
on death-screen handling); hide spot now clicks once (char actually walks
there); wait loop breaks on any non-InGame screen (death/kick) instead of
only MainMenu; whole handler wrapped in try/except with best-effort
recovery so the bot never stops; on_end_run() short-circuits for baal_xp
(char is already in town — skip TP logic, go straight to maintenance)
- config.py: enabled parsed as plain bool (was bool(int(str)) crash on
'true'); empty game_name_filter no longer crashes float()/int() overrides
- game_browser.py: drop unused imports (keyboard, Config, focus_d2r_window,
select_screen_object_match)
- src/ui/game_browser.py: new module for game browser interaction
(Play button → Join Game tab → OCR game list → click → loading)
- src/config.py: [baal_xp] section with enabled, game_name_filter,
max_wait_s, xp_threshold, min_hp_pct, hide_x/y, join_timeout_s
- config/params.ini: [baal_xp] section + route doc
- src/bot.py: baal_xp state, on_run_baal_xp handler (8-phase cycle:
leave own game → hero select → join public game → wait in-game →
corpse/nopickup/pre_buff → walk to hide spot → wait loop (XP/HP/timer)
→ leave → recover to own game), _recover_to_own_game helper
Enable by adding run_baal_xp to [routes] order in params.ini.
Launching D2R by hand kept failing in two different ways, both silent:
1. D2R.exe direct is fast but can come up with "Cannot Connect to Server" when
the client has no Battle.net session.
2. The launcher's Play button always yields an authenticated client, but a
hardcoded coordinate for it clicks the DESKTOP whenever the launcher has
moved, been minimised to tray, or is DPI-scaled — which opened unrelated
applications rather than reporting a failure.
So: try the exe, fall back to Play, and find Play by COLOUR rather than a fixed
point. It is the large saturated-blue block in the launcher; sampled live it is
HSV ~(104, 255, 122), and the value channel being that low is why a naive
"bright blue" threshold matches nothing.
show_launcher() also restores/maximises the window first, since the button
cannot be found while the launcher is hidden in the tray.
Never passes params.ini launch_options: those resolve to "-mod profile -txt",
and -mod puts D2R in offline mode where ladder does not exist.
client_size() imports utils.misc for its side effect of setting per-monitor DPI
awareness. Without it GetClientRect returns logical pixels, so a correct
1280x720 client reads as 1024x576 under 125% scaling and looks like a
resolution fault that is not there.
Co-Authored-By: Claude Opus 5 <[email protected]>
- return_to_town() walks pather nodes [705, 702] (A1 outdoor->town) in
reverse, using the pather's auto-recovery sweep to handle mis-positioning
- _drink_if_needed() drinks belt HP/mana potions mid-run so a lvl 1 sorc
doesn't die before it can walk back to town
- bot.py: _current_run tracks the active run object; on_end_run() calls
run_obj.return_to_town() for no-TP chars instead of wasting time on
tp_town() retries
Movement was the else-branch of "did the scan find anything", and motion
detection almost always finds something, so roamed was 0 in every single run.
She never relocated — she churned on one spot re-detecting the same movement,
which is why XP crawled (140 xp in 6 minutes) and why she appeared to ignore
the rest of the map.
Make the rotation unconditional: move N steps (--move-clicks, default 3), then
scan, then fight until clear or --max-engagements, then repeat.
Also stop her running past targets. At range she now force-moves toward the
target instead of left-clicking: a left-click that lands on ground rather than
on the monster is a MOVE order, so any small offset in the motion-blob centre
turned an attack into a walk-past. Only inside --melee-dist does she click, and
then with stand-still held so a swing can never be reinterpreted as movement.
Adds --max-engagements, which the rotation referenced but was never defined as
an argument — that raised AttributeError mid-run and aborted the session.
Co-Authored-By: Claude Opus 5 <[email protected]>
get_build_skill_checks() only knows hammerdin/fohdin/blizz_sorc. For any other
build (basic, basic_ranged, ...) it returns an empty list, so the tool fell back
to [char] keys alone and never checked the build's own hotkeys — e.g. a
basic_ranged character reported only town_portal, leaving right_attack and
buff_1 unverified.
When no preflight rules exist, read the section named after the build type
instead. Only applies when the rule-based lookup found nothing, so hammerdin and
blizz_sorc are unaffected.
Verified across three builds against real .keyo files:
hammerdin (profile1) 10/10 ok
blizz_sorc (ding) 7/7 ok
basic_ranged (ding_level) 3/3 ok — was 1/3
Co-Authored-By: Claude Opus 5 <[email protected]>
SKILL_KEYS was a fixed Paladin list read via Config().char.get(). Build skills
live in their own sections ([hammerdin], [blizz_sorc], [sorceress]), not [char],
so that lookup returned empty for all of them and they were never checked.
In practice the tool only ever verified 3 keys — teleport, battle_orders and
battle_command — for every build. A hammerdin's blessed_hammer/concentration/
redemption/vigor/conviction/holy_shield went unverified, and a sorc got no
build coverage at all (blizzard, ice_blast, static_field, energy_shield,
telekinesis all silently skipped).
Derive the list from skill_preflight.get_build_skill_checks() instead, which
already resolves each build's skills out of the right config section, and keep
[char]-level keys (teleport, town_portal) alongside. battle_orders/
battle_command are now gated on cta_available so a character without a Call to
Arms doesn't report two permanent false "unbound" entries.
Also print the build alongside the file so it is obvious what is being checked.
Verified against two real .keyo files:
hammerdin (fistman) — 10/10 keys ok, was reporting only 3
blizz_sorc (ding) — 7/7 keys ok, was reporting only 1
Co-Authored-By: Claude Opus 5 <[email protected]>
find_keyo() matched the .keyo filename against general["name"], which is the
bot profile ("profile1"), not the character ("fistman"). No file ever starts
with the profile name, so it silently fell through to files[0] — the
alphabetically first .keyo in Saved Games.
In practice that meant `testbed.py keyo` verified Burr114743261.keyo while the
bot itself read Fistman211469871.keyo (key_detector correctly uses char_name),
reporting 3 unbound keys for a character that isn't being played. Running
--fix would have WRITTEN those binds into the wrong character's file.
Now keys off char_name (falling back to name), and normalises both sides
before prefix-matching, since D2R suffixes the file with an account id
("Fistman211469871.keyo") — the same approach key_detector._find_key_file
already uses.
Also stop falling back to an arbitrary character when char_name is set but
matches nothing: raise with the list of files found instead. Silently
verifying/writing someone else's bindings is worse than failing.
Verified: testbed.py keyo now reports the correct file and
"Controls layer matches params.ini".
Co-Authored-By: Claude Opus 5 <[email protected]>
target_detect.get_visible_targets cannot see ordinary monsters. FILTER_RANGES
holds two HSV bands only — "poison" (hue 38-70) and "frozen" (hue 110-120) —
so it finds monsters already tinted by Poison Nova or Holy Freeze and nothing
else. Measured against a live Fallen and zombie in Blood Moor: 0 targets at
every radius from 300 to 1280. That is why run_level, run_cold_plains and
firebolt_roam all roam without ever attacking on a fresh character.
Scan by motion instead: D2 monsters animate continuously, so differencing two
frames ~180ms apart lights them up against static terrain. The HUD and a radius
around the character are masked out to drop her own animation. Verified live —
38 engagements in 2 minutes and a confirmed Fallen kill.
Attacks with plain left-click, which walks to and hits the target: correct for
a clvl 1 sorceress with a staff and no skills (a level 1 character has zero
skill points, so there is nothing to cast).
Includes potion handling via the same meters.get_health the bot's health_manager
uses: drinks a healing potion at or below --heal-at (default 0.30). When nothing
is found it takes --move-clicks steps (default 3) before rescanning, rather than
rescanning after every single step.
Also carries tools/manual_drive.py onto this branch — it was committed only on
feat/manual-drive-seasonal-char, so it vanished from the working tree here.
Co-Authored-By: Claude Opus 5 <[email protected]>
- on_init: cold-plains-only profiles skip town-marker detection and start
directly (char is already standing in A1 town), same as run_level-only
- on_end_run: chars with no teleport at all (pre-clvl-18) walk back to
town instead of failing on TP; _walk_back_to_town() walks south in
steps checking for town markers, then maintenance's open_wp()
re-detects the physical act and traverses from there
Enables the bloodmoor profile: lvl 1 sorc firebolt grinder that walks
out to Cold Plains, scans for enemies, kills, and walks back.
Windows refuses SetForegroundWindow from a process that does not already own
the foreground. The refusal is silent-ish and was swallowed, so the window never
came forward and every subsequent key and click went to whatever window WAS
focused. Measured on a live client: pressing the inventory key changed 0.89 mean
pixel value (i.e. nothing happened); after the fix, 16.44.
Attach our input queue to the current foreground thread for the duration of the
call — the documented way round the foreground lock — and return True only if
D2R actually ended up foreground, instead of returning True for "no exception
was raised".
Also harden tools/firebolt_roam.py with two preflights, because both failure
modes are silent — the character just stands there:
- refuse to start if D2R cannot be brought to the foreground
- refuse to start if the attack hotkey does not change the right-skill icon,
i.e. no skill is bound to it (--skip-skill-check overrides)
Verified live: with nothing bound to F1 the tool now exits with an actionable
message rather than roaming silently casting nothing.
Co-Authored-By: Claude Opus 5 <[email protected]>
src/run/cold_plains.py needs a waypoint, the town manager and the bot state
machine. None of that is usable for a clvl 1 character standing in Blood Moor,
which has no waypoint at all.
This starts from wherever the character already is: scan with target_detect ->
cast the configured skill at the nearest target -> roam if nothing is visible.
No waypoints, no town, no state machine.
python tools/firebolt_roam.py --key f1 --minutes 10
The skill is a plain hotkey, so Fire Bolt now and Fireball at clvl 12 is the
same command. Stop key (default F12) is polled between every cast, and there is
a hard --minutes budget.
Explicitly NOT included: potion, chicken or death handling. Documented in the
module docstring — it will keep casting while dying, so it wants supervision.
Co-Authored-By: Claude Opus 5 <[email protected]>
discord_embeds.py imported discord at module level. discord pulls in aiohttp,
which builds a default SSL context at import time, which loads the Windows
certificate store. On this machine that raises
ssl.SSLError: [ASN1: NOT_ENOUGH_DATA] not enough data (_ssl.c:4030)
for EVERY certificate (65/65 across the CA/ROOT/MY stores — not one bad cert,
but ssl.load_verify_locations(cadata=...) failing wholesale against the env's
OpenSSL 3.6.3, which is much newer than this Python 3.10 build expects).
Because ui_manager -> messages -> discord is on the import path of every run,
that made the bot — and any tool importing target_detect or screen — impossible
to start. An optional notification dependency should never do that.
Import it defensively instead: on failure log a warning, set DISCORD_AVAILABLE
False, and substitute inert Embed/Color/File stand-ins so the send_* methods
still build their payloads without special-casing every call site. _get_webhook
returns None when unavailable and _send_embed already early-returns on that, so
nothing is transmitted.
Verified every notification path (message/death/chicken/stash/gold/error) is a
silent no-op rather than a crash, and that target_detect/screen/input_layer now
import cleanly.
This restores startup; it does not fix Discord itself. That needs the env's
openssl pinned back to something this Python supports.
Co-Authored-By: Claude Opus 5 <[email protected]>
Every existing run targets a boss and assumes endgame damage and (mostly)
teleport, so none of them are usable while levelling. run_level is closest but
hardcodes plain left-click attacks, which does nothing for a caster.
cold_plains takes the waypoint to a configurable Act 1 area, then loops:
scan with target_detect -> cast one configured skill at the nearest target ->
loot -> roam if nothing is visible. The bot's normal end_run TPs it home.
It sends its own hotkey rather than going through the build's _skill_hotkeys,
so it works with any build. A blizz_sorc profile can run it with Fire Bolt long
before Blizzard (clvl 24) exists, and re-binding to Fireball at clvl 12 is a
one-line config change.
Guards, so an unattended run cannot wedge:
- empty attack_hotkey fails in approach() with step "no_attack_hotkey" rather
than roaming for three minutes doing no damage
- max_runtime_s caps total battle time
- max_engagements caps casts per step, so an immune or misdetected target
cannot pin the run in one spot
Disabled by default: routes are built from [routes] order, and the new run is
not in it, so Config().routes.get() returns None and the entry is filtered out.
Opt in by adding run_cold_plains to the order.
PREREQUISITE, documented in params.ini: the destination waypoint must already be
discovered. A fresh character has none, so it needs one manual walk out first.
Co-Authored-By: Claude Opus 5 <[email protected]>
Old template was too large and included background elements that changed
between D2R versions, causing template matching to fail at all thresholds.
New crop isolates just the waypoint stone itself.
- Restore FIRST-RUN.md onboarding guide
- Pin environment.yml to requirements.txt (was loose 24 packages)
- Remove debug screenshots from git (debug_*.png -> .gitignore)
- Remove .hermes/ dev plans from git (already in .gitignore)
- Archive botty_next/ test harness to alexpolo1/my-botty-tools
- Archive docs/legacy-go/ to alexpolo1/my-botty-tools
- Move 14 dev tools from repo root to tools/ directory
- Extend .gitignore to prevent dev artifacts re-entering root
- Promote stable branch with all bug fixes from main
- Bump version 0.8.4 -> 0.9.1
Pindle (src/run/pindle.py):
- Pre-check if already in Pindle area before portal click
- Extended verify timeout to max(5s) for temple marker detection
A5 waypoint (src/town/a5.py):
- Two-tier WP scan: 0.45 threshold first, then 0.55
- Progressive stash thresholds (0.60 -> 0.50 -> 0.40) with direct click fallback
NPC detection (src/npc_manager.py):
- Close waypoint panel before NPC search (prevents WP UI blocking template match)
Diablo battle (src/char/paladin/hammerdin.py):
- Extended spawn wait from 15s to 20s
- Mid-fight target re-verification and repositioning
- Extra redemption burst to ensure kill
Auto-fixer (test/auto/auto_fixer.py):
- Updated to detect and verify all applied fixes
CI (.github/workflows/ci.yml):
- Added log analyzer tests to CI pipeline
- Excluded self-healing orchestrator (requires live D2R) and broken tests
New modules:
- test/auto/log_analyzer.py: parses bot logs and event JSONL files,
categorizes failures (approach, maintenance, battle, chicken, timeout,
crash, OCR) into structured BotFailure objects
- test/auto/auto_fixer.py: maps failure patterns to targeted code fixes,
checks if fixes are already applied, reports what needs work
- test/auto/test_self_healing.py: orchestrator that launches the bot,
monitors for failures in real-time, analyzes logs, applies fixes,
and retries up to 3 rounds
Supporting changes:
- test/auto/test_log_analyzer.py: 8 tests against historical run data
- pytest.ini: added repo root to pythonpath for test package imports
- test/__init__.py: new, enables test/ as importable package
All 95 existing tests pass. 8 new auto-test tests pass.
GetAsyncKeyState only detects keys when the bot process has focus.
When D2R is focused, F11/F12/End hotkeys were silently ignored.
Replaced polling loop with SetWindowsHookEx WH_KEYBOARD_LL which
intercepts all keystrokes globally before they reach any app.
Keeps polling as fallback if hook installation fails.
Also:
- Removed if-gate on enforce_d2r_window in game_controller.start()
- Added try/except pywintypes.error around all SetWindowPos calls
- Added pywintypes import to misc.py
- Added stop_hotkeys() cleanup in on_exit
enforce_d2r_window() now returns False when D2R is elevated, but the
if-gate caused all subsequent setup (window position, health manager,
death manager, game thread) to be skipped. Removed the if-gate so
startup continues regardless of window move result.
Wraps SetWindowPos in try/except pywintypes.error in move_d2r_window,
set_d2r_always_on_top, and restore_d2r_window_visibility. When D2R runs
as Administrator, Windows denies the call. Bot now continues gracefully
instead of crashing.
Wrapping SetWindowPos in try/except pywintypes.error in both
set_d2r_always_on_top() and restore_d2r_window_visibility(). When
D2R runs as Administrator and the bot runs as standard user,
Windows denies the call, causing a crash. Now it logs a debug
message and continues gracefully.
After Pindle fast-save/exit, the character spawns near the Nihlathak
portal area. During maintenance, identify() often fails and resets
_curr_loc to A5_TOWN_START, causing the pather to navigate from the
wrong starting position. The WP stone is often already visible on
screen, but the bot wasted 45s trying node-based pathing first.
Add an immediate full-screen WP scan as step 0 before any pathing.
This finds the WP directly when it's visible, bypassing stale
location tracking entirely.
Every fatal message told the user THAT something failed but not what to do
about it. The worst was "conda env create failed. See output above." --
useless when run_install_capture.bat redirects that output to a 56 KB log.
Each error now names the likely cause and the concrete next step:
- download failed -> the URL tried, firewall/proxy hint, manual-install
fallback that install.bat will detect on re-run
- truncated download -> got N bytes vs expected ~78 MB, bad file deleted
- installer failed -> antivirus/UAC hint, how to run it by hand
- conda found but dead -> the exact command to reproduce the real error
- env create failed -> disk/network/antivirus causes, plus the
"env remove -n botty -y" recovery for a half
finished install
- pip install failed -> notes the env itself is fine and a re-run resumes
- python.exe missing -> explains partial env, gives the recovery commands
- find_python.bat -> distinguishes "never installed" from "install.bat
did not finish", pointing at the capture log
Added a shared ":fail" exit so every fatal path ends with how to produce a
full log for a bug report, and states that nothing else was changed.
Also added a disk-space pre-flight before env creation: under 3 GB now
fails immediately with a clear message instead of letting conda die halfway
through with an opaque error; 3-6 GB warns. The environment needs ~4 GB
plus ~1 GB of downloads.
Verified: install.bat still completes with exit 0; the pre-flight was
exercised at real, simulated-2 GB and simulated-5 GB levels and all three
branches render and exit correctly; find_python.bat still resolves; 140
tests pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
install.bat had 9 pause statements on failure paths and none on the
success path. The README tells users to double-click install.bat, so on a
successful install the console vanished the moment it finished -- a new
user never saw "Installation complete", the OCR verdict, or the dependency
verification, and had no way to tell whether it had worked.
Added pause to the success path.
run_install_capture.bat redirects stdout to install_log.txt, so that new
pause would have blocked behind the redirect: an empty window silently
waiting on a keypress the user cannot see. It now feeds stdin from nul,
reports success/failure with the log path, and pauses itself.
Verified non-interactive: run_install_capture.bat completes in ~28s with
exit 0 and no hang.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
test_setup_bat_files.py asserted that run_asset_extractor.bat and
run_quest_debug.bat exist in the repo root. Those are developer tools that
the end-user `stable` branch deliberately strips, so a fresh clone of
stable shipped 4 failing tests even though the bot was fine.
Split the list into CORE_BATS (install/find_python/run_botty -- required on
every branch) and OPTIONAL_BATS (dev tooling -- validated only when
present). The username, absolute-path and find_python checks now iterate
over the files that actually exist rather than a hardcoded list.
Found by cloning stable from GitHub onto a clean machine and running the
suite as a new user would.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Includes the pefile import-chain technique that found it, since WinError
126 names the importing DLL and never the missing dependency.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
tesserocr never loaded -- install.bat always reported "tesserocr: not
available (DLL issue)" and the bot ran on the pytesseract fallback, which
shells out to tesseract.exe per OCR call instead of using the in-process
C++ API.
Root cause, found by walking the import table with pefile:
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.x 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") -- which is why the error
looked like a missing module even though every file was present.
Fix: install libdeflate explicitly alongside tesseract=4.*, then copy
deflate.dll to the legacy name libdeflate.dll when that name is absent.
Same library, same exports.
Verified: removing the alias reproduces the failure exactly; running
install.bat recreates it and the installer now reports "tesserocr: OK
(fast path)". tesserocr initialises and performs real OCR with both bundled
models, and the bot logs "OCR backend: tesserocr (primary)" at startup.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
run_install_capture.bat writes install_log.txt into the repo root. It was
untracked but not ignored, so it showed up as noise in git status and was
easy to commit by accident.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Both were found by running install.bat under simulated clean-machine
conditions (conda removed, winget stripped from PATH, Tesseract hidden).
Bug 20: an unescaped ")" in an echo inside a parenthesised block aborted
the script at parse time, killing the conda direct-download path -- the
only path available without winget.
Bug 21: winget defaulted to machine scope, so the installer needed admin
and failed silently on a normal double-click.
Also documents the two recurring batch pitfalls with an awk audit command,
and the measured limitation that Tesseract has no per-user install path.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Testing the direct-download fallback with Tesseract absent and winget
unavailable showed the official installer self-elevates and its elevated
relaunch discards /D=, so it always installs machine-wide to
"C:\Program Files\Tesseract-OCR" regardless of TS_DEST.
The previous comment claimed this path gave a per-user install needing no
admin rights, which is not true: there is no per-user install path with
the official Tesseract installer, and it requires admin/UAC. Corrected the
comment rather than the code -- /D= is harmless as best-effort, and both
find_tesseract and src\d2r_image\ocr.py already search the machine-wide
and per-user locations, so either outcome works at runtime.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The Miniforge direct-download fallback -- the only path available on a
clean machine without winget -- could never complete. install.bat aborted
with ". was unexpected at this time." immediately after running the
Miniforge installer, so conda was installed but the botty env was never
created and the bot was unusable.
Cause: line 133 echoed "(exit code %errorlevel%)" inside a parenthesised
if-block. An unescaped ")" inside a block terminates the block, leaving
"." as a stray token. cmd parses the entire if-block when it reaches it,
so this fired even when the installer SUCCEEDED and the block body was
never meant to run -- verified with a minimal repro: the unescaped form
exits 255 on a false condition, the escaped form exits 0.
Fix: escape as ^(exit code %errorlevel%^), matching the convention the
rest of the file already uses ("^(fast path^)"). Audited every echo
inside a block; this was the only remaining unescaped instance.
Found by running install.bat with winget removed from PATH to simulate a
clean Windows 10 machine.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Live clean-install test caught a syntax bug in the new Tesseract setup
block: install.bat died with "so was unexpected at this time." right
after "Setting up OCR...", so OCR setup and the whole dependency
verification stage never ran.
Cause: "::" comment lines placed INSIDE parenthesised if-blocks. Two
problems compound there -- a "::" line inside a ( ) block is itself a
parse error, and any parenthesis in the comment text closes the block
early. The text "(non-zero when already installed), so after each" left
"so" as a stray token.
Fix: move every comment out of the parenthesised blocks, in both the
Tesseract block and the conda winget block added earlier. The conda one
had survived only because its text happened to contain no parentheses.
Verified: install.bat now runs to completion with exit 0 --
Tesseract: C:\Program Files\Tesseract-OCR\tesseract.exe
pytesseract: OK (tesserocr: DLL issue, expected)
cv2/mss/numpy/transitions/rapidfuzz/pydantic/pytesseract/yaml/discord: OK
All dependencies verified.
140 tests pass against the freshly created env.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Follow-up to the conda scope fix: the same admin/winget assumptions broke
the OCR backend, which is what actually carries item text reading since
tesserocr's MSVC DLL chain commonly fails to load.
- install.bat installed Tesseract via `winget install` with no --scope,
i.e. machine-wide into "C:\Program Files", which requires admin. On a
clean non-admin box this failed and left NO working OCR backend at all
(tesserocr already fails), so OCR_READY=0 and item reading was dead.
Now: winget machine scope -> winget --scope user -> direct download of
the official NSIS installer with a per-user /D= target. Also stops
trusting winget's exit code (non-zero when already installed) and
re-resolves tesseract.exe after each attempt.
- The downloaded installer is size-checked (~50 MB; <20 MB = failed
download) before being executed, matching the Miniforge handling.
- Added a :find_tesseract subroutine that resolves tesseract.exe from
Program Files, Program Files (x86), %LOCALAPPDATA%\Programs,
%ProgramData% and PATH. Verification now uses the resolved path instead
of the hardcoded "C:\Program Files" one.
- ocr.py: added Program Files (x86) and the per-user
%LOCALAPPDATA%\Programs\Tesseract-OCR location to the runtime search
order, since per-user installs are not on PATH.
- run_botty.bat: only export PYTESSERACT_TESSERACT_CMD when the file
exists, falling back to the per-user path, so a stale machine-wide
value cannot shadow a valid per-user install.
Verified on this machine: all install.bat dependency imports OK
(cv2/mss/numpy/transitions/rapidfuzz/pydantic/pytesseract/yaml/discord),
pytesseract resolves tesseract 5.5.0, osdetect reports the win11 profile,
config loads, 140 tests pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
install.bat could fail to auto-install conda on a fresh, non-admin
machine:
- winget install used the default (machine) scope, landing conda in
%ProgramData% and requiring elevation. A normal double-click without
admin failed silently and conda never installed. Add --scope user so
it installs to %USERPROFILE%\miniforge3 with no admin needed.
- winget returns non-zero when the package is already present, so its
exit code was unreliable. Rescan for conda.exe after winget and only
fall through to the direct download when it is genuinely missing.
- The GitHub (git) download fallback never validated the file before
running it: a truncated download or an HTML error page served with a
200 would be launched as the "installer" and silently do nothing. Add
a size check (<40 MB => failed download, clear error + bail) plus a
pre-download cleanup of any stale temp file.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
rapidfuzz 3.x moved levenshtein out of rapidfuzz.string_metric (removed)
into rapidfuzz.distance.Levenshtein, but Levenshtein is now a module, not
a function. The fallback alias pointed at the module — fix it to bind the
.distance method directly.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
environment-win11.yml installs only from requirements.txt, which was missing
pyinstaller. build.py constructs the full path to pyinstaller.exe so the
install is sufficient; no PATH change needed.
Verified locally: 99 passed, 0 failed.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
environment-win11.yml pulls only requirements.txt (not environment.yml), so
coverage, pytest, pytest-env, pytest-mock, and pytest-pythonpath were missing
from the CI conda env. Pinned to versions matching the local botty env.
Verified locally: 99 passed, 0 failed.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
conda activate does not add Scripts/ to PowerShell PATH in the GitHub Actions
runner. Switching to 'python -m coverage' works regardless of PATH state.
Verified locally: 99 passed, 0 failed.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
CI was failing at conda env setup: pip could not find async-timeout==5.1.0.
Latest available version is 5.0.1.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
time.time() on Windows has ~15 ms resolution; a 0.0-second budget produced a
deadline equal to the current tick, so the anchor-loop check never fired and
traverse_calls reached 5 instead of 1. Using -1.0 puts the deadline one second
in the past — guaranteed expired on any hardware.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
A "999" gem count (OCR couldn't read the digit, code assumes "convert
until depleted") relied on the loop noticing an empty stack and
breaking — but that check only ran when _gems_stack_monitor_for
returned None. For any registered gem type it always returns a static
screen coordinate, so the depletion check was dead code: the loop
just kept blindly clicking the same fixed position forever.
Observed in the wild: stuck on Topaz Flawless for 30+ minutes and 177
iterations (of a fake "999" target, ~2.7h worst case) before being
manually force-exited, repeatedly clicking fixed convert-panel/GEMS
coordinates with nothing real there — the likely cause of it also
grabbing and re-placing unrelated stash items during that time.
Now always does a live template search before clicking, breaking
immediately once the stack is genuinely gone, on every gem type.
Verified end-to-end with a mocked run: a fake depleted "999" stack now
stops instantly instead of looping, and a real gem right after it
still converts correctly.
Every single game logged "Failed to find Battle Command, swapping
weapons again" — 1182 times in the last log alone, always on the
first attempt, always resolved by the very next loop iteration's
identical check with no extra wait in between. The skill icon just
takes a bit longer than the fixed 0.6-0.8s wait to render on this
system; the check was racing it every time.
Poll for up to 1.2s instead of a single check after a fixed wait.
Catches the skill as soon as it's actually visible rather than always
failing once first, and removes the latent risk of the fallback path
incorrectly swapping back to the main weapon if timing ever degraded
further.
It already read Config().char.get("protect_charms_from_sell", True) in
personal.py's drop/sell guard, but the key was never added to the char
config dict builder in config.py, so setting it in an ini file did
nothing — charms were unconditionally undroppable regardless of the
pickit verdict. Wired it up the same way protect_shields_from_sell
already works. Defaults to 1 (protected, unchanged behavior) so this
is opt-in only.
When a tab showed a free slot but the specific placement click kept
failing, the code deliberately gave up rather than advance tabs (to
avoid falsely triggering stash_full()'s taskkill on a transient
glitch). In practice this meant the bot got stuck retrying the same
tab forever every game, leaving loot in inventory even when every
other stash tab was completely empty.
Now it tries the next tab (up to all 6) on repeated transfer failure,
same as it does for a genuinely full tab — but never calls
stash_full() from this path, only from the original "confirmed no
empty slot anywhere" detection. Verified with a mocked simulation:
cycles through failing tabs to a working one, and degrades gracefully
(leaves items in inventory, no crash, no false stash_full) if every
tab fails.
Was hardcoded to 10 games; a strict pickit on a fast boss-only rush
route can legitimately go 10 games without a keep-worthy drop, making
the log warning noisy. Defaults to 10 (unchanged), override per-user
via profile.ini.
The run_diablo route was failing every game. Diagnosed and fixed live —
a full run now clears all three seals (Vizier, De Seis, Infector) and loots.
Pentagram navigation (was the #1 abort: "battle_failed", char stranded in
CS trash, pentagram never detected):
- _loop_pentagram now falls back to active node-602 navigation when the blind
fixed-path teleport loop fails to surface the pentagram. Node 602 searches the
PENT templates directly and teleports toward them with the pather's auto-
recovery sweep — the same robust approach _cs_pentagram already uses. Applied
in both diablo.py and vizier.py.
- Combined with the lowered _PENT_THRESHOLD (0.50), the pentagram now resolves:
live reads were 57-96% where the old 0.83 threshold rejected them.
Seal layout check (next abort after the pentagram fix, at the Vizier seal):
- Added per-seal score logging (LC primary/confirm). This revealed the real
cause is character-positioning variance, NOT template drift: the true layout
reads 84-88% and the other 42-57% (clean separation) when well-positioned, but
from a bad camera angle BOTH read ~55-66% and the check is ambiguous.
- So the fix is more re-approach attempts (max_attempts 2 -> 3), not lower
thresholds — lowering a disambiguation threshold risks picking the WRONG seal
from a bad-position read.
- Normalized seal-A threshold_confirmation 0.85 -> 0.80 (every other seal is
0.80; safe given the 84-88% vs 42-57% separation).
Crash fix (game_recovery.py): go_to_hero_selection had been dedented to module
level while its body kept method indentation, so it fell out of the GameRecovery
class. Every post-chicken/death/failed-game recovery threw
AttributeError: 'GameRecovery' object has no attribute 'go_to_hero_selection'
and killed the run_bot thread. Re-indented into the class. Verified live: the
bot now recovers from failed games and auto-starts the next one.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
- replace print() with Logger.debug() in misc.py to fix colorama OSError crash on restart
- try/except RuntimeError in template_finder ThreadPoolExecutor so interpreter shutdown falls back to sequential matching
- add last-resort direct WP scan in A5 open_wp after anchors fail
- extend go_to_hero_selection timeout 30s->45s, add ESC fallback after 15s if blocked by UI panel
- remove startup warning spam in screen.py
The A5 waypoint stone matches reliably (65-91% when on screen). The real
failure mode is a stale curr_loc that lands the char off the stone, so
select_by_template("A5_WP") never matches. The old escalation (NPC anchors +
a 6-step directed sweep) then looped for 5+ minutes — the "stuck in town"
behavior seen in log/log.txt 2026-06-24 (08:28:48 -> 08:30 force-exit).
- Add a 45s hard wall-clock budget to open_wp; bail between anchors once past.
- Drop the directed sweep entirely: it never recovered in practice and was the
main multi-minute time sink. A failure now returns fast so the caller falls
back (buy at Malah / skip to stash) instead of stranding the bot.
- Also folds in the in-progress A5 repair-menu timing fix (wait_until_visible
instead of a too-short 0.2-0.3s peek).
- Add test/town/a5_open_wp_test.py covering fast-fail, quick-mode, and budget.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
A single bot session produced a 22 GB log. The file logger used daily-only
rotation (TimedRotatingFileHandler when='midnight') with NO size cap, so a
long/spammy session grew log.txt unbounded within a day. Its archiver also
looked for .1/.2 backups that the timed handler never produced.
- logger.py: switch to size-based RotatingFileHandler — log.txt rotates at
50 MB (override via BOTTY_LOG_MAX_MB), keeps 5 zipped backups, and prunes
log/archive/ to 30 zips. Hard cap on both the live file and total disk.
The .1/.2 naming now matches what the handler emits, so archiving works.
- install.bat: pip --progress-bar off. The progress bar redraws via \r;
redirected to a file (run_install_capture.bat) those redraws became
millions of lines — the other way an install log balloons to GBs.
- params.ini: document the log.txt cap + BOTTY_LOG_MAX_MB.
Verified: with a tiny cap, log.txt stayed under the limit while rotated
files zipped to archive; full suite 80 passed / 2 skipped.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
The bare "config-3.py" omit never matched the phantom's absolute path
(D:\a\...\config-3.py), so coverage xml only survived via --ignore-errors
and still logged the alarming "No source for code" line. Use a glob
(*config-*.py) that matches the phantom at any path while keeping
src/config.py measured (verified via coverage GlobMatcher).
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Make the standalone exe work with OCR out of the box — no separate
Tesseract install, no tesserocr DLL hell. Verified end-to-end: built the
exe, ran it frozen with the system Tesseract blinded, confirmed it
resolves the bundled binary and reads text ("CHAM RUNE").
- ocr.py: resolve an _APP_BASE (exe dir when frozen, else cwd) and prefer
a bundled <exe_dir>/tesseract/tesseract.exe over PATH / Program Files.
Resolve assets/tessdata to an absolute path so OCR no longer depends on
the current working dir. Applies to both the tesserocr and pytesseract
paths.
- build.py: copy a portable Tesseract (exe + DLLs) from TESSERACT_DIR
(default C:\Program Files\Tesseract-OCR) into <release>/tesseract/. Our
trained models in assets/tessdata are used via --tessdata-dir, so their
tessdata is skipped. Warns (non-fatal) if Tesseract isn't present.
- ci.yml: choco install tesseract before the build so the bundle is
reproducible on the runner; verify it landed in the release dir.
- test/conftest.py: apply the SSL cert-store workaround so pytest can be
collected on Windows boxes with a corrupted cert store (no-op on CI).
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- git rm --cached data/d2jsp_pages (102 files, already gitignored) — saved
authenticated HTML embedded the live d2jsp msec session token
- apply_manual_cookies.py: read member_id/msec from env vars instead of
hardcoding the real session token
History purge of these blobs follows in the same cleanup.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Pre-public cleanup:
- Remove cookies.txt from tracking (held live d2jsp session cookies
member_id + msec) and delete the local copy
- .gitignore: cookies.txt, cookies_temp*, *.cookies, config/custom.ini
- fg_scrape_pipeline.sh: replace hardcoded /c/Users/alex/Downloads path
and /c/Python313/python with a script-relative cd and $PYTHON from PATH
NOTE: cookies.txt still exists in git history (commit e3d6605) — a
history purge + cookie rotation is still required before going public.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Switch from the upload-to-an-existing-release model (create release in
UI/CLI first, release:published triggers CI) to a tag-driven flow:
git tag v0.8.5 && git push --tags
-> CI builds + smoke-tests both exes
-> softprops/action-gh-release creates the release and attaches the zip
Build fails => no release is ever created (no more empty/half-published
releases). Removes the release: trigger so the workflow can't double-fire
when the action publishes the release. Keeps permissions: contents: write.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
The build job's "Upload to Release" step failed with HTTP 403
"Resource not accessible by integration" because the default
GITHUB_TOKEN is read-only. Add top-level permissions: contents: write.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Verified by building locally and running both exes to their menus.
build.py: prepend the conda env's Library\bin, Library\lib and DLLs
dirs to PATH before invoking PyInstaller. PyInstaller resolves binary
deps via PATH (not --paths, which only affects Python imports), so
without this the frozen exe crashed at startup with
"DLL load failed while importing _ctypes" (missing ffi-8.dll, plus
liblzma/libbz2). Now ffi-8/lzma/bz2/leptonica/tesseract52 all bundle.
src/shopper.py: mirror main.py's startup header — add the
ssl.load_default_certs monkey-patch (corrupted Windows cert store made
aiohttp crash at import with ASN1 NOT_ENOUGH_DATA) and drop Library\bin
from os.add_dll_directory (it ships mismatched OpenSSL DLLs that break
_ssl). shopper.exe previously crashed on any machine with a bad cert
store; CI's clean runner masked it.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Split monolithic 'Validate Botty' into 4 named steps so failures are
visible per-step without log diving: Python version / Syntax check /
Tests / Coverage report
- Add scripts/ to compileall so new scripts are syntax-checked too
- Add -v to pytest for per-test pass/fail in CI output
- Fix coverage xml exiting 1 on conda phantom config-3.py:
- Move ignore_errors to [report] section (was wrongly in [xml])
- Add --ignore-errors flag on coverage xml command (belt+suspenders)
- Omit config-3.py and site-packages paths from [run] tracking
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Fixes CI build job which passes --conda_path C:\Miniconda but
botty_env was hardcoded to C:\Users\alex\.conda\envs\botty.
Also replace os.system mkdir with os.makedirs and add error
detection so PyInstaller failures are not silently swallowed.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
'coverage xml' was failing with 'No source for code: config-3.py' because
coverage was tracing conda internals (config-3.py from Miniconda's base env).
Add source=src to restrict coverage collection to our source tree, and
ignore_errors=True in [xml] as a safety net for any remaining phantom paths.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- bnip/utils.py: standardise error code prefix NIP_0x23 -> BNIP_0x23 to match
all other error codes in the bnip module (all use BNIP_ prefix)
- test_transpile: update syntax_test9 expected code 0x11 -> 0x23; parsing of
'[idname] > ring' now hits the unique/set lookup (BNIP_0x23) before the
logical-operator token check (BNIP_0x11) fires
- test_skill_preflight: test_unknown_build_has_no_checks used 'hammerdin' as
the unknown build, but hammerdin now has skill checks defined; switch to
'nonexistent_build' which has no checks
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Wrapper for install.bat that redirects all output to install_log.txt so the
install can be run non-interactively and the log inspected after the fact.
Useful for debugging installs in CI or remote sessions.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
discord.py imports aiohttp which calls ssl.create_default_context() at import
time. A malformed cert in the Windows cert store raises ASN1 NOT_ENOUGH_DATA.
The bot patches this at runtime via _patch_ssl(); the install check now applies
the same workaround (null-patch load_default_certs) so discord: OK on all machines.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Echo with (no ABSOLUTE flag) had unescaped ) closing the if block early,
causing BOTH Win11 and Win10 branches to run and wrong requirements used.
Fix: escape the parens with ^( and ^).
- %errorlevel% inside ( ) blocks is expanded at parse time, not after the
command that sets it. Affects winget install result check, tesseract winget
check, pytesseract check, and dependency verify loop.
Fix: use !errorlevel! (delayed expansion) in those four spots.
- Remove final pause so the CMD window closes automatically on success.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
winget (available on Win10 1709+ and all Win11) handles registration,
PATH, and package integrity checks automatically — cleaner than the
NSIS /S fallback. Fall back to curl+NSIS if winget is absent or fails.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Miniforge3's JustMe default install location changed from
%USERPROFILE%\miniforge3 to %LOCALAPPDATA%\miniforge3 in recent versions
(per robotology-superbuild docs and winget package metadata). Both the
initial scan and the post-bootstrap rescan now check %LOCALAPPDATA% first.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Two bugs found by studying oobabooga/text-generation-webui (reference impl):
1. PowerShell Invoke-WebRequest without $ProgressPreference='SilentlyContinue'
renders an ASCII progress bar that drops download speed from ~50 MB/s to
~1 MB/s. Switch primary download to curl (built into Win10/11, fast, clean
progress bar). PowerShell is kept as a fallback with the flag set.
2. Running the NSIS installer directly ("installer.exe /S") can return before
the install finishes. The correct pattern is "start /wait "" installer.exe"
which blocks until the installer process truly exits. Also adds /NoShortcuts
and /NoRegistry flags (standard for embedded/self-contained installs).
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
NSIS /D= does not support paths with spaces. %USERPROFILE% often contains
spaces (e.g. C:\Users\John Smith). Omitting /D= lets Miniforge install to
its own default location (%USERPROFILE%\miniforge3) which it handles safely.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Users no longer need to pre-install conda. If the first scan finds no
conda.exe, install.bat downloads Miniforge3-Windows-x86_64.exe via
PowerShell (available on all Win10/Win11), installs it silently to
%USERPROFILE%\miniforge3 with no PATH changes, then re-scans and
continues. On failure it prints a manual fallback URL. Users with an
existing conda install are unaffected.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
The old install.bat copied tesseract41.dll as tesseract.dll (wrong name — wheel
needs tesseract51.dll) and the bundled .pyd used MSVC C++ ABI that the MinGW conda
DLLs can't satisfy. Both tesserocr imports now work:
tesseract52.dll — MSVC tesseract 5.2.0 from conda-forge (compiled vs2019)
tesserocr.cp310-...pyd — conda-forge 2.5.2 build linked against tesseract52.dll
The MSVC leptonica-1.78.0.dll comes from the existing conda tesseract=4.x install.
ocr.py already registers Library/bin via os.add_dll_directory so both backends
(tesserocr fast path, pytesseract reliable fallback) load correctly at bot startup.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
count scenario was registered in the scenarios dict but missing from
the module docstring — running testbed.py with no args hid it.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Architecture: two backends, first one that works wins at runtime (ocr.py).
1. tesserocr — C extension via conda tesseract 4.x DLLs + bundled wheel (fast)
2. pytesseract — subprocess via winget tesseract.exe (always works if exe exists)
Install changes:
- tesserocr setup: best-effort, never fatal. Wheel install failure or DLL mismatch
just falls through to pytesseract. Removes the hard-exit on missing wheel.
- pytesseract setup: winget install with a manual download link fallback for Win10
users where winget is unavailable. pytesseract is now in requirements.txt so it
is always importable.
- OCR verification: tests BOTH backends after setup, reports which is active.
Warns clearly if neither works rather than silently leaving OCR broken.
Sets ALL_OK=0 so the installer ends with a visible failure state.
- Dependency loop: removes tesserocr from the import check — OCR is now verified
end-to-end by the backend test above, not by a bare import.
- Removes the broken pytesseract source-patch block (syntax error, never ran).
- TESS_PATH hoisted to :env_found so it is available for the OCR check.
requirements.txt: add pytesseract (pure Python, no native deps).
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Reverts 63d0a33 and e712076. Both commits got the OCR architecture backwards:
- Made winget "optional" but DEPENDENCIES.md shows pytesseract is the working
backend on the actual machine (tesserocr is DLL-broken there)
- Added a tesserocr smoke test that would always fail on this machine
- Added pytesseract to verification loop but it's not in requirements.txt
Starting fresh with a correct understanding of the OCR stack.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
tesserocr (conda DLLs + bundled wheel) is the primary OCR backend and works
identically on both platforms. pytesseract is an optional fallback that needs
a working tesseract.exe, which winget can install on Win11 but not reliably
on Win10.
Changes:
- Winget step: reframed as optional/best-effort; failure is informational only,
not a warning that "OCR will not work" (tesserocr handles all OCR)
- Remove pytesseract from the verification loop — it is not in requirements.txt
and would always fail on Win10 without winget; adding it in the previous commit
was wrong
- Remove pytesseract-specific smoke test block
- Add tesserocr end-to-end smoke test after the verification loop: opens a
PyTessBaseAPI against the real tessdata model so DLL resolution + tessdata
access are both exercised; sets ALL_OK=0 on failure so the installer reports
the right thing
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Three fixes:
1. After winget tesseract install, check C:\Program Files\Tesseract-OCR\tesseract.exe
actually exists and print a download link if not — previously silent failure.
2. Remove the broken pytesseract source-patch block (Python syntax error on the
os.path.join call — it has never run). ocr.py already handles cmd discovery at
import time via PYTESSERACT_TESSERACT_CMD (Bug 14 fix), so patching is moot.
Replace with a pytesseract.get_tesseract_version() smoke test that exercises the
full exe chain and fails clearly if tesseract is missing.
3. Add pytesseract to the dependency verification loop alongside tesserocr.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Standalone script: scans loot columns, tooltips every occupied slot to
identify chipped gems, then right-clicks each to convert in-place.
Uses the same screen/input_layer stack as the main bot (no D2R restart needed).
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
fg_market_scraper.py: broaden user.php href regex to allow a path prefix
before user.php (some topic pages use relative paths like ../user.php).
fg_scrape_pipeline.sh: --days 21 -> --days 60 for a fuller price history.
improve_fg_estimates.py: hoist min_valid_price constant out of the per-file
loop (was a constant being re-assigned every iteration).
fg_daily_estimates.json: refreshed from 853 topics over 60 days (was 21).
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- New 'count' scenario: read gem counts from GEMS tab via OCR, print plan
- gems_all: accept tier and gem-type filters + --max cap (e.g. 'gems_all flawless diamond')
- gems_all: fast-path ESC + stash-chest click before launching full TownManager
- gems_all: leave stash open after run so re-invocations skip navigation
- stash: stash chest auto-walk if stash not open
- Remove duplicate 'return 0' at end of _gems_all that made dead code
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
ocr.py: use -c tessedit_char_whitelist=0123456789 (tesseract 4+ style)
instead of the legacy blacklist+whitelist pair that was silently ignored.
a5.py: include left_inventory_ready() in the stash_is_open_func poll so
the open_stash success check fires even when GoldBtn templates miss at
current D2R rendering settings.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
New production flow for convert_all_gems_to_perfect:
- Count gem stacks via OCR badge on each GEMS-tab slot (Hermes as fallback)
- Open cube from PERSONAL stash per-transmute, return to GEMS tab while cube stays open
- ctrl+shift+right-click x3 loads gems directly into cube from GEMS tab
- ctrl+shift+left-click moves result from cube back to GEMS tab
- Pre-flight: clear any leftover items in cube before planning
- Stash reopen guard at every iteration in case ESC closed the stash
- _ensure_cube_available / _locate_cube / _ensure_cube_in_stash safety chain
- _empty_cube_to_gems_tab: blindly ctrl+shift+left-click all 12 slots to clear
Also adds tier_filter, gem_filter, max_transmutes params to convert_all_gems_to_perfect
and wires the new flow into run_transmutes when stash_tabs > 4.
Includes 8 pytest unit tests covering OCR plan, gem/tier filters,
cube-location logic and _open_available_cube_for_gems_tab edge cases.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Avoids blocking on town-marker detection failure when character is
in A5 but detect_current_act() can't find templates (e.g. stash was
just closed, unusual camera angle). Assumes A5_TOWN_START so open_stash
navigates to Harrogath stash without triggering Cain.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- convert_all_gems_to_perfect: revert to _open_cube_from_inventory() — cube lives
in character inventory, not stash PERSONAL tab
- After opening cube, switch to GEMS tab; ctrl+shift+click sends gems directly from
GEMS tab into the open cube without an inventory roundtrip
- testbed _gems_all: add left_inventory_ready() fallback so stash-open detection
works with new D2R UI (GoldBtnStash template doesn't match new UI)
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- _open_cube_from_stash(): switch to PERSONAL tab, right-click cube from stash
- _ctrl_shift_click_monitor(): ctrl+shift+click sends item from active stash tab
directly into the open cube (no inventory roundtrip)
- convert_all_gems_to_perfect: open cube once per gem type from stash, switch to
GEMS tab, ctrl+shift+click gem stack 3x -> into cube, transmute, ctrl+click
result -> back to GEMS tab; close cube when type is done
- Keeps _open_cube_from_inventory as fallback for other callers
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- _gems_tab_pull_3: click same stack position 3x (stacked slots = 1 match, not 3)
- convert_all_gems_to_perfect execute loop: same fix (was checking len(matches)<3)
- Replace inspect_inventory_area with search_all on right_inventory ROI in both
old _run_gem_transmutes_new_ui and new convert_all_gems_to_perfect - slot-by-slot
scan at 0.91 threshold missed gems that search_all finds at 0.80
- _plan_transmutes: handle both Hermes short keys (ruby_flawless) and template
fallback long keys (inventory_ruby_flawless) via dual-key lookup
- _count_gems_by_template: return 999 instead of slot count - stacked slots show
1 match per type regardless of how many gems are in the stack; execution loop
stops naturally when the stack empties
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
The new D2R stash has named tabs (PERSONAL/SHARED/GEMS/MATERIALS/RUNES)
where gems are stacked (one slot per type/tier) instead of individual
inventory grid positions. The old slot-by-slot inspect_area approach
found 0 matches because gem icons don't align to the 38x38 grid.
Fix: _gems_tab_pull_3() uses template_finder.search_all() to find gem
positions anywhere in the left panel, then ctrl+clicks the 3 best hits.
Cube must now be in the character's right inventory (not personal stash).
Verified: 101 flawless->perfect transmutes in one run. Batch cap 100->500.
Also adds _switch_to_gems_tab() and _open_cube_from_inventory() helpers.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
transmute.py: convert_all_gems_to_perfect() chains all four gem tiers
(chipped->flawed->standard->flawless->perfect) by calling the existing
_run_gem_transmutes_lod for each tier in order. Result gems from each
pass land back in stash and feed the next pass.
testbed.py: gems_all scenario triggers convert_all_gems_to_perfect with
stash open; also adds missing return 0 to _gems().
hermes.py: fix UnicodeEncodeError on cp1252 terminals when the LLM
response contains non-ASCII characters (arrows, bullets, etc.).
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
thinking mode caused the model to loop on uncertain items (e.g. weapon
slot identification). disable_thinking=False + repetition_penalty=1.15
gives clean, non-repetitive responses in ~5s.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
tools/hermes.py grabs the current D2R window (same grab() path the bot
uses), sends it to the local Qwen vision model at 192.168.1.98:8010,
and runs an interactive REPL. The system prompt gives Hermes full context
on coordinate systems, template matching, input layer, and bot patterns
so it can reason about what's on screen and suggest feature code.
<botty-env-python> tools/hermes.py [initial prompt]
<botty-env-python> tools/testbed.py hermes [initial prompt]
Also wired into testbed.py as the 'hermes' scenario.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- scripts/stash_inventory.py: fix BaseItem["dimensions"] being a list
not a dict — use dims[0] for height so the claimed-slot dedup works
and multi-slot items (Large/Grand Charms, 2h weapons) are no longer
double-counted
- scripts/make_stash_csv.py: new — convert stash_inventory.json to a
deduplicated stash_list.csv (name, page, stats) for trade reference
- stash_list.csv: current stash export (71 unique items)
- config/params.ini: add stash_scan_interval (default 0/off)
- src/config.py: parse stash_scan_interval from [general]
- src/bot.py: after stash+transmute, trigger scan every N runs when
stash_scan_interval > 0
- src/inventory/personal.py: reset transfer_failures per stash tab so
each tab gets its own 2-failure budget before moving to the next
- tools/testbed.py: PASS/FAIL fix for `stash all` — equipped-area cols
(≥4) correctly left in inventory is a PASS not a FAIL (Bug 19 guard)
- CLAUDE.md: document stash scanner scripts in File Quick Reference
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
stash_all_items() treated any keep item remaining after transfer_items()
as "this tab is full", paged through every stash tab, and on the last page
called stash_full() -> taskkill D2R + a false Discord "stash full" alert.
A transfer can fail for non-fullness reasons (equipped-area click guard,
transient UI), so this could kill the game on a stash that has free slots.
Fix: before declaring a tab full, check EmptyStashSlot. If a slot is free
but the transfer still failed, count it as a transfer failure (cap 2) and
bail, leaving items in inventory -- never advance tabs / call stash_full()
on a non-full page. Also fix a leftover >3 page bound (-> >5) in the same loop.
Add a `stash` scenario to tools/testbed.py (self-bootstraps: main menu ->
create game -> walk -> open stash -> run stashing; `all` widens the scan to
all 10 columns to exercise the keep-item branch) and extract the conda-env
SSL cert workaround into a shared _patch_ssl() helper. Document as Bug 19.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
- pickit: run-scoped loot accumulator (item names of every successful
pickup), consumed once per run.
- bot._run_wrapper: at run end (success, battle-fail, and approach-fail
paths) logs "Loot from run_diablo: JAH RUNE, 2x FLAWLESS SAPPHIRE" (or
"nothing picked up") at INFO level.
- game_stats: run_finished events now carry a counted loot dict, so
events_*.jsonl can be mined for per-boss drop value over time.
Co-Authored-By: Claude Fable 5 <[email protected]>
Session evidence (2026-06-11/12): 2 merc deaths vs Hell Diablo with exactly
1 heal fired and 0 rejuvs. The pipeline works (read 30% -> fed a potion)
but the tuning loses: Hell bosses chunk a merc 30-50% per hit, so the
25% rejuv band was skipped straight past between polls, leaving only the
slow 10.24s-cooldown health pot branch. Rejuv (instant, 4s cooldown) now
covers the real danger band under 45%; pots top up from 70%.
Co-Authored-By: Claude Fable 5 <[email protected]>
The most valuable drop must never be lost to pickup order or a chicken:
- pickit: ground items now sort high-value-first (HIGH_VALUE_KEYWORDS:
Ist+ runes, Tyrael's, DWeb, Griffon's, SoJ, facets, HC, etc.), nearest
of them first, junk after - if looting gets interrupted, the Jah is
already in the bag.
- dive mode (health_manager.set_loot_priority): while a high-value drop
is being picked up, the chicken threshold is halved (hard floor 20%
HP), the two-juv panic and merc chicken are suppressed, and potions
keep flowing - the bot spends its belt to secure the item instead of
save+exiting away from it. Auto-expires after 15s so a stuck flag can
never disable safety permanently; engaged/cleared around each
high-value pickup attempt.
Unit-tested (Jah-first ordering incl. over nearer gold/potions, flag
expiry) and validated in a live profile-enabled Pindle run.
Co-Authored-By: Claude Fable 5 <[email protected]>
Per-user character profiles (gitignored, survive git pulls):
- config/profiles/<name>/profile.ini overrides any params.ini key
(runs, difficulty, char type, keybinds...). Priority:
custom.ini > profile.ini > params.ini. Injected into _select_val and
all 22 build-section merges.
- Active profile selected via config/active_profile.txt (gitignored);
Config.get/set_active_profile + list_profiles/list_pickit_profiles.
Shared pickit profiles (git-tracked team content):
- config/pickit_profiles/<set>/*.bnip - one folder per season phase,
built once, shared via git. Selected per user with
[general] pickit_profile=<set> in their profile.
- Pickit dir priority: config/profiles/<me>/pickit/ (personal)
> config/pickit_profiles/<set>/ > config/bnip/ > default.bnip.
Menu integration (main.py):
- startup banner shows active profile + pickit set + available lists
- "end" hotkey cycles character profiles (applies on restart)
Verified: fistman profile active end-to-end (general/char/build-section
overrides + shared pickit set resolution + 474 expressions loaded).
Co-Authored-By: Claude Fable 5 <[email protected]>
Mirrors the D2R settings check: on the first on_maintenance of a bot
session, validate_build_skill_icons presses each configured skill hotkey
and verifies the icon on the right slot, warning loudly (with a pointer to
tools/set_binds_from_params.py) when a bind does not match params.ini.
Non-blocking; ~5s once per session.
Co-Authored-By: Claude Fable 5 <[email protected]>
Live finding (fresh game, 2026-06-12): D2R skill hotkeys select onto the
RIGHT skill slot, not the left as the old comments assumed. The left slot
permanently holds Blessed Hammer. Consequence: _cast_hammers pressing the
hammer hotkey after activating an aura was REPLACING the aura on the right
slot every cast cycle - the true root cause of fights running without
Concentration. _cast_hammers no longer touches the hammer hotkey: select
aura (lands on right, stays active), hold stand-still, spam left-click.
Verified live: full Diablo kill at 09:04, ~55s from last seal to kill.
Auto skill binding proven end-to-end (tools/set_binds_from_params.py):
blessed_hammer/concentration/redemption/vigor/holy_shield/teleport all
bound via the in-game picker and visually verified (6/7 OK; conviction
correctly reported missing - not skilled on this char).
- capture tool + preflight verify now watch the RIGHT slot
- fresh skill slot templates + clean PICKER_* cell templates captured at
current settings (blessed_hammer, concentration, redemption, vigor,
holy_shield, teleport)
- removed bogus conviction.png (had captured vigor's icon)
Co-Authored-By: Claude Fable 5 <[email protected]>
transfer_items now filters protected items out of BOTH the sell and drop
paths (previously only shields, only on sell): anything whose name contains
"charm" is blocked by default (charms live in the inventory permanently -
a pickit misread must not vendor them), shields stay protected, and
never_sell_keywords in [char] params can extend the list. Blocked items
stay untouched in the inventory.
Motivated by a real grand-charm-sold-by-mistake incident.
Co-Authored-By: Claude Fable 5 <[email protected]>
Layer 1 (Controls page / .keyo): tools/set_controls_keyo.py parses the
character keyo (same binary format as utils/key_detector) and verifies every
skill hotkey in params.ini is bound to a skill slot; --fix writes missing keys
into free skill slots with a backup (D2R must be closed). Picks the configured
character file. Verified: fistman controls match params.
Layer 2 (skill assignment / picker): enabled the existing skill_hotkey_setter
machinery for the hammerdin build:
- skill_preflight: hammerdin build rules (blessed_hammer left/required,
concentration right/required, redemption/vigor/conviction/holy_shield/
teleport right/optional) + PALADIN_TEMPLATE_ALIASES, merged TEMPLATE_ALIASES.
- skill_hotkey_setter: paladin picker template aliases with slot-icon fallback.
- tools/set_binds_from_params.py: end-to-end runner - opens the in-game picker,
binds each skill to its params hotkey, verifies via slot icon, reports
match/mismatch per skill.
- tools/capture_skill_hotkeys.py now saves crops straight into
assets/templates/ui/skills/ so captures immediately become live templates
for both the preflight and the picker search.
Co-Authored-By: Claude Fable 5 <[email protected]>
- hammerdin.py _cast_hammers: skip aura/hammer re-selection when the slot
already holds the wanted skill (tracked via _active_skill, invalidated by
pre_move/_weapon_switch/cast_buffs and all raw redemption/vigor hotkey
sends). Visible effect: aura stays on right-click and hammers fly from
plain left-click spam instead of constant F1/F3/F8 churn.
- 35 mid-clear attack casts switched from Redemption to Concentration
(damage aura while fighting); Redemption still pulses between packs and
after kills for corpse cleanup.
- DEPENDENCIES.md: full verified working-state snapshot (env paths, package
versions, tesseract wiring, D2R settings, DPI specifics, safety nets).
- tools/capture_skill_hotkeys.py: presses each params.ini skill hotkey
in-game and saves labeled left-slot icon crops - verifies binds match the
character and builds skill-icon templates for future aura checks.
- CLAUDE.md: remove stale main.exe mention (no exe build exists; bat runs
current source directly).
Co-Authored-By: Claude Fable 5 <[email protected]>
Session fixes (2026-06-11), all live-tested over multiple farm games:
- input_layer/hotkey.py: no-arg keyboard.wait() returned on ANY keypress,
silently killing the process right after F11 (all bot threads are
daemons). Now blocks forever like the original keyboard lib. Poll loop
is edge-triggered (no ~20ms refire while a hotkey is held) and callback
exceptions print instead of being swallowed. (Bug 13)
- d2r_image/ocr.py: pytesseract never read PYTESSERACT_TESSERACT_CMD;
tesseract_cmd is now wired from env var / PATH / winget default, fixing
exit 0xC0000135 on every OCR call. (Bug 14)
- bnip/utils.py + config/default.bnip: undefined NipSyntaxError ->
BNipSyntaxError, and Shaefershammer -> Schaefershammer typo; 474 pickit
expressions load (was 473 + parser error). (Bug 15)
- town_manager/a5/bot: A5 WP death-loop containment - per-game WP failure
budget (2 strikes), quick=True direct-path-only retries, sweep trimmed
10->6 steps with 4s select timeouts, A5 select thresholds lowered
(WP 0.62, stash 0.60/0.45; safe - every select is success_func-gated).
Worst case dropped from 10+ min wandering to ~4.5 min contained fail
with fresh-game recovery. (Bug 16)
- bot.py: vendor trip gating - the failure-prone A5->A4 Jamella round
trip now only runs when consumables are needed or 3+ sell items pend.
- town_manager.py: Cain identify skips acts whose Cain timed out this
session (straight to working A5 fallback).
- char/i_char.py: Battle Command buff check waits 0.6-0.8s after the
hotkey (icon fade-in) to reduce double weapon-swaps.
- char/paladin/hammerdin.py: kill_diablo fights with Concentration
instead of Conviction (useless for magic-damage hammers) and drops
mid-fight Redemption downtime - faster kills, merc survives. (Bug 17)
- inventory: stash supports all 6 pages (personal + 5 shared, D2R 2.7+);
gold deposits navigate via OCR-verified select_stash_page instead of
raw 4-tab clicks; rotation %6, shared-first starts at page 5. (Bug 18)
Docs: CLAUDE.md Bugs 13-18; .hermes/plans/dia_run_test_state.md has the
full test log (two complete Diablo kills verified end-to-end today).
Co-Authored-By: Claude Fable 5 <[email protected]>
OpenSSL 3.x + corrupted Windows cert store causes aiohttp to crash
at import time. Monkey-patch ssl.SSLContext.load_default_certs to
fall back to certifi when Windows store fails.
traverse_nodes previously gave up after one random-guess move when a node
template wasn't on screen, failing every travel path where the char was
mis-positioned (the A5-after-Pindle stranding was the worst case). Now,
on timeout during travel pathing (timeout > 3.1s, so boss approaches keep
their fast fail), it walks an expanding 12-step sweep, re-scanning for the
node each step, and only fails once the sweep is exhausted. Makes ALL
node pathing self-healing, not just A5.
Co-Authored-By: Claude Fable 5 <[email protected]>
The A5 waypoint was unopenable after a Pindle return: the char lands in
the NE near Anya, where the town_start->WP node templates are off-screen,
so the pather random-guessed and timed out. Pathing to NPC spots
(Qual-Kehk/Malah) DOES work from there, and each has a defined path to
the WP. New open_wp: direct -> re-anchor via Qual-Kehk/town_start/Malah
-> directed sweep that re-attempts node pathing + full-screen WP scan
each step. This is the linchpin: it unblocks the A5->A4 vendor redirect,
maintenance, stash, and the Diablo run's travel out of A5.
Co-Authored-By: Claude Fable 5 <[email protected]>
CRITICAL loot-loss bug: buy_consumables failure called end_game and
returned BEFORE the stash step, so Pindle runes/items piled in inventory
and were never banked (zero stash events across a whole session). Buying
pots is optional (belt refills from drops); stashing loot is the point.
Now warn + re-anchor + fall through to stash. Also raise
max_maintenance_time_s 120->240 so the A5 vendor thrash completes and
reaches stash instead of timing out first (transitional — disappears once
a Diablo run shifts spawns to A4).
Co-Authored-By: Claude Fable 5 <[email protected]>
A5-spawn games burn on the Malah vendor step (stale patch templates);
A4-spawn games sail through Jamella. The Diablo run ends with a TP to
A4 town, so running it LAST makes every next game spawn in A4.
Co-Authored-By: Claude Fable 5 <[email protected]>
The blind ESC after a failed sweep click opens the ESC game menu when no
dialogue was actually open (seen covering the screen during the Tyrael
hunt). Detect SaveAndExit after the ESC and close the menu again.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diablo died in 22s but picked_up_items was false: his death animation /
lingering effects register as visible targets, so the mobs-alive guard
skipped his drops. pick_up_items(force=True) at the Diablo loot site.
Co-Authored-By: Claude Fable 5 <[email protected]>
The sweep for Tyrael clicked Deckard Cain (stands in the same zone) and
left his TALK dialogue open, blinding the rest of the sweep. Also sweep
the full search area as a second pass since NPCs can stand outside the
stored ROI, with a total time budget.
Co-Authored-By: Claude Fable 5 <[email protected]>
- OCR reads FISTMAN as "fabiman" — SequenceMatcher >= 0.6 on the first
word of the row handles the mangling
- start_bot_detached.bat kills existing main.py instances first: F11/F12
are global hotkeys, so duplicate bots receive every press and fight
each other (one starts while the other pauses/exits)
Co-Authored-By: Claude Fable 5 <[email protected]>
Body templates went stale with the June 2026 patch (Malah, Cain,
Tyrael, Larzuk, Qual-Kehk all undetectable), but name tags on hover
score 0.9+ reliably. When the body-template hunt times out, sweep a
coarse hover grid over the NPC ROI and click wherever the name tag
appears.
Co-Authored-By: Claude Fable 5 <[email protected]>
User-tuned detection thresholds and a safety guard against teleporting
into live packs during loot phases.
Co-Authored-By: Claude Fable 5 <[email protected]>
A misclick on the stash chest (Cain stands beside it) opens the stash
UI which covers most of the screen and blinds every template search:
NPC hunts, A5_WP selection, act detection. The old guard pressed the
inventory key, which leaves a stash panel open — ESC closes both.
Panels are now closed at hunt start, per hover-loop iteration, and
before WP traversal.
Co-Authored-By: Claude Fable 5 <[email protected]>
Malah wanders and her body templates predate the patch; the hunt fails
most games and the failed-hunt position strands the char. Jamella is
static and detects at 0.99+. Travel cost ~30s vs a lost game.
Co-Authored-By: Claude Fable 5 <[email protected]>
A full CS clear takes ~10 min; the 600s watchdog force-quit a game that
had all three seals open and was waiting for Diablo to spawn.
Co-Authored-By: Claude Fable 5 <[email protected]>
After a failed NPC hunt the char can be in a corner where no path nodes
anchor; both node traverses fail and the game dies. Walk a search
pattern across the small town scanning the whole screen for the WP
template directly each step.
Co-Authored-By: Claude Fable 5 <[email protected]>
- Seal 36 has two visual variants; dia_a2y4_36b_closed.png existed in
assets but was never searched, so the B variant could never be clicked
(8 sealdance tries then aborted the run)
- info_failed_seal_ screenshot name contained ": " (illegal on Windows)
producing 0-byte files
Co-Authored-By: Claude Fable 5 <[email protected]>
After Vizier/De Seis are dead, a failed recalibration traverse aborted
the whole Diablo run (threw away two boss kills at 13:43). The seal
stage re-anchors at the pentagram via template loop immediately after,
so just skip the extra loot pass and continue.
Co-Authored-By: Claude Fable 5 <[email protected]>
- town_manager.identify: return the reached A5 location when Cain fails
after successful travel (returning False made bot.py fall back to the
pre-travel act and run wrong-act pathing all maintenance long)
- town_manager.resurrect: leave last_known_loc breadcrumb when the A4
revive fails after travel; bot.py consumes it to re-anchor
- bot.py: alternate vendor retry prefers A4 Jamella (static, reliable)
over A1; Malah wanders and is the main A5 vendor failure
Co-Authored-By: Claude Fable 5 <[email protected]>
- diablo.py _layoutcheck: ambiguous/lost layout check now loops back to
the pentagram and retries the approach once before aborting the run;
template checks bumped from single-frame 0.1s to 0.5s
- hammerdin.py _cast_hammers: an unbound aura (e.g. conviction in
kill_diablo on a non-Infinity build) silently cast NOTHING, so Diablo
was never attacked with the damage aura. Fall back to concentration
and always cast hammers.
Co-Authored-By: Claude Fable 5 <[email protected]>
First encounter at char select blindly saved whichever character was
highlighted, locking in a wrong character for the whole session. Now,
when char_name is configured, select it via OCR first and warn if the
saved template name does not match.
Co-Authored-By: Claude Fable 5 <[email protected]>
Root causes from the 2026-06-09 session (51 games, 45 failed):
- Act desync: add TownManager.detect_current_act() and
Bot._verify_town_location(); every maintenance/end-run retry and
fallback now verifies the physical act instead of hardcoding town
starts. open_wp/go_to_act self-heal act mismatches. Never run A1
pathing when travel to A1 failed.
- NPC dialogue: poll action buttons up to 2.5s after click instead of
a single-frame check (premature retry click was closing the dialog).
- Stats integrity: log_end_game skips duplicate calls (phantom 0s
"successful" games were resetting the consecutive-fail breaker);
clear stale failure reason at game start; set chicken flag before
bot.stop() so chickens are no longer labeled "Bot stopped".
- Repair: prefer in-act Larzuk over cross-act Halbu trip (Halbu
detection failed 100% last session and desynced the act state).
Documented as Bugs 9-12 in CLAUDE.md.
Co-Authored-By: Claude Fable 5 <[email protected]>
NPC body templates find Akara/Malah correctly but the name tag templates
score ~0.28, below the 0.35 threshold, causing 20s spinning when the NPC
is detected but the hover confirmation never passes.
Three targeted changes in open_npc_menu():
- Use a 240x140px ROI directly above the hover cursor for name tag search
instead of the full screen, containing false-positive risk
- Lower name tag threshold from 0.35 to 0.26 so Akara at ~0.28 now passes
- Check ScreenObjects.NPCDialogue after click as primary confirmation;
this is UI-state based and immune to stale template images
- Reduce per-NPC search timeout from 20s to 8s for faster failure recovery
Also lower max_maintenance_time_s from 120 to 60 to cap total stuck time.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Reliability
- All run approach() methods now set approach_fail_step before every return False;
bot.py _run_wrapper reads it and includes [step: X] in Discord/log failure reasons
- on_maintenance() sets _maintenance_step before each town step (heal, identify,
buy_consumables, stash_items, repair, resurrect_merc, gamble) for the same coverage
- on_init() logs startup line: char=X | difficulty=Y | routes=[...] each game
Per-run disable (bot runs as long as possible)
- GameStats tracks per-run failure counts across Bot instances (previously reset each game)
- After disable_run_after_failures consecutive failures a run is disabled for the session;
game-level consecutive-fail counter resets so the bot continues on remaining routes
- game_controller no longer quits on max_consecutive_fails if active routes remain;
only exits when all routes are disabled
Maintenance timeout (params.ini: max_maintenance_time_s=120)
- Hard 120 s wall on the entire town maintenance loop; checked between each major step
and before every retry (buy_consumables, stash, repair, resurrect_merc, gamble)
- On timeout: error screenshot + Discord, then trigger end_game → save-and-exit → rejoin
Bug fixes
- Win11 mouse overshoot: mouse_move() uses SetCursorPos + zero-delta MOUSEEVENTF_MOVE
- _curr_loc = True propagation: TownManager.identify() now returns the act Location enum
- DAMAGED KeyError in pickit: added ItemQualityKeyword.Damaged.value to NTIP_ALIAS_QUALITY_MAP
- A4 WP interaction range: force-move character to WP stone before select_by_template
- NPC click blocked by equipped-area guard: open_npc_menu() closes inventory if open
Docs
- Added CLAUDE.md: AI working guide with step tables, bug history, debugging tips
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
open_npc_menu timeout reduced 35s → 20s and now saves a screenshot when
it gives up. buy_consumables and stash failures after their retry now
trigger end_game (save & exit) instead of silently continuing with a bad
location, preventing the bot from running the next game in a broken state.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
When restart_d2r_when_stuck is enabled, spawn a fresh Python process
(same main.py) and exit immediately rather than killing and relaunching
D2R. The new bot detects D2R is already running and skips launching it,
preserving the game session. D2R is only killed on deliberate exits
(safe_exit) and the initial auto_login launch.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
After using Cain to identify items, self._curr_loc was set to True (boolean)
instead of the actual location. Every subsequent get_act_from_location(True)
returned None, causing buy_consumables and stash to bail immediately and
fall back to A1 even when the character was physically in A5.
Now returns curr_loc when identification succeeds in the current act, or
new_loc (the A5 WP location) when the A5 Cain fallback is used — so the
bot correctly routes to Malah and the A5 stash instead of navigating back
to Akara.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Adds Messenger.send_error across the Discord (red embed with screenshot attached)
and generic (text-only) APIs. Bot._save_error_screenshot now also pushes the
failure to the configured messenger after saving the screenshot to disk, so each
approach/battle/exception failure is reviewable in Discord with the visual.
Gated by new config discord_log_errors ([general], default 1) and the
[discord_events] error toggle. Both optional/backward-compatible. Verified wiring
and the suppression path via a stubbed messenger.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
When every run has been auto-disabled there are no routes left to run, so save a
session report and shut down via safe_exit() instead of calling restart_or_exit
(which would needlessly restart D2R into empty games when restart_d2r_when_stuck
is enabled).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Recovery: each run now has its own consecutive-failure counter. After
disable_run_after_failures (default 5) consecutive failures, that single run is
disabled for the rest of the session and the bot keeps doing the other runs
instead of stopping. A success resets the counter. If every run is disabled the
bot stops for investigation. Disable is in-memory only (restart re-enables).
Diagnostics: on every run failure (approach, battle, or exception) the bot saves
a timestamped screenshot to log/screenshots/error/ named with the run, reason,
and game/run counters so logs and visuals can be cross-referenced. Gated by the
new error_screenshots config (falls back to info_screenshots). The error/ dir is
routed through log rotation.
Adds config keys error_screenshots and disable_run_after_failures (both optional,
backward compatible) and docs/recovery_and_error_logging.md. Verified bot startup,
config parsing, Bot construction, the disable/reset logic, and screenshot writing.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Every silent return False in a1-a5 town act files (resurrect, heal, identify,
open_wp, open_trade_menu, open_stash, open_trade_and_repair_menu, gamble) now
emits Logger.error with the specific step that failed (traverse, NPC menu open,
button press, panel visibility). Also adds Logger.error to all run approach()
methods (arcane, shenk_eld, trav, andariel, countess, mephisto, baal, pindle,
nihlathak) for open_wp, use_wp, go_to_act, and traverse failures. Fixes a4.py
open_trade_menu to use LeftPanel check instead of unreliable GoldBtnVendor.
town_manager fallback go_to_act calls (resurrect, identify, open_stash, gamble,
stash, heal) now log when they fail.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Problems:
1. use_wp() fired immediately after open_wp() — WP panel animation
still playing, WaypointTabs template not yet visible → silent fail
2. vizier.py + diablo.py ignored use_wp() return value → approach()
returned a truthy Location even when the WP was never used, causing
battle() to run from the wrong map location
3. go_to_act() also ignored use_wp() return value
Fixes:
- waypoint.use_wp(): retry WaypointTabs detection up to 4x (1.6s total)
before giving up; log each retry + the target WP and act being switched
- vizier/diablo approach(): check use_wp() return, return False on failure
- town_manager.go_to_act(): check use_wp() return, log + return False
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
GoldBtnVendor (small gold coin template in a tight ROI) was failing even
when the vendor window was fully open. Replace with wait_until_visible(
LeftPanel, timeout=3.0) which checks for the panel close-X in the header
— the same reliable check used by wait_for_left_inventory/left_inventory_ready.
Fixes: A5 (Malah) and A1 (Akara) buy_consumables failing every run.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Add auto-generated price files to .gitignore and remove them from
tracking. These are updated at runtime by the price tracker and
should not be versioned.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- screen.py: default monitor_roi width 1280 -> 1282 (actual window size)
- diablo.py: re-detect window position before TP in CS run (2 places)
- npc_manager.py: adjust Akara ROI/poses for 1282px; lower NPC name tag
threshold 0.5->0.35 for more reliable hover detection; add NPCDialogue
visibility wait + per-threshold debug logging in press_npc_btn
- a1.py: add wait(0.5, 0.8) after trade button before GoldBtnVendor check;
guard open_trade_and_repair_menu on NPC menu result; add Logger.info
throughout open_trade_menu, heal, open_trade_and_repair_menu
- a5.py: same wait + logging pattern for Malah open_trade_menu
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Adds _log_platform_info() called from startup_checks(). On each bot
start the log now shows:
- Windows 10/11 + build number (Win11 = build >= 22000)
- Mouse input mode: relative (Win11) or absolute (Win10)
- OCR backend: tesserocr (primary) or pytesseract fallback or ERROR
Win11 (build 26200) confirmed working: mouse in relative mode,
tesserocr 5.2.0 with hover/ground botty models loading correctly.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Resolved 7 conflicts by keeping both local (Logger.info diagnostics in
buy_consumables and repair) and remote (set_panel_check_paused health-check
suppression during vendor/repair panels). All paths now log + pause correctly.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Implementation of centralized cooperative shutdown mechanism (C10) to replace dangerous thread killing.
- Consolidation of all timing signatures through jittered wait() for anti-cheat stealth (C1).
- Enhanced HealthManager reactivity with smart mana potion fallback and premature panel-closing protection.
- Refactored waypoint stealth logic to simulate human-like mis-aim instead of area-breaking misclicks.
- Implementation of stateless 'garbage item' filtering in PickIt to eliminate ghost item loops and OCR artifacts.
- Optimization of inventory management to automatically stash protected items that cannot be sold.
- Suppression of individual game failure notifications on Discord; alerts now trigger on 5+ consecutive fails.
- Implementation of centralized cooperative shutdown mechanism (C10) to replace dangerous thread killing.
- Consolidation of all timing signatures through jittered wait() for anti-cheat stealth (C1).
- Enhanced HealthManager reactivity with smart mana potion fallback and premature panel-closing protection.
- Refactored waypoint stealth logic to simulate human-like mis-aim instead of area-breaking misclicks.
- Implementation of stateless 'garbage item' filtering in PickIt to eliminate ghost item loops and OCR artifacts.
- Optimization of inventory management to automatically stash protected items that cannot be sold.
- Suppression of individual game failure notifications on Discord; alerts now trigger on 5+ consecutive fails.
Alex's value (2000) preserved over FiskenPoul's (200) for transmute cadence.
stash_destination=0,1,2,3 accepted from remote (matches fill_shared_stash_first=0).
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
The FiskenPoul merge (95f520e) resolved conflicts by dropping three comment
blocks that documented Hammerdin-specific tuning guidance:
- Hammerdin difficulty guide near the 'difficulty' setting (Normal/NM/Hell
gear targets, chicken-rate advice)
- Hammerdin attack-length reference table near atk_len_* values (per-boss
HP ranges, Conviction interaction, CS seal boss breakdown)
- auto_downgrade_threshold parameter and its description (the feature is
parsed by config.py but not yet active; restored with a note)
No logic changes — comments and one dormant config key only.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- win_input.py: detect OS build via platform.win32_ver(), use
MOUSEEVENTF_ABSOLUTE only on Win10 (build < 22000)
- install.bat: detect and display Windows version on install
personal.open_inventory() returns None when the inventory cannot be opened
(e.g. after a failed vendor interaction leaves the UI in a bad state). The
previous code passed that None straight into common.get_slot_pos_and_img()
which tried to index it, crashing the bot thread with:
TypeError: 'NoneType' object is not subscriptable
Also wraps the shift-click loop in try/finally so keyboard.release("shift")
is guaranteed even if an exception fires mid-loop (prevents stuck Shift key).
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- i_char.py: rewrite _pre_buff_cta() to verify each weapon switch via BC
skill-bar template; detects wrong slot on game start (leftover from
interrupted buff cycle), retries failed switches once, logs clearly
when stuck on CTA slot to prevent dying with wrong weapon in combat
- personal.py: protect needed consumables from sell/drop in inspect_items;
check get_needs() before marking a pot for discard — if the belt needs
that pot type, skip it so fill_up_belt_from_inventory can restock later
- bot.py: add fill_up_belt_from_inventory + update_pot_needs after
buy_consumables so inventory pots reach the belt even when out of gold;
add town-heal loop at start of on_maintenance to drink health/rejuv pots
until HP >= 95% before the next run (health manager is paused in town)
- game_stats.py: add rolling 10-game pickup health check; warns in log and
sends Discord alert when zero item pickups occur across 10 games while
chickens or merc deaths are present
- params.ini: fix show_belt=n -> show_belt=k (belt key was wrong, causing
1.5s wasted recovery attempt every first game in a session)
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Updated bot behavior to end the game when no TP charges are left, instead of attempting to walk back to town.
- Refined item processing logic in `processing_helpers.py` to use identity checks for item comparisons and improved item removal methods.
- Enhanced game stats logging to include failure reasons and send Discord notifications for failed games.
- Improved health management logic to trigger chicken behavior based on health percentage after rapid rejuvination.
- Added error handling for item pickup failures in `pickit.py` to blacklist items that cannot be picked up.
- Introduced walking fallback nodes for Countess, Andariel, Mephisto, and Baal runs in `pather.py`.
- Implemented path data validation in run scripts for Countess, Andariel, Mephisto, and Baal to ensure paths are correctly defined.
- Enhanced item identification and stash processes in `diablo.py` with retry logic for failures.
- Updated town management to handle identification failures gracefully and log appropriate warnings.
- Improved character selection logic to provide clearer logging messages.
- Modified game restart logic to launch the game via PowerShell for better session handling.
- Added documentation for recording paths for the Countess run and created a PowerShell script for scheduled task management.
- BC and BO cast with left-click (targets self) instead of right-click
- Swap-back loop simplified to 2 attempts with match score logging
- Added full test route order: trav, pindle, eldritch_shenk, nihlathak, arcane, diablo, vizier
Removed the visual skill check (is_right_skill_selected BC/BO) that always
failed because the BC.png/BO.png templates do not match D2R icons. New flow:
switch weapon, cast BC, cast BO, buff, switch back. Only verifies swap-back
by matching the original skill icon via template matching.
- game_controller: set reason on max_game_length, chicken, force_stop
- bot.py on_end_game: set default reason when failed=True has no reason yet
- bot.py save_and_exit failure: set reason before stop()
- bot.py restart_or_exit: capture error message as failure reason
- All retry paths (stash, buy_consumables, heal, repair) now use known-good
location constants instead of corrupted self._curr_loc (False)
- Guard gamble->stash chain against False location
- Guard wait_for_tp against False location
- Add run_trav to route order (travincal then pindle)
- Fix merc icon detection: expanded ROI from 40x40 to 60x60 (matchTemplate requires search area > template)
- Add MercPanelText screen object using mercenary_screen.png for reliable merc alive check
- Bot merc check now uses MercPanelText instead of generic LeftPanel
- Diablo: wait for boss to spawn before attacking (up to 15s), use conviction aura
- Diablo/Pindle attack lengths increased (diablo 3->10s, pindle 3->8s)
- Aura key switch delay increased from 50-100ms to 200-300ms for reliable activation
- Add conviction hotkey (F5) to hammerdin config
- Move Pindle CTA pre-buff from town to after entering red portal
- More aggressive healing thresholds (health potion at 98%, rejuv at 85%)
- Max game length increased from 160s to 600s for full Diablo runs
- Crop A5_RED_PORTAL template to remove empty space (center now aligns with portal)
- Fix merc_icon ROI from 10,9,50,50 to 15,14,40,40 (matches 40x40 merc templates)
- Lower MercIcon threshold from 0.9 to 0.85, add MERC_A2_2 variant
- Add merc panel confirmation with 'O' key before resurrect (prevents false resurrect when icon detection fails)
- Override capabilities to can_teleport_natively
- src/inventory/vendor.py: Log screenshots when NotEnoughGold detected
during gambling, purchasing, and repairing
- src/ui_manager.py: Update NotEnoughGold to use new MISSING_GOLD template
from assets/npc/missing_gold/ (full dialog match instead of color-based)
- assets/npc/missing_gold/missing_gold.png: New template for the popup
- src/utils/misc.py: move_d2r_window now calculates outer window position
to place client area at target coords (was incorrectly setting outer
window to target, shifting client area unpredictably)
- src/bot.py: Call move_d2r_window(5,98) at game start to restore original
working client area position and prevent 13px offset drift
- src/screen.py: Add force param to find_and_set_window_position/set_window_position
to bypass early-return guard and skip wait when called programmatically
- src/run/pindle.py: Force window re-detection before each traverse_nodes and
select_by_template call to handle offset drift between runs
- src/pather.py: Add fallback full-image search with lower threshold (0.55) when
cropped ROI search fails in find_abs_node_pos
- src/bot.py: Move D2R window to stable position (0,0) at game start via
move_d2r_window; skip merc resurrect after first failure (no gold)
- src/game_stats.py: Add _merc_resurrect_failed flag (reset each game)
- src/utils/misc.py: Add move_d2r_window() function
- config/params.ini: Updated teleport=b, show_belt=k, override_capabilities,
restore_settings_from_backup_key=insert, graphic_debugger_key=delete
- health_manager.py: read actual merc health for heal/chicken/rejuv decisions
instead of timer-only logic; pass merc_health to belt.drink_potion stats
- processing.py: remove stale TODO comment (traceback import is used)
- Restore test/assets/ from commit 5127417^ (was moved to bottytools/botty-test-assets which is now 404)
- Remove 'Download test assets' step from CI workflow (assets are now in-repo)
- Make download_test_assets.py a no-op for backward compatibility
- Fix import: from pipes import Template -> from template_finder import Template
- Remove test/assets/ from .gitignore
- Add utils/log_rotation.py: safe_imwrite() auto-rotates screenshot dirs
when they exceed configured file count or size limits
- Add [log_rotation] config section in params.ini (pickit/info/items
max_files, max_mb, discord_notify_rotation)
- Replace 40 cv2.imwrite() calls with safe_imwrite() across 13 files
- Add failure reason tracking: bot.py catches run exceptions, stores
reason in game_stats, Discord message includes the error
- Add Discord notification when log rotation deletes old files
- Prevents disk-full crashes that stopped botty on May 30
- config/params.ini: show_belt k->n matches the actual D2R keybind so
the bot can now correctly read and manage belt potion inventory.
pickit_screenshots=1 enables per-run loot scan evidence.
- blizz_sorc.py: kill_pindle() now waits 1.5-2.0s (was ~0.33s) after
the attack loop before teleporting to the loot area. Blizzard has an
~1.8s fall duration, so the old wait left enemies alive when the sorc
arrived. Also adds _cast_static() at the start to reduce pack HP
immediately.
- pickit.py: rename loop var i->ground_item (fixes potential NameError
when items list is empty) and improve tele-fail warning message.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
The TELE_ACTIVE template only matches native teleport's icon. Charged
teleport (from a staff/item) shows a different icon with a blue charge
indicator, so is_right_skill_selected(["TELE_ACTIVE"]) returns False
and char.move() falls back to walking.
Walking a path calibrated for teleport drops the sorc in the wrong
game-world position, so blizzard casts land south of Pindle ("below
targets") and the sorc is exposed to the full minion pack.
Fix: for can_teleport_with_charges chars, call select_tp() once then
pass force_tp=True to each char.move() hop. force_tp bypasses the
template check and re-arms the teleport hotkey before each right-click,
so charges are actually consumed per hop. If charges deplete mid-path
char.move() right-clicks with no effect and the remaining hops degrade
to standing still rather than walking into monsters.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
pindle.py: route can_teleport_with_charges chars through
traverse_nodes_fixed for the Pindle approach instead of traverse_nodes.
traverse_nodes (node-based) falls back to walking for charges chars
because pre_move does nothing for them — so the sorc was slow-walking
through the entire minion pack before the fight, arriving at near-zero HP.
pather.py: traverse_nodes_fixed now calls char.select_tp() before
pre_move when using charge-based teleport, so char.move() sees
teleport on the right-skill slot and uses charges for each step.
When charges deplete mid-path it falls back to walking gracefully.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- misc.py: catch psutil.NoSuchProcess in WindowSpec.match() — a process
can exit between EnumWindows enumerating it and us calling .name(),
which was crashing the window-detection thread and leaving
monitor_x_range/monitor_y_range as None
- screen.py: guard convert_screen_to_monitor against None ranges so
template matching doesn't throw TypeError before the D2R window is
first located; logs a warning and returns unclamped coords instead
- main.py: switch BeautifulTable to STYLE_DEFAULT (ASCII only) to avoid
UnicodeEncodeError on cp1252 terminals; add try/except fallback
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
The run-selector window now also shows a character section with radio
buttons for the three supported builds. Selecting a char updates
Config().char["type"] in-memory alongside the run selection; both take
effect on the next game.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Auto-launch of D2R and bnet credential injection are now gated behind
an explicit `auto_login=0` flag in params.ini. Setting it to 1 restores
the previous behaviour. Credentials stored in bnet_name/bnet_pass are
never appended to launch options while auto_login=0.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Adds a tkinter run-selector window accessible from the botty console via
the new select_runs_key hotkey (default F6). Users can check/uncheck any
of the 11 supported boss/farm runs; the selection updates Config in-memory
and takes effect on the next game without restarting botty.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
The custom tesserocr-2.5.2 wheel was compiled on a specific machine
against DLL versions that do not match a fresh conda-forge installation.
This caused persistent ImportError: DLL load failed regardless of PATH
or LoadLibraryExW approach.
conda-forge's tesserocr package is compiled against the exact same
conda-forge tesseract/leptonica binaries, so all DLL dependencies
are automatically satisfied within the conda environment — no manual
DLL path manipulation needed.
Changes:
- environment.yml: add tesserocr + tesseract as conda-forge packages,
remove leptonica pin (no longer needed), remove custom wheel from pip
- install.bat: replace wheel force-reinstall with pip uninstall cleanup
- src/*.py: simplify DLL fix to os.add_dll_directory only
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Every approach to manually replicating conda's DLL environment from
Python code or batch PATH manipulation has failed. conda run activates
the environment exactly like "conda activate botty" — setting PATH,
running activate.d scripts, and properly resolving all transitive DLL
dependencies for tesseract51.dll.
run_botty.bat now derives conda.exe from the botty python.exe path
(two levels up: envs/botty -> envs -> miniforge3 -> Scripts/conda.exe)
and uses "conda run -n botty --no-capture-output python src/main.py".
install.bat smoke test now uses "%CONDA_EXE% run -n botty python -c ..."
which already has CONDA_EXE set from the install step.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
os.add_dll_directory alone is not enough — LOAD_LIBRARY_SEARCH_USER_DIRS
does not propagate to transitive deps of deps when loaded automatically
by the OS (e.g. tesseract51.dll's deps like mingw runtimes, leptonica).
LoadLibraryExW with LOAD_LIBRARY_SEARCH_DEFAULT_DIRS (0x1000) explicitly
propagates user DLL dir search to the entire transitive dep chain, so
leptonica, libgcc, libstdc++, zlib etc. are all found in Library\bin
and Library\mingw-w64\bin without conda activate.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
ctypes.WinDLL uses LoadLibraryW which does NOT search user DLL dirs
registered via os.add_dll_directory/AddDllDirectory. It was throwing
FileNotFoundError and blocking the import before tesserocr was ever tried.
Python 3.8+ loads .pyd files with LOAD_LIBRARY_SEARCH_USER_DIRS which
DOES search user-registered dirs for the pyd and all its transitive DLL
dependencies. os.add_dll_directory(Library\bin) alone is sufficient.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
os.environ['PATH'] set from inside Python does not affect the Windows
DLL loader used by ctypes.WinDLL — the loader reads the process PATH
at load time, not from Python's env dict. Set PATH in the .bat files
before python.exe is launched so tesseract51.dll's transitive deps
(leptonica, zlib, libpng, etc.) are findable by the loader.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
When ctypes.WinDLL loads tesseract51.dll by absolute path, Windows
resolves that DLL's own transitive deps using the standard search order:
app-dir → System32 → Windows → cwd → PATH. Library\bin is in none of
those (conda activate was not run), so leptonica, zlib, libpng, etc.
are invisible and the load fails even though the DLLs are all present.
Fix: prepend all conda DLL dirs to os.environ['PATH'] before the
ctypes.WinDLL call so the standard DLL search finds them. os.add_dll_directory
is still called for Python's LOAD_LIBRARY_SEARCH_USER_DIRS path.
Together the three steps guarantee the import works without conda activate:
1. os.add_dll_directory - for .pyd loading
2. os.environ PATH - for ctypes transitive dep resolution
3. ctypes.WinDLL(abs) - pre-cache tesseract so .pyd reuses it
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
os.add_dll_directory alone is not enough on Windows. When Python loads
the tesserocr .pyd via LOAD_LIBRARY_SEARCH_USER_DIRS, Windows finds
tesseract51.dll in the added directory but then resolves tesseract's own
transitive deps (leptonica, zlib, libpng etc.) using only the standard
system search path -- not the user DLL dirs. Those libs live in
Library\bin, not System32, so they're invisible and the load fails even
though every DLL is present.
Fix: call ctypes.WinDLL(absolute_path_to_tesseract51.dll) before the
tesserocr import. LoadLibraryW with a full path anchors tesseract51.dll
to Library\bin, so Windows searches that directory for its transitive
deps. The already-loaded DLL is then returned from cache when the .pyd
requests it, making the import succeed.
Applied to ocr.py (test entry point), main.py, and shopper.py.
Also updated install.bat smoke test and diagnostic to use the same fix.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
The custom tesserocr wheel links against leptonica-1.78.0.dll at
compile time. Unpinned leptonica on conda-forge resolves to 1.82+
which installs leptonica-1.82.0.dll — a different filename — so
Windows DLL loader cannot find it regardless of os.add_dll_directory.
Also force-reinstall the wheel in install.bat to guarantee the
correct binary is used (not a stale cached version), and add a
diagnostic that prints which DLLs are actually present when the
smoke test fails so the root cause is visible instead of a vague
warning.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- README: add step-by-step Installation section (Miniforge → download
ZIP → install.bat → config → run_botty.bat) so a non-technical user
can follow it without reading development.md
- config/params.ini: reset personal fields (name, char_name,
saved_games_folder) to generic defaults so the downloaded zip
works out of the box for anyone
- install.bat: apply os.add_dll_directory before the tesserocr smoke
test so it stops emitting a false warning on every install
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
dirname(dirname(sys.executable)) walks two levels up from the exe on
Windows, landing in the envs/ parent rather than the env root. Replace
with sys.prefix which is always the correct conda env root, and cover
Library/{bin,mingw-w64/bin,usr/bin} to handle all conda-forge layouts.
Also remove unused _dll_fix.py which had the same bug.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
install.bat:
- Add conda self-test (conda --version) before env create
- Verify botty python.exe exists after env creation
- Smoke-test key imports (cv2, tesserocr, discord, etc.)
CI (.github/workflows/ci.yml):
- Add test_setup_bat_files.py to test matrix
test/test_setup_bat_files.py (7 tests):
- All expected .bat files exist
- No hardcoded usernames (alex, alexpolo, ultimate) in run scripts
- No absolute home paths in run scripts (must use %~dp0 / %USERNAME%)
- All run_*.bat source find_python.bat (no duplicated conda detection)
- find_python.bat checks >= 6 conda locations
- install.bat has conda self-test and python verification
- Add find_python.bat shared helper to auto-detect conda env (6 locations)
- All bat scripts now resolve paths relative to script location (%~dp0)
- Fixes crash when launching from Start Menu / pinned shortcut (cwd=System32)
- Fixes hardcoded username path in run_asset_extractor.bat
Python 3.8+ no longer searches conda's Library\bin for DLLs automatically.
Add os.add_dll_directory(sys.prefix/Library/bin) before importing tesserocr
so leptonica and tesseract DLLs are found on any conda-based Windows env.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Without cd /d "%~dp0", conda resolves environment.yml relative to
wherever the user launched the bat from (e.g. C:\Windows\system32).
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
find_best_match was using extractOne (which maximises its scorer) with
Levenshtein distance (lower = better), so it returned the worst match
instead of the best. Switch to extract+min to correctly minimise distance.
Fixes 6 failing tests in test_text_correction.py.
CI: run pytest under `coverage run` so coverage.xml has data to report;
drop deprecated use-only-tar-bz2 flag from setup-miniconda steps.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Adds install.bat, run_botty.bat, run.bat, environment.yml, src/, and
dependencies/ to the build output so users can download zip → run
install.bat → run_botty.bat without needing a pre-built conda env.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Added bnet_name, bnet_pass, char_name config options
- Launch options auto-append -bnetname/-bnetpass when credentials set
- character_select.py: OCR fallback when template matching fails
- Scans character list row-by-row, matches by name, clicks and scrolls
- Parse D2R binary .key format (10-byte entries, action=1=skill, action=0=non-skill)
- Detect skill slot bindings and validate against params.ini
- Scan Saved Games for any .key/.keyo file (handles battle tag names)
- Config loads cleanly, warns on skill/non-skill mismatches
- target_detect: add aspect ratio filtering to reject health bars and immune text
- pickit: add 0.2-0.3s wait after pickup before moving on (fix#939)
- game_controller: detect hardcore mode and stop instead of infinite death loop
- config: add 'hardcore' flag (default 0)
Closes#959, #939, #942
- Read character .key file from Saved Games / D2R install dir / APPDATA
- Auto-fill empty hotkeys in [char] section (inventory, belt, potions, etc.)
- Match skill slots to configured skills for validation
- Normalize key variants (left alt ~ alt, left shift ~ shift)
- params.ini values always take priority over detected bindings
- Warn on genuine mismatches between config and .key file
Full run implementations with approach/battle methods following the
existing botty pattern. Added 12 Location constants to pather.py,
12 placeholder path nodes to game.ini (coords recorded in-game later),
and 6 new kill_* methods to IChar interface. Updated params.ini
with new route documentation.
Path nodes are 0,0 placeholders — record real coords with
node_recorder.py in-game before enabling these routes.
Full run implementations following existing botty patterns:
- andariel.py: Act 1 Catacombs L2→L3→L4, waypoint-based approach
- countess.py: Act 1 Forgotten Tower L1→L5 via Black Marsh WP
- mephisto.py: Act 3 Durance of Hate L2→L3 via A3 WP
- baal.py: Act 5 Throne of Destruction, wave clearing + boss
Added 12 Location constants to pather.py and 12 placeholder
path nodes to game.ini (record in-game with node_recorder.py).
Path node coordinates are 0,0 placeholders — must be recorded
in-game before runs can actually execute.
- on_end_run: calls maybe_afk_break() after returning to town
- run selection loop: checks should_skip_run() before each run
- on_init: reshuffles _do_runs_reset if reshuffle_each_rotation is enabled
Also scaffolded 4 new run stubs: baal, mephisto, andariel, countess
Updates the prebuffs for paladin to use holy shield before entering Pindleskin's portal. Also, adds additional pickit logic for picking up and storing all rare items when uncommenting the sections.
I watched 10 hours of diablo run by 4 fohdins. About 30% were fail games which aborted in middle run, especially on detecting that if the seal is openned. In almost every fail game caused by this reason, the seal was openned successfully, but the decisions were closed for 5 times and the game was aborted. By increasing the numbers of try and reduce the seal_opentemplates threshold to 0.7, the incorrect judgements on seal's open status seldom occur, and the ratio of failed games typically reduced. Now it is about 12% mostly caused by temporary high latency or getting lost after teleport to pick item.
Fixed Stormshield using wrong mod for damage reduction
Fixed Crown of Ages using wrong mod for damage reduction
Added +Skill to Arkaine's Valor Perfect
Added Damage Reduction mod for Leviathan Perfect
* Setting Up FOHdin for CS
* replaced hammers with FOH, but not targeted yet
* finish merging
* demo
* implemented all runs. avoid nihlatak.
* replaced FOH with Hammer for Nihl
* using hammers for dia now
* reverted dia hammers to FOH, so pure FOH runs CS
* added non mob-detection attacks to trav
* some tweaks
* tweaked hammer pindle for tele amu
* improved pindle for walker FOHdin
* Pindle, Eld, Shenk, CS tweaked. Trav needs work.
* fix NoneType error if nodes aren't found
* consolidate trav code
* small adjustment
* increase attack duration in loop condition rather than attack sequence
* pindle optimizations
* optimizations of pindle, eld, shenk
* more optimizations, trav still kinda ghetto
* update target detect to include closer targets
* implement concentration with holy bolt
* nihlathak and summoner
* revised CS trash atk pattern
* tweaked sealbosses!
* CS ready for pressure tests
* implement in-chaos pickit, match/case logic over if/elif, consolidate more functions, move some to i_char.py, patch health_manager.py to prevent it from closing inventory, update inspect_items() to allow items to be dropped and not kept to be sold
* revert change
* reserve hammerdin changes for a separate PR
* get rid of extra attack sequence
* disable target/attack logs for merge
* code cleanup
Co-authored-by: mgleed <[email protected]>
* Add files via upload
Sazabis sword found to add since its decent for barb fury mercs.
* Update pickit.ini
Adds sazabi sword to pickit so users can turn it on if they want it.
* Update pickit.ini
* Add files via upload
sazabi helmet for addition to complete sazabi set
* Add files via upload
there, the pngs. I don't know why I missed them the first time....sorry bout that
* Add files via upload
* Delete set_sazabi_sword.png
* Delete set_sazabi_helmet.JPG
* Delete set_sazabi_helmet.png
* Delete set_sazabi_sword.JPG
* Add files via upload
Fixed images
* Better pathing for walkers in trav
* Add files via upload
* trim a few templates
* re-add column asset, utilize another v2 asset
* fix tab/space issue
Co-authored-by: mgleed <[email protected]>
* Added the ability to transmute all gem types and select in params.ini
* Added the assets
* Updated STD to standard for transmute and updated ini for list style
* Changed the assets to standard
* changed list in INI to run only flawless by default
* Optimized the transmute: will only need to go through search once
* update tab selection
* Update params.ini
Co-authored-by: mgleed <[email protected]>
* feat/cached-img
* thread safe
* update
* add a force_new param for grab() and enable it for pather, which is more dependent on exact current frame
* use existing img for pather screen object checks
Co-authored-by: mgleed <[email protected]>
* Small optimization by know if current run is last
* Tele amu optimization
Prefer to not use tele changes when getting back inside to loot.
Only use tele charge if pathway was blocked.
This change makes bot use a less tele charges, and thereby improve run speed due to not having to repair as often.
* Pass whole list of runs instead of just last one
This would allow to e.g check first run / only run etc
* migrate from python 3.9 to 3.10, recompiled tesserocr
* cleanup old types, change all Unions to |
* add a few match/case statements
* Update ci.yml
Co-authored-by: aeon0 <[email protected]>
* init, wip
* time to test
* remove unused dataclass
* tests passing, just need to run in-game
* fix wait logic
* add threading lock, delete excess funcs
* start putting best_match back in to search_and_wait()
* bugfixes, found a bad template
* scope
* fix npc detect
* added mob detection to CS to speed up runs
* refactoring - redemption does not work
* remove unused filterimage param for template_finder.search_and_wait
* get rid of selfs, add live view within mob_detection.py
* mobcheck(self) -> mobcheck()
* cleanup, img as an arg to mobcheck
* prefix functions for encapsulation, cleanup redundant code
* simplify further
* trim
* simplify live-viewer, move
* corrected liveviewer -- opencv hue ranges to 180 not to 255
* rename funcs
* consolidate live-view into target_detect.py
* silly ide
* fix nonetype error
fox nonetype error - but no mobs are detected anymore :(
* script runs, but fails to deteect mobs
* add pytest for target detection, fix target sorting
* revert info_ss
* running now! added "mercwait" to debug runtime
* added testing back to pather.py
* update pytest, discrete blue/green filtering, cleanup code
* fix NoneType, add negative test
* consolidate tests
* fix oopsie
* add config param
* fix config param
* fixed atk sequences & logging spam
ready to merge after my final 2 tests
* undo bad import
Co-authored-by: mgleed <[email protected]>
* New Hydra Build
Only runs pindle shenk eld so far
* Adjust hydra sorc to use any alt skill on right click
* Lowered spray
* Update config/params.ini
Co-authored-by: mgleed <[email protected]>
* Added bone necro char
* Added damage_scaling option
* adjusted some targeting
* Removed colored logging statements, documented damaage_scaling option, and moved its usage to the base class i_char
* Enhancement: Faster save and exit using keyboard
* Enhancement: faster chicken
1) Remove delay among key/mouse release (human can do it simutaneously
2) Save screenshot after save_and_exit
* Updated pot drinking logic
* fixed bugs
* simplify
* Fixed bad hp/mana check
* Fixed bad hp/mana check, (last was noob git by me)
Co-authored-by: Your Name <[email protected]>
Co-authored-by: mgleed <[email protected]>
* feat/restricted-mouse-clicks
* fix typos, use GoldBtnInventory ScreenObject, use personal.specific_inventory_roi()
* make smaller gold button, trim ROI, and use grayscale to search
* remove unused roi
* fix circular import by reverting to Config inventory area and adding open_inventory_area variable, adjust code throughout
* simplify
* revert to template finding due to circular import with ui_manager
* grayscale template match
Co-authored-by: mgleed <[email protected]>
* Condsensed discord message will be the only option
Condensed message will be default. removed the option from params.
* Update game_stats.py
* Update discord_embeds.py
* Update bot.py
* Update shenk_eld.py
* Update config.py
* removed a line
removed e.set_thumbnail(url=f"{self._psnURL}36L4a4994.png")
* Update README.md
* New params for runtime and break time
* New params to manage session length and break time
* Starting message
* Message adjustments
* use _default_iff in config
Co-authored-by: mgleed <[email protected]>
* Leveraging OCR to read exp and track in game_stats
Start of each game and end of each run OCR exp
game_stats stores starting EXP and current EXP
game_stats summary calculates XP gained, XP per game, XP per hour
* Adding lvl tracking and xp to next level stats
* logic clean up
* Added a way to add config variables to ini files.
* Added examples
* Added examples
Co-authored-by: Your Name <[email protected]>
Co-authored-by: mgleed <[email protected]>
* Enhancement: Use keyboard to select difficulty
* press instead of send
if somehow the select difficult menu is not available when the while True: loop is running, will still work.
* simplify, delete now unused assets
* cleanup more
Co-authored-by: jagarop <[email protected]>
Co-authored-by: mgleed <[email protected]>
* fix item_cropper.crop_item_descr to allow some edge cases where there is lots of text in the box
* Fix send_stash message when stash is full
* remove self
* undo
* fix id logic, fix count reset, up to version 0.7.0, trim excess
* attempt to fix loop at cain, etc. when return_to_play() is called
* spell check, simplify
* center mouse after opening NPC menu
* fix messenger item count bug
* fix id logic, fix count reset, up to version 0.7.0, trim excess
* attempt to fix loop at cain, etc. when return_to_play() is called
* spell check, simplify
* init, wip
* migration from original ocr complete, now troubleshooting phase
* first
* starts now
* catching more
* pause here, fix keep_item for ItemText class
* getting close
* adjust consumable quantity to refill per Legit
* fix import, refactor tp scroll tracking
* fixed property checking
* merge master, fix discord embed
* move tp charge setter
* properly track id scrolls after use
* fix accidental stashing
* fix sell_junk and chest_label bugs
* working on drognan instead of lysander
* a2 pathing, drognan support
* fix town-start to wp pathing, change key counting
* better drognan pathing
* comment out pather stuff
* restore old asset
* proper tp tracking
* revert key pickup to decrease need by 1
* fixing gambling, WIP
* working on gambling
* add comments to transfer_items
* add comments to transfer_items
* gambling semi-working, not stashing right
* simplify gambling process
* incorporate OCR gold reading to gamble, WIP
* gambling is painful
* simplified gambling - ~done
* finally finished gambling!
* fix diablo inventory check
* do not fail purchasing routine if item template is not seen
* fix condition
* deprioritize keys, scrolls during pickit; stop clicking Misc tab at repair
* cleanup
* init
* add support for grayscale + color_filtered
* cleanup logic
* only generate grayscale templates if use_grayscale=True
* simplify template_gray logic
* fix false detection of out of gold
* gambling with OCR was too buggy--replace with max gamble item count
* cleanup
* logic for ident, first attr. advanced pickit
* logic advanced vs normal pickit
* logic_empty_ID_Tome
* small adj. to fit PR
* converted yaml to ini. changed some val.
* changed param to auto_ident
* pickit integration
* added set in routine
* reset bot init value
* bugfix
* removed old id logic
* added_nested_filter
* changed pickit
* bugfix replace
* added some more props
* Cleanup
* cleanup
* cleanup
* started unittests
* unittest
* small pickit adjustments
* Added missing fct. after merge
* bugfix due to static method in config.py
* added comments in pickit.ini
* Update pickit.ini
* Update params.ini
* Update ui_manager.py
* Update pickit.ini
* bugfix
* remove redundant for loop, use set_gold_full
* fix merge
* simplify
Co-authored-by: aeon0 <[email protected]>
Co-authored-by: mgleed <[email protected]>
* Temporary bugfix: Jamella and Halbu roi's poses
npc_manager.py needs roi updates for jamella and halbu as they were statically calculated based on pose positions from a4_vendor, but Skizot updated Jamella pathing and thus these no longer work.
Gonna delete the roi's and pose positions until this can be done--it'll just search by original algorithm for NPc detection in the meantime.
* noticed the same behavior with Fara :(
* Bugfix: Properly dismiss NPC dialogue
Thought this was fixed in #582 but it seemed to work only in some scenarios. This change is more robust.
* Remove debug line
Summary of changes:
-Rearranged many UI-related functions across the codebase (particularly in ui_manager.py) into individual components. See src/ui_components/. Functions related to components can be called like if waypoint.use_wp("Arcane Sanctuary"): or loading.wait_for_loading_screen(2.0)
-Removed Screen and TemplateFinder dependencies across the codebase
-Screen functions separated from class
-TemplateFinder made into a singleton
-Added generic ScreenObject UI functions to simply detect UI elements on screen; e.g., detect_screen_object(ScreenObjects.TownPortal), wait_for_screen_object(ScreenObjects.Loading, 2). Also included related functions like select_screen_object_match(template_match) to automatically select the match.
-Consolidated assets. Organized templates folder into components. Adjusted some asset ROI's and alpha channels.
-Changed some template searches to grayscale for speed
-Reworked save/exit routine, should now be more consistent and faster with normal exit, chicken exit, and death exit.
-Got rid of unused and consolidated duplicate game.ini parameters
-spelling fix _last_merc_healh
-NPC manager class removed and replaced with functions
-D2 window detection now runs on its own thread for semi-live window position offsets.
-Character selection reworked. OCR your selected character for fun (not that accurate, lol)
-Simplified game start and difficulty select
-Added a get experience function, but this is not called anywhere yet
-Reworked corpse pickup. pickup_corpse is no longer a necessary argument/parameter throughout the codebase (bot.py, game_controller.py, runs, etc.)
* C1F & B1S switched to static pathing
* Improved overall robustness
* improved A layout check consistency
* added A layout check consistency & CTA rebuff
* Home now a loop, B now first seal to pop.
* Trash clear at pentagram, B1S, C1F loop added.
* Changed DeSeis attack pattern so MERC can kill
* tweaked diablo spawn timer & vizier attack pattern
* fixed minor pickit error.
* fixed Layout B Check loop & improved C1F
* improved C1F pathing home
* improved B layout check by removing trash kill
* added C2G static path to infector & loop home
* tweaked pathing
* lots of pathing tweaks to increase consistency
* Clarified Logger.Info
* Fixed A1L Tele to Lava Bug
* Fixed A1Y Tele Lava bug
* Fixed B2U missing pentagram on loop back
* fixed logger
* Completely reworked layout checks
* Reworked C seal layout check
* fixed ending up too low at Diablo
* optimized loop home for C
* added all seals
* removed duplicate trash sequence at C
* Fixed B2U looping home, added essence
* Template Check Threshold raised for Layout Checks
* Reverted A layout check back to inital version
* Aggressive Layout Checks implemented.
* Replaced Pentagram Templates to improve [602]
* Fixed loops for new pent layout
* Reverted A layout checks back to initial version
* merged 6.1?
i hope it worked.
* Pressure Test: A2Y 98% layout check success
* Massive improvements on Layout Checks
* Integrated Skizot's A4 merchants
* Fixed an error on A1L.
* fixed a small error on final pickit
* Slight Cleanup
* tweaked A2Y nodes to avoid lava jumps
* Added B2U Seal Logging
* Add templ. to 611 & 610 to fix stuck pather
* WiZ/SwePow suggestions
* Some more tweaking
* Trying to improve B layout checks (not full run)
* Seal B logic pressure test (no full run)
* added timeouts to weak traversenodes (no full run)
* Pacifist Version - just checking layouts no bosses
* Pure Layoutcheck Pressure Test (double checks)
* Pacifist mode: Layout Check
* Update bot.py
* Added Templates to improve Layoutchecks
* Tweaked B2U Templates (Pacifist Version)
* add B2U nodes & calibration to impr. layout check
* added nodes to layout checks
* Added A1L Calibration Node for Layoutcheck
* Full running script
* Fixed Calibrations - Full Run
* fixed mousover keyerror
* yet another mousover
* fixed a pather issue on B1S, full run
* yet another mousover. full run.
* mousover... ful run
* yet another mousover. full run
* fixed C2G looping across pentagram
* Update game.ini
* WiZ testing
* added seal_node to sealdance
* added C1F layoutcheck nodes
* Added Wiz Speedrun
* tweaked wait in sealdance
* A1L-Static path to Pentagrram
* added A2Y hop 622
* A2Y static path home
* B1S static path home
* changed C1F fake template
* added new C1F seals & static home
* Speed or no speed
Using kill_cs_trash config
* fixed a 36b key error
* Random guess actually is random now
It wasnt random at all before so trying out if this is better
* Minor fixes and tweaks
* B2U - Static Path Home
* Redworked C1F - not stable yet
* Seal order B,C,A and DeSeis fix
There was copy/paste bug in DeSeis making it use wrong atk_len and then I also tuned the attacks a bit.
* fixed final B2U issue, should be stable now!
* added B2U 644_646 static path
* added mapping
* A seals fixed hopefully
More testing tomorrow!
* run diablo py
Yours is the diabloC, I didnt have time to compare them before sleep and wanted you to have both in case
* so this should be super stable now!
* Reverted A1L & A2Y Seal Order (but kept WiZ Idea)
* Reverted Seal Order
* Added Rudi TP :)
* all loop home now
* added TP template to loop
* typo
* stashs & shrine gfx, TP counter
* removed pent_6 (many false positives looping home)
* clean up for aeon
* fixed typos
* New A2Y & C2G home pathing
* Reworked C1F 704 to avoid stuck
* Ready to test!
* fixed pather clicking outside D2R
* Tweaked Seal A
* Loot to Sealdance() & A2Y tweaks
* removed FALSE from TP fail
* Updated Readme.md
* remove env
* clean up code
* account for used tp
* Removed Dia C1F 700s and replaced with 650s
* Moved Seals, Fights & Pickit to Hammerdin file
* #Trapsin now supported for Arcane and CS
#Trapsin now optionally uses defensive spells Cloak of Shadows and Mind Blast
#Trapsin now waits for Shadow Warrior before using CTA
* Update readme
* Working version
* added trapsin
* added nova sorc
* added nova sorc
* added light & nova sorc
* added clear trash, issue on 1 function
* Added Clearing Trash
* added 1 tele stop between WP & Pentagram
* fixed mistake on i_char
* CS Trash is now available for beta testing
* fixed a minor issue
* percised some debug logging
* Added CS Trash for Necro, Trapsin, Sorc (no blizz)
* added CS trash to Necro, Trapsin, Sorc_Light _Nova
* # moved functions from chars back to diablo.py
# removed CS for all chars execept hammerdin
# added seal_layout to sealboss functions
# added location to cs_kill_trash functions
* removed pickit from hammerdin.py
* trying to fix some attribute errors for pre_move
* forgot to add _char for pather.traverse_nodes
* added & removed some ._char, now it runs :)
* fixed a missing argument & cleaned up
* clear_trash=1 implemented & working
* added more kill_trash options
* added more debug info & generic kill trash spots
* fixed some debugger errors
* hammerdin done
* updated readme for cs_trash
* #merged A1L and A2Y pathing to char.py
# added A2Y static pathing to Pentagram
* fixed some errors ...
* A1L & A2Y work with new pathing logic
* Ok generic Diablo.py should now work
* and hammerdin.py aswell
* full run
* moved C1F static path to hammerdin.py
* fixed an error for pathing
* Improved A LC consistency to >99%
* Colors!
* added 2 A1L LC templates
* moved pickit to hammerdin.py
* 4min runs
* removed timeouts. 88% killrate right now.
* Added Logparser (thanks famE)
* separated log parser columns
* tweaked log parser
* changed positions of de seis kill screenshots
* added clearing trash between Pent & LC
* fixed missing _self
* Added option for cs_town_visits between seals
* Improved B1S loop home & tweaked some messages
* fixed an error not clearing seals & added colors
* removed 632 duplication & not needed LC templates
* added clear trash to A & B LC
* added pickup at 646
* added PENT_2 Template & removed fail shrine checks
* Added C2G Templates currently only 82% good runs
* Added Hybrid Calibration Nodes
* re-added templates for LC
* added 1px to WP->PENT traverse to avoid missing it
* tweaked home from 632 for not missing pent
* added more redemption before 632 calibration
* merged individual seals to one function
* fixed seal template error
* Added LC function. Fixed C2G & Loop to Pent-Stable
* added atk_len for diablo
* fixed typo so log parser stats are right
* added & increased threshold to LC calibration
* fixed new LC threshold oO - should push less often
* reverted B LC back to initial order
* log parser tweaks
* added shrine check to pather.py
* replaced 630640 with 647
* added B1S LC templates & changed shrine check
* swapped LC for B (current had 75%)
* reworked cs_trash up to LC
* log parse counts templates used
* Fixed B Layout checks
* log parser now w. templates. shrine check added
* just changes to debug names & adding a nicer table for botty start :P
* #best match & time_out=0.1,
#return false on nodes
#new shrine ROI
* Force Stashing before running Dia, logger wording
* shrine check less frequent. char.py cleaned up
* reworked trash layout B
* added 603 ROF1 templates
* added info to params.ini to reduce Q&A in discord
* # added stash every run param
# transferred trash choreography to char.py
# reverted some template changes.
* Ready to merge. Not perfect, but much better!
* small fix before merging
* Update nova_sorc.py
* Update basic.py
* added missing templates causing an error @602
* C2G: static path LC 661 & comment screenshots
* various changes--changed some info to debug, changed floats in config, adjusted runs_per_stash logic
* autoformatting made me commit
* nodes for trash between pent & seal. A works B is bad.
* fix bool -> bool(int(
* added calibration nodes for trash pent>seal
* Full run for testing efficacy of new trash nodes
* fixed a pathing issue
* reverted back to old method for trash.
* Ready for merge.
Co-authored-by: WiZ <[email protected]>
Co-authored-by: aeon0 <[email protected]>
Co-authored-by: mgleed <[email protected]>
* Revert tesserocr to conda-forge rather than pip
Tesserocr with Tesseract > 5 works with pip install but installer fails if user doesn't have VS compilers. Will revisit
* fixed by providing precompiled tesserocr
* Update ui_manager.py
Fix for gambling issue. Looks like self._config.char["gamble_items"] always returns true. Therefore im checking if the first entry is empty and go for a not.
not self._config.char["gamble_items"][0]==""
* simplify
Co-authored-by: mgleed <[email protected]>
* Add ability to launch botty from either main menu or town
* Fix game recovery routine is stuck forever on character selection screen
* Move _char_template to class variable to be able to share among instances
* fix character recovery and character select, support offline/online. WIP
Co-authored-by: Vladimir Makayev <[email protected]>
Co-authored-by: mgleed <[email protected]>
This branch will detect if stash is full of gold. Afterwards the gambling functionality (currently only in act 4 implemented) will be called. The bot will gamble for specific items (determined in params.ini) until no gold is left. He will check for properties specified in pickit.ini and sell not matching items.
Supported items are: ring, amulet, circlet, coronet, talon
Leverages recent advanced pickit PR.
* logic for ident, first attr. advanced pickit
* logic advanced vs normal pickit
* logic_empty_ID_Tome
* small adj. to fit PR
* converted yaml to ini. changed some val.
* changed param to auto_ident
* pickit integration
* added set in routine
* reset bot init value
* bugfix
* removed old id logic
* added_nested_filter
* changed pickit
* bugfix replace
* added some more props
* Cleanup
* cleanup
* cleanup
* started unittests
* unittest
* small pickit adjustments
* Added missing fct. after merge
* bugfix due to static method in config.py
* added comments in pickit.ini
* Update pickit.ini
* Update params.ini
* Update ui_manager.py
* Update pickit.ini
Co-authored-by: aeon0
Co-authored-by: mgleed
* fix duplicate bot name in status update message
This commit changes the behavior of the _send_status_update method. If the generic_api is in use, it will avoid adding the bot name to the start of the message because the bot name is already added by the generic api.
* fix duplicate bot name in status update message
This commit moves the fix to the DiscordEmbeds class instead of performing it with an if statement in the GameStats class.
* fixed bug w/ walking hammerdin that would cause vigor to be left on while attacking in trav
* added option to fill shared stash first
* reverted a change not related to this PR
* integrated diablo scaffold
still pending pathing & kill logic
* minor changes to cross river of flame - but loop not working
* River of Flame sucessfully skipped - CS Entrance cleared
* Seal B works.
* Everthing is slow :(
* reparied slowness
* revert deleted main code
* Added CS Entrance recognition & changed Pentagram templates
* Merge branch 'diablo' of https://github.com/jobithu/botty into diablo
* Seal B works for both layouts - still clumsy, but it works.
* added Vizier (A). working: A1, B1, B2 - pending: A2,C1,C2,Diaaaa
* C identifcation implemented.
* All seals done, optimization needed for pathing & fighting sequences
* fixed minor error in game.ini
* should now run - badly. all seal spots & paths need optimization
* A1Y works stable
* A1Y stable, WP now has issues
* A2Y seal mouseover added - new bug, could not determine window offset
* Both A seals work great now!
* A2L Seals work consistently. A1Y quite good.
* easteregg
* A2L improvements
* Succesful kill on A2L, B1S, C1G
* Tweaked Diablo Kill Wait Time
* start cleanup
* added functions for each seal layout
* First stable dia kills with fixed map layout
* added Sealdance function
* Fixed Sealdance. A1L is finished. All others work in Progress.
* A2L + B1S Works
* A2L and B1S works
* Script runs well now on A1L, B1S, C1G. Sealdance needs more movement.
* Update diablo.py
* A1L, C2F, B2F working. Kills Dia Hell well.
* added issue log. testing all layouts now.
* Individual runs work, Sealdance needs tweaking
* All seals work, got frustrated, added sorc&she rox
* tweaked sorc vizier & infector fights
* rebuild templates & nodes for A2Y, B1U, C2G
* Forgot to add Seal A to the run oO
* renamed forgotten templates for A2Y
* fixed minor bugs on A1Y. succesfull dia kill
* Optimized A2Y
* Reworked A1Y templates & fight - very stable! Yay!
* A2Y, B2U, C2G work - but i think it should be B1U
* 4/6 seals work (B1S & C1F pending)
* some clean up
* remove unwated code
* 5/6 Seals done - added C1F
* sealdance and error handling
* try to merge stuff somehow
* add additional template coordinates to failed nodes
* fix health manager by adding diablo lcoation
* remove thresholds
* fix template names and error handling for c seals
* C1F added. 5/6 seals done.
* All seals work - let's get it tested!
* B1S fight coregraphy improvements
* New paths for layout check to increase consistency
* fixed A2Y edge lava port (625>626)
* All seals work, lets test with more ppl & finetune
* C1F & B1S switched to static pathing
* Improved overall robustness
* improved A layout check consistency
* added A layout check consistency & CTA rebuff
* Home now a loop, B now first seal to pop.
* Trash clear at pentagram, B1S, C1F loop added.
* Changed DeSeis attack pattern so MERC can kill
* tweaked diablo spawn timer & vizier attack pattern
* fixed minor pickit error.
* fixed Layout B Check loop & improved C1F
* improved C1F pathing home
* improved B layout check by removing trash kill
* added C2G static path to infector & loop home
* tweaked pathing
* lots of pathing tweaks to increase consistency
* Clarified Logger.Info
* Fixed A1L Tele to Lava Bug
* Fixed A1Y Tele Lava bug
* Fixed B2U missing pentagram on loop back
* fixed logger
* Completely reworked layout checks
* Reworked C seal layout check
* fixed ending up too low at Diablo
* optimized loop home for C
* added all seals
* removed duplicate trash sequence at C
* Fixed B2U looping home, added essence
* Template Check Threshold raised for Layout Checks
* Reverted A layout check back to inital version
* Aggressive Layout Checks implemented.
* Replaced Pentagram Templates to improve [602]
* Fixed loops for new pent layout
* Reverted A layout checks back to initial version
* merged 6.1?
i hope it worked.
* Pressure Test: A2Y 98% layout check success
* Massive improvements on Layout Checks
* Integrated Skizot's A4 merchants
* Fixed an error on A1L.
* fixed a small error on final pickit
* Slight Cleanup
* tweaked A2Y nodes to avoid lava jumps
* Added B2U Seal Logging
* Add templ. to 611 & 610 to fix stuck pather
* WiZ/SwePow suggestions
* Some more tweaking
* Trying to improve B layout checks (not full run)
* Seal B logic pressure test (no full run)
* added timeouts to weak traversenodes (no full run)
* Pacifist Version - just checking layouts no bosses
* Pure Layoutcheck Pressure Test (double checks)
* Pacifist mode: Layout Check
* Update bot.py
* Added Templates to improve Layoutchecks
* Tweaked B2U Templates (Pacifist Version)
* add B2U nodes & calibration to impr. layout check
* added nodes to layout checks
* Added A1L Calibration Node for Layoutcheck
* Full running script
* Fixed Calibrations - Full Run
* fixed mousover keyerror
* yet another mousover
* fixed a pather issue on B1S, full run
* yet another mousover. full run.
* mousover... ful run
* yet another mousover. full run
* fixed C2G looping across pentagram
* Update game.ini
* WiZ testing
* added seal_node to sealdance
* added C1F layoutcheck nodes
* Added Wiz Speedrun
* tweaked wait in sealdance
* A1L-Static path to Pentagrram
* added A2Y hop 622
* A2Y static path home
* B1S static path home
* changed C1F fake template
* added new C1F seals & static home
* Speed or no speed
Using kill_cs_trash config
* fixed a 36b key error
* Random guess actually is random now
It wasnt random at all before so trying out if this is better
* Minor fixes and tweaks
* B2U - Static Path Home
* Redworked C1F - not stable yet
* Seal order B,C,A and DeSeis fix
There was copy/paste bug in DeSeis making it use wrong atk_len and then I also tuned the attacks a bit.
* fixed final B2U issue, should be stable now!
* added B2U 644_646 static path
* added mapping
* A seals fixed hopefully
More testing tomorrow!
* run diablo py
Yours is the diabloC, I didnt have time to compare them before sleep and wanted you to have both in case
* so this should be super stable now!
* Reverted A1L & A2Y Seal Order (but kept WiZ Idea)
* Reverted Seal Order
* Added Rudi TP :)
* all loop home now
* added TP template to loop
* typo
* stashs & shrine gfx, TP counter
* removed pent_6 (many false positives looping home)
* clean up for aeon
* fixed typos
* New A2Y & C2G home pathing
* Reworked C1F 704 to avoid stuck
* Ready to test!
* fixed pather clicking outside D2R
* Tweaked Seal A
* Loot to Sealdance() & A2Y tweaks
* removed FALSE from TP fail
* Updated Readme.md
* remove env
* clean up code
* account for used tp
* Removed Dia C1F 700s and replaced with 650s
Co-authored-by: aeon0 <[email protected]>
Co-authored-by: WiZ <[email protected]>
* My pick up and sorc parameters
* Moved the game logic away from the main within the new GameController class.
Refactored the main to use built-in keyboard add_hotkey and wait functions instead of self-written main loop.
* Revert "My pick up and sorc parameters"
This reverts commit 0920a406
* Changed number of loot columns
* Changes as per the comments:
Handle non-existing backup file.
Removed newline at the beginning of GameController class.
Changed line splitted into multiple lines back to a single line.
* Refactored graphic debugger with similar logic to botty to now have a controller.
* Added a small comment on the graphic debugger class and fixed a now-typo on the presented menu
* Bug Fix: if pressing F11 repeatedly the main loop may crash due to self.bot not yet initialized in the game_controller.
A safe solution would be to try and catch all but that would also be an overkill as it would most likely mask exceptions that we might want to make the program crash.
Checking that self.bot is initialized should work for the time being.
* No need to re-assign the resume key within the game controlelr anymore
* Urgent 1 liner bug fix for always_on_top: name 'config' is not defined
* Bug fix, keyboard dependency on game_recovery.py
For some reason PyCharm seems to flag this import as unused while it is actually used.
* Set diablo to not run by default
* My pick up and sorc parameters
* Moved the game logic away from the main within the new GameController class.
Refactored the main to use built-in keyboard add_hotkey and wait functions instead of self-written main loop.
* Revert "My pick up and sorc parameters"
This reverts commit 0920a406
* Changed number of loot columns
* Changes as per the comments:
Handle non-existing backup file.
Removed newline at the beginning of GameController class.
Changed line splitted into multiple lines back to a single line.
* Refactored graphic debugger with similar logic to botty to now have a controller.
* Added a small comment on the graphic debugger class and fixed a now-typo on the presented menu
* Bug Fix: if pressing F11 repeatedly the main loop may crash due to self.bot not yet initialized in the game_controller.
A safe solution would be to try and catch all but that would also be an overkill as it would most likely mask exceptions that we might want to make the program crash.
Checking that self.bot is initialized should work for the time being.
* No need to re-assign the resume key within the game controlelr anymore
* Urgent 1 liner bug fix for always_on_top: name 'config' is not defined
* Bug fix, keyboard dependency on game_recovery.py
For some reason PyCharm seems to flag this import as unused while it is actually used.
* Set diablo to not run by default
* set d2r on top upon restart if configured to do so
* Revert "Set diablo to not run by default"
This reverts commit 6202814a8b6a0ae0ddbcd7c5dcee520ed897f3d0.
* integrated diablo scaffold
still pending pathing & kill logic
* minor changes to cross river of flame - but loop not working
* River of Flame sucessfully skipped - CS Entrance cleared
* Seal B works.
* Everthing is slow :(
* reparied slowness
* revert deleted main code
* Added CS Entrance recognition & changed Pentagram templates
* Merge branch 'diablo' of https://github.com/jobithu/botty into diablo
* Seal B works for both layouts - still clumsy, but it works.
* added Vizier (A). working: A1, B1, B2 - pending: A2,C1,C2,Diaaaa
* C identifcation implemented.
* All seals done, optimization needed for pathing & fighting sequences
* fixed minor error in game.ini
* should now run - badly. all seal spots & paths need optimization
* A1Y works stable
* A1Y stable, WP now has issues
* A2Y seal mouseover added - new bug, could not determine window offset
* Both A seals work great now!
* A2L Seals work consistently. A1Y quite good.
* easteregg
* A2L improvements
* Succesful kill on A2L, B1S, C1G
* Tweaked Diablo Kill Wait Time
* start cleanup
* added functions for each seal layout
* First stable dia kills with fixed map layout
* added Sealdance function
* Fixed Sealdance. A1L is finished. All others work in Progress.
* A2L + B1S Works
* A2L and B1S works
* Script runs well now on A1L, B1S, C1G. Sealdance needs more movement.
* Update diablo.py
* A1L, C2F, B2F working. Kills Dia Hell well.
* added issue log. testing all layouts now.
* Individual runs work, Sealdance needs tweaking
* All seals work, got frustrated, added sorc&she rox
* tweaked sorc vizier & infector fights
* rebuild templates & nodes for A2Y, B1U, C2G
* Forgot to add Seal A to the run oO
* renamed forgotten templates for A2Y
* fixed minor bugs on A1Y. succesfull dia kill
* Optimized A2Y
* Reworked A1Y templates & fight - very stable! Yay!
* A2Y, B2U, C2G work - but i think it should be B1U
* 4/6 seals work (B1S & C1F pending)
* some clean up
* remove unwated code
* 5/6 Seals done - added C1F
* sealdance and error handling
* try to merge stuff somehow
* add additional template coordinates to failed nodes
* fix health manager by adding diablo lcoation
* remove thresholds
* fix template names and error handling for c seals
* C1F added. 5/6 seals done.
* All seals work - let's get it tested!
* B1S fight coregraphy improvements
* New paths for layout check to increase consistency
* fixed A2Y edge lava port (625>626)
* All seals work, lets test with more ppl & finetune
* C1F & B1S switched to static pathing
* Improved overall robustness
* improved A layout check consistency
* added A layout check consistency & CTA rebuff
* Home now a loop, B now first seal to pop.
* Trash clear at pentagram, B1S, C1F loop added.
* Changed DeSeis attack pattern so MERC can kill
* tweaked diablo spawn timer & vizier attack pattern
* fixed minor pickit error.
* fixed Layout B Check loop & improved C1F
* improved C1F pathing home
* improved B layout check by removing trash kill
* added C2G static path to infector & loop home
* tweaked pathing
* lots of pathing tweaks to increase consistency
* Clarified Logger.Info
* Fixed A1L Tele to Lava Bug
* Fixed A1Y Tele Lava bug
* Fixed B2U missing pentagram on loop back
* fixed logger
* Completely reworked layout checks
* Reworked C seal layout check
* fixed ending up too low at Diablo
* optimized loop home for C
* added all seals
* removed duplicate trash sequence at C
* Fixed B2U looping home, added essence
* Template Check Threshold raised for Layout Checks
* Reverted A layout check back to inital version
* Aggressive Layout Checks implemented.
* Replaced Pentagram Templates to improve [602]
* Fixed loops for new pent layout
* Reverted A layout checks back to initial version
* merged 6.1?
i hope it worked.
* Pressure Test: A2Y 98% layout check success
* Massive improvements on Layout Checks
* Integrated Skizot's A4 merchants
* Fixed an error on A1L.
* fixed a small error on final pickit
* Slight Cleanup
* tweaked A2Y nodes to avoid lava jumps
* Added B2U Seal Logging
* Add templ. to 611 & 610 to fix stuck pather
* WiZ/SwePow suggestions
* Some more tweaking
* Trying to improve B layout checks (not full run)
* Seal B logic pressure test (no full run)
* added timeouts to weak traversenodes (no full run)
* Pacifist Version - just checking layouts no bosses
* Pure Layoutcheck Pressure Test (double checks)
* Pacifist mode: Layout Check
* Update bot.py
* Added Templates to improve Layoutchecks
* Tweaked B2U Templates (Pacifist Version)
* add B2U nodes & calibration to impr. layout check
* added nodes to layout checks
* Added A1L Calibration Node for Layoutcheck
* Full running script
* Fixed Calibrations - Full Run
* fixed mousover keyerror
* yet another mousover
* fixed a pather issue on B1S, full run
* yet another mousover. full run.
* mousover... ful run
* yet another mousover. full run
* fixed C2G looping across pentagram
* Update game.ini
* WiZ testing
* added seal_node to sealdance
* added C1F layoutcheck nodes
* Added Wiz Speedrun
* tweaked wait in sealdance
* A1L-Static path to Pentagrram
* added A2Y hop 622
* A2Y static path home
* B1S static path home
* changed C1F fake template
* added new C1F seals & static home
* Speed or no speed
Using kill_cs_trash config
* fixed a 36b key error
* Random guess actually is random now
It wasnt random at all before so trying out if this is better
* Minor fixes and tweaks
* B2U - Static Path Home
* Redworked C1F - not stable yet
* Seal order B,C,A and DeSeis fix
There was copy/paste bug in DeSeis making it use wrong atk_len and then I also tuned the attacks a bit.
* fixed final B2U issue, should be stable now!
* added B2U 644_646 static path
* added mapping
* A seals fixed hopefully
More testing tomorrow!
* run diablo py
Yours is the diabloC, I didnt have time to compare them before sleep and wanted you to have both in case
* so this should be super stable now!
* Reverted A1L & A2Y Seal Order (but kept WiZ Idea)
* Reverted Seal Order
* Added Rudi TP :)
* all loop home now
* added TP template to loop
* typo
* stashs & shrine gfx, TP counter
* removed pent_6 (many false positives looping home)
* clean up for aeon
* fixed typos
* New A2Y & C2G home pathing
* Reworked C1F 704 to avoid stuck
* Ready to test!
* fixed pather clicking outside D2R
* Tweaked Seal A
* Loot to Sealdance() & A2Y tweaks
* removed FALSE from TP fail
* Updated Readme.md
* remove env
* clean up code
* account for used tp
Co-authored-by: aeon0 <[email protected]>
Co-authored-by: WiZ <[email protected]>
* My pick up and sorc parameters
* Moved the game logic away from the main within the new GameController class.
Refactored the main to use built-in keyboard add_hotkey and wait functions instead of self-written main loop.
* Revert "My pick up and sorc parameters"
This reverts commit 0920a406
* Changed number of loot columns
* Changes as per the comments:
Handle non-existing backup file.
Removed newline at the beginning of GameController class.
Changed line splitted into multiple lines back to a single line.
* Refactored graphic debugger with similar logic to botty to now have a controller.
* Added a small comment on the graphic debugger class and fixed a now-typo on the presented menu
* Bug Fix: if pressing F11 repeatedly the main loop may crash due to self.bot not yet initialized in the game_controller.
A safe solution would be to try and catch all but that would also be an overkill as it would most likely mask exceptions that we might want to make the program crash.
Checking that self.bot is initialized should work for the time being.
* No need to re-assign the resume key within the game controlelr anymore
* Urgent 1 liner bug fix for always_on_top: name 'config' is not defined
* Bug fix, keyboard dependency on game_recovery.py
For some reason PyCharm seems to flag this import as unused while it is actually used.
* game_controller.py changes
* config and params.ini changes
* config and params.ini changes
* Added else statement to prevent double logging in case of failed restart
* Removed botty name from logging and api messages as it is redundant information.
Fixed issue with gamerecovery constructor - now it takes template finder as a parameter
* Messaging update
Corrects some messages formats and updates to allow titles.
* Messaging Update
Fixes the new messages and updates some of the others.
* mark 2
fixes some space.
* Update messenger.py
* Update generic_api.py
* Char selector after restart
* at least make it work...
* Scroll the characters if the template was not found.
TODO: Ideally we would like to understand which character is currently selected rather than assuming we'll always be using the top one.
* make it fancy
Co-authored-by: Francesco <[email protected]>
* WIP Breaking Messenger Module into something more flexible for specific apis
* Cleanup and removal of data class
* Working embeds sort of
* Simple embeds set up
* Adding image of stashing items to discord embed
* Discord Embed updates with images
death_manager and game_controller both store path to last death/chicken screenshot
Pass last screenshot path along to game_stats and discord_embeds to place in discord message
* Color adjust
* Updating filenames formobile notifications
* Create loot_screenshots folder if it doesn't exist and api=discord
* Fixing missed messenger.send calls
* UI for discord updates
* Switching to i_api and explicit functions
* Update all messenger calls to new functions
* Param name change
* Simplify discord_basic api
* Switching from interface to generic api class
* One new messenger call from master
* spacing
* fix for pathing from jamella when she walks too far down.
* updated kashya and cain as well
they kept walking into the node!
so i had to adjust it.
* Update pather.py
* My pick up and sorc parameters
* Moved the game logic away from the main within the new GameController class.
Refactored the main to use built-in keyboard add_hotkey and wait functions instead of self-written main loop.
* Revert "My pick up and sorc parameters"
This reverts commit 0920a406
* Changed number of loot columns
* Changes as per the comments:
Handle non-existing backup file.
Removed newline at the beginning of GameController class.
Changed line splitted into multiple lines back to a single line.
* Refactored graphic debugger with similar logic to botty to now have a controller.
* Added a small comment on the graphic debugger class and fixed a now-typo on the presented menu
* Bug Fix: if pressing F11 repeatedly the main loop may crash due to self.bot not yet initialized in the game_controller.
A safe solution would be to try and catch all but that would also be an overkill as it would most likely mask exceptions that we might want to make the program crash.
Checking that self.bot is initialized should work for the time being.
* No need to re-assign the resume key within the game controlelr anymore
* Urgent 1 liner bug fix for always_on_top: name 'config' is not defined
* Bug fix, keyboard dependency on game_recovery.py
For some reason PyCharm seems to flag this import as unused while it is actually used.
* Moved pywin32 dependency outside of pip (tested a clean install on two machines with this change and worked fine)
Also removed the win32api import that was causing the issue.
Both changes fix the issue that several people are expereincing on discord separately (either would fix it but both are better).
* removed 1920_1080 support
* Update ui_manager.py
Fixed bug...
* Update pather.py
fixed scale factor
* Update i_char.py
* Update game.ini
removed tp locations
* Bump version to v0.4.3
* Bump version to v0.4.3
* added working restart module
* tested and looks stable after refactor
* fixed a few things after looking at PR
* switched things back to search after testing and removed 1s delay in save_and_exit search
* fixed tab select bug where the exit game prompt was up, and also fixed max game length bug on WP use
* Update a5.py
Changing back, as not needed.
* adding requested changes
* keyboard went missing from game_recovery
* missed case where restart fails then loops infinitely
* fixed typo. all tested now
* also removed flag resets since we're recreating hm and dm
Co-authored-by: Matt <[email protected]>
* My pick up and sorc parameters
* Moved the game logic away from the main within the new GameController class.
Refactored the main to use built-in keyboard add_hotkey and wait functions instead of self-written main loop.
* Revert "My pick up and sorc parameters"
This reverts commit 0920a406
* Changed number of loot columns
* Changes as per the comments:
Handle non-existing backup file.
Removed newline at the beginning of GameController class.
Changed line splitted into multiple lines back to a single line.
* Refactored graphic debugger with similar logic to botty to now have a controller.
* Added a small comment on the graphic debugger class and fixed a now-typo on the presented menu
* Bug Fix: if pressing F11 repeatedly the main loop may crash due to self.bot not yet initialized in the game_controller.
A safe solution would be to try and catch all but that would also be an overkill as it would most likely mask exceptions that we might want to make the program crash.
Checking that self.bot is initialized should work for the time being.
* No need to re-assign the resume key within the game controlelr anymore
* Urgent 1 liner bug fix for always_on_top: name 'config' is not defined
* Act 4 - Full usage
Added Jamella as working NPC.
Added Halbu as working NPC.
Set Cain to Identify.
Added stash capabilities.
* Delete pickit.ini
* Delete a3.py
* Update pather.py
* Update npc_manager.py
* oopsy doodle
* Fixing more issues.
* Create pickit.ini
* Update a4.py
* Update a3.py
* Update pather.py
* Update pickit.ini
reverted pickit
* A1 - Completed NPCS
i hate my life.
All npcs that matter are working.
charsi repairs and tomes.
akara heals and pots.
cain identifys.
kashya resurrects.
warriv STANDS IN FRONT OF THE DAMNED STASH SO IT CAN'T BE CLICKED.
the stash .. uh stashes.
waypoints work (as long as it's the SOUTH ONE ONLY.)
#todo: fix the WP's to actively check.
* wp fix.
now it will check south and then go north.
* WP fix
* My pick up and sorc parameters
* Moved the game logic away from the main within the new GameController class.
Refactored the main to use built-in keyboard add_hotkey and wait functions instead of self-written main loop.
* Revert "My pick up and sorc parameters"
This reverts commit 0920a406
* Changed number of loot columns
* Changes as per the comments:
Handle non-existing backup file.
Removed newline at the beginning of GameController class.
Changed line splitted into multiple lines back to a single line.
* Refactored graphic debugger with similar logic to botty to now have a controller.
* Added a small comment on the graphic debugger class and fixed a now-typo on the presented menu
* Bug Fix: if pressing F11 repeatedly the main loop may crash due to self.bot not yet initialized in the game_controller.
A safe solution would be to try and catch all but that would also be an overkill as it would most likely mask exceptions that we might want to make the program crash.
Checking that self.bot is initialized should work for the time being.
* No need to re-assign the resume key within the game controlelr anymore
* Saving changes before switching repo
* Move the logic to set d2r always on top in the utils package.
* Remvoed unused dependency
* Always on top currently only works in Windows
* Removed one-time used variable
* Moved D2R window always on top to advanced_options.
Now the D2R window is set always on top only when the bot start (F11) and when it pause or F12 is pressed the D2R window (if it still exists) will be restored to its original value
* Create run.bat
run bat instead of typing commands in cmd
* Update run.bat
added exit option, fixed some typo and it will open new cmd for install/update evn then close, and run.bat will stay open
* Update run.bat
* Update run.bat
* Update src/char/sorceress/light_sorc.py
* Replaced not defined for Sorceress right_attack and left_attack by blizzard and iceblast or chain_lightning and lightning
Co-authored-by: aeon0 <[email protected]>
* My pick up and sorc parameters
* Moved the game logic away from the main within the new GameController class.
Refactored the main to use built-in keyboard add_hotkey and wait functions instead of self-written main loop.
* Revert "My pick up and sorc parameters"
This reverts commit 0920a406
* Changed number of loot columns
* Changes as per the comments:
Handle non-existing backup file.
Removed newline at the beginning of GameController class.
Changed line splitted into multiple lines back to a single line.
* Refactored graphic debugger with similar logic to botty to now have a controller.
* Added a small comment on the graphic debugger class and fixed a now-typo on the presented menu
* Bug Fix: if pressing F11 repeatedly the main loop may crash due to self.bot not yet initialized in the game_controller.
A safe solution would be to try and catch all but that would also be an overkill as it would most likely mask exceptions that we might want to make the program crash.
Checking that self.bot is initialized should work for the time being.
* No need to re-assign the resume key within the game controlelr anymore
* My pick up and sorc parameters
* Moved the game logic away from the main within the new GameController class.
Refactored the main to use built-in keyboard add_hotkey and wait functions instead of self-written main loop.
* Revert "My pick up and sorc parameters"
This reverts commit 0920a406
* Changed number of loot columns
* Changes as per the comments:
Handle non-existing backup file.
Removed newline at the beginning of GameController class.
Changed line splitted into multiple lines back to a single line.
* Refactored graphic debugger with similar logic to botty to now have a controller.
* Added a small comment on the graphic debugger class and fixed a now-typo on the presented menu
* CTA check to make sure you can BO/BC
* added a second check to make sure they even HAVE cta.
added a second check to see if BC shows after the change. if not it cancels it all out.
* now it turns off cta_Available if it's not found.,
* Update i_char.py
* Update i_char.py
* Add files via upload
* Update i_char.py
* code rework (thank to aeon)
now with 100% less hacks!
* corrected icon
bc not bo.. duh
* Update i_char.py
* moved button
- Template assets renamed to use an uniform suffix. _save_dist, _noattack
- EyeCheckData class enhanced to contain a destination static path definition that is used instead of the template name
* Adding location_stats dict to hold stats for each run
Logs Items,Deaths,Chickens,Merc Deaths, and Fails for each run
Discord message prints stats by run in table
stats.log lists run table and items by run
* clean up
Co-authored-by: aeon0 <[email protected]>
* Added Barbarian support (currently War Cry/singer only) with Horking (find item) - thanks Outburst.
* Changes as requested by aeon for pull request to be merged.
* Add location from the Run procedure and all Discord messages can leverage
* Removing old area calls
* remove logger
* Shenk Eld improvements
* Cleaning
* Cleaning
* dclone ip hunting
This is add on to pause the bot and send msg to discord if we are able to obtain the correct hot ip for dclone.
* Update for dclone ip check
using messenger instead of discord
update param for section on dclone
move the block to check for ip to after town spawn
* update for dclone for ip hunting
allow setting to disable to ip search
* nihlatak for testing
* comitting staged changes
* fixed some issues on template syntax
* merged nihlatak routine to all other files in the bot
merged nihlatak routine to all other files in the bot (next to pindle & shenk)
* make it run
* improved consistency for traverse LVL1_B & finding start at LVL2
improved consistency for traverse LVL1_B & finding start at LVL2
* further optimized pathing for LVL1 C & LVL2A
* nil update, pathing on lvl 2 fine tuning still needed
* optimized pathing lvl2
might needs some tweaks on 2_D, but otherwise fine.
* fine tune pathes lvl 2
* adapt to template matching nil arm C
* arm b
* fail save for branch a
* fail save for arm c
* arm d
* small clean up
Co-authored-by: aeon0 <[email protected]>
* removed 1920_1080 support
* Update ui_manager.py
Fixed bug...
* Update pather.py
fixed scale factor
* Update i_char.py
* Update game.ini
removed tp locations
* Bump version to v0.4.3
* Bump version to v0.4.3
* implimented and tested seperation of gold and item stash counters
* removing extra newline
* not sure how this keeps creeping back...
Co-authored-by: Matt <[email protected]>
* disable quickcast in settings until this is officially supported
* disable quickcast in settings until this is officially supported
* disable quickcast in settings until this is officially supported
* disable quickcast in settings until this is officially supported
* disable quickcast in settings until this is officially supported
* Make it possible to initialize Screen ingame
This fixes broken shopping script due to only lobby being supported right now. This also fixes standalone scipts node recorder. Both resolutions are supported
* Use game.ini conf for offset
If you have questions regarding setup or feature information/requests, please consider asking in the Discord channel: https://discord.gg/Jf3J8cuXWg. The Github issues section is for bug reports only.
**Botty Version**
What version of botty are you running? Are you using a pre-compiled release? Which one. Are you using the latest master branch? On which commit?
@@ -26,7 +29,7 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots or even better a short video of the error happening to help explain your problem. Botty often automatically makes screenshots when something goes wrong. Add these also.
**Logs**
Add the relevant part of the info.log in this section. Either upload it as a fail or copy-paste the relevant part in here.
Add the relevant part of the log/log_(time).txt in this section. Either upload it as a fail or copy-paste the relevant part in here.
Botty is an autonomous bot for Diablo II Resurrected (D2R) that uses computer vision and native Windows API input to run endgame content without kernel drivers. It's built in Python 3.10 and distributed as a PyInstaller onefile executable.
## Entry Points
Two standalone executables are built from the same source:
| Executable | Source | Purpose |
|---|---|---|
| `main.exe` | `src/main.py` | Main bot controller |
- **Game Controller**: Orchestrates the bot loop - starts/stops the bot thread, handles game recovery, tracks stats
- **Health Manager**: Polls health/mana/merc every ~1s, auto-potions, chickens when low
- **Death Manager**: Polls every ~1s for "You Have Died" screen, triggers recovery
## Core Modules
### Input Layer (`src/input_layer/`)
Native Windows API input replacement designed to evade kernel-level anti-cheat (Warden). It bypasses common Python libraries like `pyautogui` or `pynput` which can be easily detected.
| File | Purpose |
|---|---|
| `win_input.py` | Low-level `ctypes` wrappers for `SendInput`, `GetAsyncKeyState`, `GetCursorPos`. Uses standard Windows user-mode APIs. |
| `hotkey.py` | Polling-based hotkey manager that avoids global hooks. All polling intervals include micro-jitter. |
| `__init__.py` | Drop-in API that shims standard input calls with stealth timing and variable duration automatically. |
All key presses include stealth micro-pauses and variable press duration automatically.
### Screen Capture (`src/screen.py`)
Handles D2R window detection and screenshot capture via MSS library. Converts between coordinate systems: Monitor (top-left first monitor), Screen (per-monitor), Absolute (character center), Relative (template-matched).
### Image Recognition (`src/template_finder.py`)
Template matching via OpenCV (`cv2.matchTemplate`). Searches against pre-captured asset templates in `assets/templates/`. Returns match position and validity.
**Asset conventions — read before adding or recapturing any template:**
- Every `.png` under the `TEMPLATE_PATHS` roots is loaded recursively and keyed by its
**uppercased filename** (`a5_red_portal.png` → `A5_RED_PORTAL`). Keys are one flat
namespace across all roots, so filenames must be globally unique — and **never leave a
backup or scratch `.png` anywhere under `assets/`**, or it silently becomes a live
template.
- **Keep templates fully opaque (3-channel, or 4-channel with no zero-alpha pixel).**
`alpha_to_mask` only produces a mask when the image has 4 channels *and* contains a
fully transparent pixel; that mask is then passed to
`cv2.matchTemplate(..., TM_CCOEFF_NORMED, mask=...)`. **OpenCV only properly supports
masks for `TM_SQDIFF` and `TM_CCORR_NORMED`** — masked `TM_CCOEFF_NORMED` returns
unreliable scores and wandering match positions. A masked template will appear to
"work" in isolation and then fail at random. See CLAUDE.md Bug 23.
- Crop something **structurally stable and unoccluded**. Animated or partly hidden
features (a swirling portal's interior, a ring occluded by scenery) make poor anchors.
- **Validate on held-out frames**: build the crop from one capture, score it against
*other* captures, and — critically — against frames where the subject is **absent**.
A template that scores high on both is matching background, not the subject. Aim for a
clear gap straddling the 0.68 default threshold.
### UI Detection (`src/ui/`)
| File | Detects |
|---|---|
| `main_menu.py` | Main menu buttons (play, save & exit) |
| `character_select.py` | Character portraits and selection |
| `skills.py` | Skill bar state, skill availability |
| `error_screens.py` | Error dialogs, death screen |
| `loading.py` | Loading screen detection |
### Pathing (`src/pather.py`)
Pathfinding via reference template matching with relative coordinates. Each location has numbered nodes with template references. The `Location` class defines named destinations (towns, dungeons). Path definitions map (start, end) pairs to node sequences.
### Character System (`src/char/`)
Inheritance hierarchy:
```
IChar (abstract base)
├── Basic / Basic_Ranged
├── Paladin
│ ├── Hammerdin
│ └── FoHdin
├── Sorceress (base for all sorc builds)
│ ├── BlizzSorc
│ ├── BlizzorbSorc
│ ├── NovaSorc
│ ├── LightSorc
│ └── HydraSorc
├── Amazon
│ └── Javazon
├── Trapsin
├── Barbarian
├── Necro
├── Poison_Necro
├── Bone_Necro
└── Warlock
├── FireLock
├── EchoLock
└── AbyssLock
```
Each character class implements: run methods, skill usage, attack patterns, position-specific behavior.
### Run System (`src/run/`)
| File | Run |
|---|---|
| `pindle.py` | Pindle of Purity |
| `shenk_eld.py` | Shenk + Eldritch |
| `trav.py` | Travincal |
| `nihlathak.py` | Nihlathak's Temple |
| `arcane.py` | Arcane Sanctuary |
| `diablo.py` | Chaos Sanctuary + Diablo |
| `vizier.py` | Vizier (Seal Boss) |
| `baal.py` | Baal |
| `mephisto.py` | Mephisto |
| `andariel.py` | Andariel |
| `countess.py` | Countess |
| `level.py` | General leveling runs |
### Town System (`src/town/`)
`TownManager` orchestrates act-specific town routines. Each act (A1-A5) has its own module with NPC interactions, waypoint usage, and stash handling.
### Inventory (`src/inventory/`)
| File | Purpose |
|---|---|
| `belt.py` | Belt slot management, potion detection |
| `personal.py` | Personal inventory grid, item positions |
Supports variable substitution via `[variables]` sections.
### Stealth System (`src/utils/stealth.py`)
A multi-tiered approach to mimicking human behavior and evading detection:
- **Tier 1: Input Stealth**: Automatic micro-pauses (20-120ms), variable key press durations (20-200ms), and non-linear mouse paths via `input_layer`.
- **Tier 2: Behavioral Stealth**: Probabilistic "mistakes" such as clicking the wrong waypoint (2.5% chance) or skill hesitation (80-300ms) before casting.
- **Tier 3: Session Stealth**: Randomized run durations (+/-15%), AFK breaks (2-12 mins), and shuffling of farming routes between rotations.
All timing across the bot is routed through `utils.misc.wait()`, which applies Gaussian jitter to every sleep call.
Standalone tools located in the root directory for project maintenance and development:
-`asset_manager.py`: Unified interface for auditing, searching, and optimizing template assets.
-`asset_extractor.py`: Screenshot capture and AI-assisted entity cropping workflow.
-`build.py`: PyInstaller wrapper for building production executables.
-`desktop_snap.py`: Lightweight tool for capturing full desktop screenshots.
-`quest_debug.py`: Debugging interface for the questing system.
-`screenshot_tool.py`: Simple utility for taking D2R client area screenshots.
## Data Flow
```
D2R Game Window
│
▼
screen.grab() ──→ Image (numpy array)
│
▼
template_finder.search() ──→ Position + Validity
│
▼
ui_manager.detect_screen_object() ──→ Current UI state
│
▼
bot.py (state machine) ──→ Decides next action
│
▼
char.run() / pather.go_to() ──→ Movement + Interaction
│
▼
input_layer.keyboard.send() / mouse.click() ──→ SendInput API
│
▼
D2R Game Window (response captured on next grab)
```
## Build System
- **Build script**: `build.py` (PyInstaller with `--onefile --noconsole`)
- **Dependencies**: `pyproject.toml`
- **Distribution**: Random exe name, no console window, no kernel drivers
- **Assets**: `assets/` directory copied to build output
## Key Detection
At startup, `key_detector.py` reads the character's `.key`/`.keyo` file from `Saved Games/Diablo II Resurrected/` to auto-detect skill bindings and non-skill keys (inventory, show items, etc.). This eliminates manual key configuration.
Slot-to-key mapping (`CHAR_BINDING_SLOTS`):
| Slot | Config key |
|---|---|
| 41 | `show_belt` |
| 36 | `stand_still` |
| 44 | `weapon_switch` |
| 43 | `force_move` |
If params.ini and the `.keyo` file disagree, params.ini wins and a `"Keeping configured key binding"` line is logged. No disagreement = no log line.
## Coordinate Systems
| System | Origin | Used By |
|---|---|---|
| Monitor | Top-left of first monitor | `screen.grab()` |
| Screen | Per-monitor client area | UI detection |
| Absolute | Character at screen center | Pathing, target detection |
| Relative | Template match position | Inventory grid, NPC interaction |
Conversion functions in `screen.py`: `convert_monitor_to_screen()`, `convert_screen_to_abs()`, etc.
## State Machine
`bot.py` uses the `transitions` library. States and transitions are defined at `Bot.__init__` time.
That's approximately every 3 game frames (96–144ms at 25 FPS). The jitter prevents perfectly regular polling patterns from being detectable.
**Rejuv logic**:
1. Minimum 0.60s between rejuv drinks (hit recovery guard).
2. Drinks rejuv if `health ≤ take_rejuv_potion_health` OR `mana ≤ take_rejuv_potion_mana`.
3. "Double rejuv" chicken fires only if `last_drink < 8s`**AND**`health ≤ take_rejuv_potion_health`.
- The HP check is critical: mana-triggered rejuvs can legitimately fire back-to-back at full HP (Hammerdin spending mana fast). Without the HP check, false chickens occur at 99.9% HP.
## PickIt — Item Identity
`GroundItem` has two identity fields:
| Field | Formula | Purpose |
|---|---|---|
| `ID` | `slugify(Name + all as_dict() values including Amount)` | Pickit cache key; different gold amounts = different IDs |
| `UID` | `ID + screen center position` | Deduplication within a single items list; same pile at same coords = same UID |
The fail-detection in `_pick_up_item` uses `item.ID == prev.ID` for gold and `item.UID == prev.UID` for everything else. Two nearby gold piles with different amounts bypass the ID check since they produce different IDs.
`_yoink_item()` always returns `PickedUpResult.PickedUp` regardless of actual success. True pickup failures surface only through `_pick_up_item`'s same-ID/UID repeat detection. On confirmed failure, the item's `ID` is blacklisted in `_cached_pickit_items` so it isn't retried in the same session.
`Config` is a singleton (`__new__` + `data_loaded` class variable). First instantiation loads all files; subsequent calls return the same instance. Key detection runs during `__init__` after `self.char` is populated.
## Known Architectural Issues
See `IMPROVEMENTS.md` for the full list. Highest-priority unresolved items:
- **C10**: `kill_thread()` uses `PyThreadState_SetAsyncExc` (CPython private API). Can corrupt locks/GIL. Replace with `threading.Event` cooperative shutdown.
- **H12/H14**: Health/death managers use module-level globals for state. HealthManager now has a `_state_lock`; death manager does not yet.
- **M14**: `PickedUpResult` enum has a gap (values 0,1,3,4,5 — missing 2).
- **H10/H11**: `pather.py` (750 lines) and `config.py` are oversized and should be split.
**"Could not find botty conda environment"** — Run `install.bat` first.
**"D2R is not running"** — Launch D2R before running `run_botty.bat`, or set `auto_login=1` in params.ini with your Battle.net credentials.
**Bot can't see templates / everything fails** — Make sure D2R is 1280x720 windowed, not fullscreen or borderless.
**OCR not working** — `install.bat` sets up OCR automatically. Check the install output for "tesserocr: OK" or "pytesseract: OK". At least one must work.
**Check logs:** Open `log/log.txt` in Notepad for detailed output.
Simple Pixelbot for Diablo 2 Resurrected single player written in python and opencv. Botty does not work for online games and is not intended for this usage!
[**Download here**](https://github.com/aeon0/botty/releases). Got to have a [**Discord**](https://discord.gg/Jf3J8cuXWg) nowadays I guess :man_shrugging:
Pixelbot for Diablo 2 Resurrected. This project is for informational and educational purposes only.
## Installation (first time)
And please. I urge you to actually read that README! It will make your life a lot easier.
**Step 1 — Install Miniforge** (only needed once, skip if you already have conda/Miniconda)
Download and run the installer from: https://github.com/conda-forge/miniforge/releases/latest
Pick the Windows x86_64 `.exe`. Keep defaults; tick "Add to PATH" if asked.
[](https://streamable.com/67h9ay)
**Step 2 — Download Botty**
Click the green **Code** button on this GitHub page → **Download ZIP**. Extract the ZIP anywhere (e.g. `C:\botty`).
## Getting started
Botty only supports English language! Botty is currently working in 1080p or 720p and will try to adjust the D2R settings accordingly depending on your monitor res and your botty setting for "res" in the [general] section.
**Step 3 — Install dependencies**
Double-click **`install.bat`** inside the extracted folder. It will create the `botty` conda environment and install everything. This takes a few minutes the first time.
`install.bat` detects Windows 10 vs Windows 11 automatically:
- Windows 10 uses `environment-win10.yml`, `requirements-win10.txt`, and absolute mouse input.
- Windows 11 uses `environment-win11.yml`, `requirements-win11.txt`, and relative mouse input.
Botty also runs OS detection at startup and logs the selected requirements profile and mouse mode.
**Step 4 — Configure**
Open `config\params.ini` in Notepad and set at minimum:
-`[char] type=` — your build (`sorceress`, `hammerdin`, `paladin`, `trapsin`, …)
-`[routes] order=` — which bosses to farm (e.g. `run_pindle`)
- Hotkeys under your build's section to match your D2R keybinds
**Step 5 — Start**
Double-click **`run_botty.bat`**. Switch to D2R, go to the hero selection screen, then press **F11** to start. Press **F12** to stop.
> D2R must be in **English** and running at **720p** window mode.
Optional personal setup:
- Copy `.env.example` to `.env` in the repo root and set personal values there.
-`.env` is git-ignored so multiple testers can use different local values without git conflicts.
---
## Getting started & Prerequisites
- D2R needs to be in English Language,
- Botty currently works with 720p D2R window (will be adjusted automatically on auto settings)
### 1) Graphics and Gameplay Settings
All settings will automatically be set when you execute `run.exe` and press the hotkey for "Adjust D2R settings" (default f9). Note that D2R should not run during this process, or if it does you will have to restart afterwards. It is not a 100% thing, in rare cases you might still have to fiddle around with your brightness. I suggest using the "Graphic Debugger" to verify your settings. Also, there are sample screenshots of how graphics should look like: <a href="/assets/docs/sample_graphics.png">Example 1</a>, <a href="/assets/docs/sample_graphics_2.png">Example 2</a></br>
**Note**: There have been issues reported with image sharpening being truned on via the graphic card settings itself outside of D2R. Try turning it off when running botty.
All settings will automatically be set when you execute `main.exe` and press the hotkey for "Adjust D2R settings" (default **ctrl+f9**). It is not a 100% thing, in rare cases you might still have to fiddle around with your brightness. I suggest using the "Graphic Debugger" to verify your settings.
**Note**: Make sure that no other external programs adapt your graphics settings (HDR, Geforce Experience, etc.)
### 2) Supported builds
#### Soceress
You can put any skills on left and right attack and see if it works out. E.g. Glacial Spike on left attack and Blizzard or right attack.
Adjust the hotkeys in the __custom.ini__ or __param.ini__ for the `[char]` and `[sorceress]` section accordingly. Check out the param.ini section in the Readme for more details on each param.
#### Hammerdin
Your standard Hammerdin with Enigma. Dont think I have to explain much here. Same story as with sorc, set up your skills in the .ini file to what you have in D2R or the other way around. When running more than just pindle or shenk you need to start with a full tomb of tps in your inventory.
Check the documentation for **params.ini** further down. Different Sorc builds, Hammerdin, Barb, Trapsin are already implemented to different extents. It is quite straightforward to implement new classes. Give it a go if you like!
### 3) Start Location
Open up D2R and wait till you are at the hero selection screen. Make sure the char you running with is selected and will be in A5 in the respective difficulty you set in the __param.ini__ once the bot starts the game.
Open up D2R and wait till you are at the hero selection screen. Make sure the char you running with is selected and will be in any of Act 3, 4 or 5 in the respective difficulty you set in the **params.ini** once the bot starts the game.
### 4) Start Botty
Download the a prebuilt release [here](https://github.com/aeon0/botty/releases). Start `run.exe` in the botty folder. Switch (ALT+TAB) to your D2R window and press the start key (default f11). You can always force stop botty with f12.
- **Quick start**: Double-click `run_botty.bat` (auto-detects your conda env)
- **Manual**: `conda activate botty` then `python src\main.py`
## Graphic Debugger
To check if you graphic settings are good and if the bot would pick up items there is a **Graphic Debugger Mode** built in. Start botty and press F10 (Default key). This will open up a (mostly black) window. Start a game in D2R and go to A5. You should see some templates with blue circles detected and scores printed out to the console. E.g. for 720p you should see scores higher 0.8 for the templates. To check item finding, throw some items of different types on the ground. The debug window should show the item names with black background. If you throw an item on the ground that should be picked up, it will have a red circle. The console will print out the scores for each item that would be picked up. Scores should be well above 0.9 for these items.</br>
After starting, focus your D2R window and press the start key (default f11). You can always force stop botty with f12. Note: Botty will use the /nopickup command in the first game to avoid pickup up trash while traversing. This command will only allow item pickup when "show items" is active.
### Stability and safety updates
- XP OCR parsing now tolerates common OCR mistakes (`I/l/| -> 1`, `O/o -> 0`, mixed-case "experience").
- XP status math no longer throws on early-session edge cases; unavailable projections show as `n/a`.
- Routine repair is best-effort for native-teleport builds to reduce fail spirals when NPC detection is flaky.
- Repair fallback from A5 now attempts A4 from Larzuk location for better path reliability.
- Discord message sending now guards invalid embed payloads and has a plain-text fallback.
- Selling now logs item names (not just positions) and shields are protected by default.
## Development
Check out the [development.md](development.md) docu for infos on how to build from source and details of the project structure and code.
## param.ini
To ease the switch to new botty versions, you can also overwrite any of the param.ini fields in a __custom.ini__ file. When a new version of botty is released you just copy the file to the new version without having to port all your __param.ini__ changes to the new version. Example:
## BNIP Pickit
Botty NIP (BNIP) is an extended version of Njaguar's Item Parser (NIP).
BNIP is compatible with NIP (with some minor exceptions as discussed below).
There is a default nip file that comes with botty called "default.nip" inside config folder, you can add your own nip files by putting them inside config/nip with a file extension of .nip. Creating your own nip file also turns off the default.nip.
We suggest you read the NIP guide if you are unfamiliar with NIP https://github.com/blizzhackers/pickits/blob/master/NipGuide.md
### New features in BNIP
Poison damage is no longer calculated, but is now read as it's raw value. Example: Adds 5-10 poison damage over 1 seconds can be picked with `[poisonmindam] == 5 && [poisonmaxdam] == 10`. "313 Poison Damage over 5 seconds" can be picked with `[poisonmindam] == 313` or `[poisonmaxdam] == 313`.
`[allres]` is a thing now. example: `[type] == amulet && [quality] == unique # [allres] == 30` will pick up Mara's
`[idname]` can now be used for unique / set items. For example, `[idname] == thestoneofjordan` will pick up SoJ. Keep in mind, however, this forces the item to be ID'd so be careful if you want to keep unid items.
`[maxquantity]` is not supported (yet). You can leave those tags in but they'll do nothing.
If you have the discord webhook hooked up to alert you when the bot keeps items, you can suppress these alerts by adding a "@" in front of your expression.
@[type] == ring = no notification
[type] == ring = notification
## Graphic Debugger
To check if you graphic settings are good and if the bot would pick up items there is a **Graphic Debugger Mode**. Start botty and press F10 (Default key). This will open up a (mostly black) window. Start a game in D2R and go to A5. You should see some templates with blue circles detected and scores printed out to the console. To check item finding, throw some items of different types on the ground. The debug window should show the item names with black background. If you throw an item on the ground that should be picked up, it will have a red circle. The console will print out the scores for each item that would be picked up. Scores should be well above 0.9 for these items.</br>
All botty configuration files are located in the __config__ folder. To ease the switch to new botty versions, you can also overwrite any of the .ini fields in a **custom.ini** file. When a new version of botty is released you just copy the file to the new version without having to port all your **params.ini** changes to the new version. Example:
```ini
; custom.ini - overwrites 3 params in the param.ini
; custom.ini - overwrites 2 params in the params.ini
monitor | Select on which monitor D2R is running in case multiple are available
res | Resolution settings can be any of [1920_1080, 1280_720]
max_game_length_s | Botty will attempt to stop whatever its doing and try to restart a new game. Note if this fails, botty will attempt to shut down D2R and Bnet
exit_key | Pressing this key (anywhere), will force botty to shut down
resume_key | After starting the exe botty will wait for this keypress to atually start botting away
graphic_debugger_key | Pressing this key will start a debug mode to check if the color filtering works with your settings. It also includes the item search and marks items it would pick up with red circles
logger_lvl | Can be any of [info, debug] and determines how much output you see on the command line
randomize_runs | If 0, the order will always be pindle -> eldritch/shenk. If 1 the order will be random.
difficulty | Set to `normal``nightmare` or `hell` for game difficulty
custom_discord_hook | Add your own discord hook here to get messages about drops and in case botty got stuck and can not resume
info_screenshots | If 1, the bot takes a screenshot with timestamp on every stuck / chicken / timeout / inventory full event. This is 1 by Default, so remember to clean up the folder every once in a while
loot_screenshots | If 1, the bot takes a screenshot with timestamp everytime he presses show_items button and saves it to loot_screenshots folder. Remember to clear them once in a while...
| difficulty | Set to `normal``nightmare` or `hell` for game difficulty. |
| name | Name used in terminal and discord messages. |
| randomize_runs | Randomize the order of `[routes]` specified in `params.ini`. |
| saved_games_folder | [Optional] Defaults to `~\Saved Games\Diablo II Resurrected`. Used to store configuration settings for `f9` / auto settings. |
| custom_loot_message_hook | Add your message hook here (such as Discord channel) to get info about loot |
| custom_message_hook | Add your message hook here (such as Discord channel) to get info about botty status updates, discord webhook is default. |
| discord_log_chicken | Set to `1` to enable messages about bot chickens, `0` to disable. |
| discord_status_count | Number of games between discord status messages being sent. Leave empty for no status reports. |
| message_api_type | Which api to use to send botty messages. Supports "generic_api" (basic discord), or "discord" (discord embeds with images). |
| break_length_m | Break for `break_length_m` minutes every `max_runtime_before_break_m` minutes |
| max_runtime_before_break_m | ^ |
| d2r_path | [Optional] Path to `d2r.exe`. If not set, it will default to `C:\Program Files (x86)\Diablo II Resurrected\D2R.exe` when attempting to restart. |
| max_consecutive_fails | Botty will stop making games if the number of consecutive fails reaches this max value. |
| max_game_length_s | Max game length in seconds. Botty will attempt to stop whatever it's doing and try to restart a new game at specified interval. If this fails, botty will attempt to shut down D2R and Bnet. |
| restart_d2r_when_stuck | Set to `1` and botty will attempt to restart d2r in the case that botty is unable to recover its state (e.g: game crash). |
| info_screenshots | If `1`, the bot takes a screenshot with timestamp on every stuck / chicken / timeout / inventory full event. This is 1 by Default, so remember to clean up the folder every once in a while. |
| pickit_screenshots | If `1`, the bot takes a screenshot with timestamp on every ground loot snapshot taken during pickit routine, can be useful for debugging. |
| loot_screenshots | If `1`, the bot takes a screenshot with timestamp everytime he presses `show_items` button and saves it to `loot_screenshots` folder. Remember to clear them once in a while... |
type | Build type. Currently only "sorceress" or "hammerdin" is supported
casting_frames | Depending on your char and fcr you will have a specific casting frame count. Check it here: https://diablo2.diablowiki.net/Breakpoints and fill in the right number. Determines how much delay there is after each teleport for example.
slow_walk | With this set to 1 the char will have a large delay for each running action in town. Set this to 1 if you keep getting stuck during traversing town.
stash_gold | Bool value to stash gold each time when stashing items
atk_len_pindle | Attack length for hdin or number of attack sequences for sorc when fighting pindle
atk_len_eldritch | Attack length for hdin or number of attack sequences for sorc when fighting eldritch
atk_len_shenk | Attack length for hdin or number of attack sequences for sorc when fighting shenk
num_loot_columns | Number of columns in inventory used for loot (from left!). Remaining space can be used for charms
take_health_potion | Health percentage when healing potion will be used
take_mana_potion | Mana percentage when mana potion will be used. Currently belt managment is not very clever and it is safest to only pick up health pots and make sure mana reg is enough for pindle to not need mana pots.
heal_merc | Merc health percentage when giving healing potion to merc
chicken | Will chicken (leave game) when player health percentage drops below set value, range 0 to 1. Set to 0 to not chicken.
merc_chicken | Will chicken (leave game) when merc health percentage drops below set value, range 0 to 1. Set to 0 to not chicken.
show_items | Hotkey for "show items"
inventory_screen | Hotkey to open up inventory
stand_still | Hotkey for "stand still". Note this can not be the default shift key as it would interfere with the merc healing routine.
tp | Hotkey for using a town portal
belt_rows | Integer value of how many rows the char's belt has
show_belt | Hotkey for "show belt"
potion1 | Hotkey to take potion in slot 1
potion2 | Hotkey to take potion in slot 2
potion3 | Hotkey to take potion in slot 3
potion4 | Hotkey to take potion in slot 4
belt_rejuv_columns | Number of belt columns for rejuv potions
belt_hp_columns | Number of belt columns for healing potions
belt_mp_columns | Number of belt columns for mana potions
cta_available | 0: no cta available, 1: cta is available and should be used during prebuff
weapon_switch | Hotkey for "weapon switch" (only needed if cta_available=1)
battle_order | Hotkey for battle order from cta (only needed if cta_available=1)
battle_command | Hotkey for battle command from cta (only needed if cta_available=1)
| type | Build type. Currently only "sorceress" or "hammerdin" is supported |
| belt_rows | Integer value of how many rows the char's belt has |
| casting_frames | Depending on your char and fcr you will have a specific casting frame count. Check it here: https://diablo2.diablowiki.net/Breakpoints and fill in the right number. Determines how much delay there is after each teleport for example. If your system has some delay e.g. on vms, you might have to increase this value above the suggest value in the table! |
| cta_available | 0: no cta available, 1: cta is available and should be used during prebuff |
| safer_routines | Set to 1 to enable optional defensive maneuvers/etc during combat/runs at the cost of increased runtime (ex. hardcore players)
| num_loot_columns | Number of columns in inventory used for loot (from left!). Remaining space can be used for charms |
| force_move | Hotkey for "force move" |
| inventory_screen | Hotkey to open inventory |
| potion1 | Hotkey to take potion in slot 1 |
| potion2 | Hotkey to take potion in slot 2 |
| potion3 | Hotkey to take potion in slot 3 |
| potion4 | Hotkey to take potion in slot 4 |
| show_belt | Hotkey for "show belt" |
| show_items | Hotkey for "show items" |
| stand_still | Hotkey for "stand still". Note this can not be the default shift key as it would interfere with the merc healing routine |
| teleport | Hotkey for teleport (set blank if your character can't teleport) |
| town_portal | Hotkey for town portal |
| weapon_switch | Hotkey for "weapon switch" (only needed if cta_available=1) |
| battle_order | Hotkey for battle orders from cta (only needed if cta_available=1) |
| battle_command | Hotkey for battle command from cta (only needed if cta_available=1) |
| stash_gold | Bool value to stash gold each time when stashing items |
| use_merc | Set to 1 for using merc. Set to 0 for not using merc (will not revive merc when dead), default = 1 |
| atk_len_arc | Attack length for hdin/sorc fighting arcane |
| atk_len_eldritch | Attack length for hdin or number of attack sequences for sorc when fighting eldritch |
| atk_len_nihlathak | Attack length for hdin or number of attack sequences for sorc when fighting nihlathak |
| atk_len_pindle | Attack length for hdin or number of attack sequences for sorc when fighting pindle |
| atk_len_shenk | Attack length for hdin or number of attack sequences for sorc when fighting shenk |
| atk_len_trav | Attack length for hdin fighting trav (note this atk length will be applied in 4 different spots each) |
| atk_len_cs_trashmobs | Attack length for hdin or number of attack sequences when fighting Trash Mobs in Chaos Sanctuary (Diablo) |
| atk_len_diablo_deseis | Attack length for hdin or number of attack sequences when fighting Sealboss B "Lord De Seis" in Chaos Sanctuary (Diablo) |
| atk_len_diablo_infector | Attack length for hdin or number of attack sequences when fighting Sealboss C "Infector of Souls" in Chaos Sanctuary (Diablo) |
| atk_len_diablo_vizier | Attack length for hdin or number of attack sequences when fighting Sealboss A "Vizier of Chaos" in Chaos Sanctuary (Diablo) |
| atk_len_diablo | Attack length for hdin or number of attack sequences when fighting Diablo in Chaos Sanctuary |
| cs_mob_detect | If 1, it will attempt to use holy freeze from merc / conviction aura / poison to detect nearby mobs to help speed-up CS run.
| cs_town_visits | CURRENTLY BROKEN, LEAVE AT 0 FOR NOW |
| kill_cs_trash | If 1, most Trash mob packs from Chaos Sancturay Entrance to Pentagram are cleared. If 0, the run starts at Pentagram and just kills Sealbosses & Diablo (default) |
| belt_hp_columns | Number of belt columns for healing potions |
| belt_mp_columns | Number of belt columns for mana potions |
| belt_rejuv_columns | Number of belt columns for rejuv potions |
| take_health_potion | Health percentage when healing potion will be used. e.g. 0.6 = 60% helath |
| take_mana_potion | Mana percentage when mana potion will be used |
| take_rejuv_potion_health | Health percentag when rejuv potion will be used |
| take_rejuv_potion_mana | Mana percentag when rejuv potion will be used |
| heal_merc | Merc health percentage when giving healing potion to merc |
| heal_rejuv_merc | Merc health percentage when giving rejuv potion to merc |
| chicken | Will chicken (leave game) when player health percentage drops below set value, range 0 to 1. Set to 0 to not chicken. |
| merc_chicken | Will chicken (leave game) when merc health percentage drops below set value, range 0 to 1. Set to 0 to not chicken. |
| enable_no_pickup | When enabled, will type `/nopickup` into chat at game start, which can help reduce accidental pickups especially for walking characters. |
| fill_shared_stash_first | Fill stash tabs starting from right to left, filling personal stash last |
| gamble_items | List of items to gamble when stash fills with gold. Leave blank to disable. Supported items currently include circlet, ring, coronet, talon, amulet
| open_chests | Open up chests in some places. E.g. on dead ends of arcane. |
| pre_buff_every_run | 0: Will only prebuff on first run, 1: Will prebuff after each run/boss |
| runs_per_repair | Force repair after `runs_per_repair` of runs. Set to 0 to repair only when needed. |
| runs_per_stash | 0: Will only stash after intentional item pickup, 1+: Will force stash after # of runs set here (recommend at least 4 in case of accidental pickups) |
| sell_junk | 0: Discard unwanted items by dropping them on ground. 1: Discard items by selling them at vendor. |
| stash_destination | Stash tabs by priority to place the results of the transmute. Default: 3,2,1,0. (It means the result will be first placed in stash 3 untils it's full, then to stash 2, etc. 0 - personal tab)
| transmute | Add any or all of `chipped, flawed, standard, flawless` to trasmute gems of these types |
| transmute_every_x_game | How often to run transmute routine (currently transmutes flawless gems into perfect gems). Transmute routine depends on stashing routine it will only start after items stashing is done. E.g. so it could take more than X games to perform transmutes if there were no items to stash at the time. Default: 20 |
item_type | 0: Item will not be picked up. 1: Item will be picked up. 2: Item will be picked up and a discord message will be sent.
Botty includes a built-in stealth/anti-detection system configured in `[stealth]` section of params.ini. All features aim to make bot behaviour look more human.
## Support this project
Support it by contributing in any technical way, giving feedback, bug reports or submitting PRs.
|| micro_pause_min_ms / micro_pause_max_ms | Random micro-pauses between actions (simulates human hesitation) |
|| click_delay_min_ms / click_delay_max_ms | Delay between arriving at a target and clicking (50-800ms default) |
Additional stealth features include endpoint wobble (pixel-level hand tremor simulation), variable key press durations, and naturalistic mouse movement paths via a motion planner.
### Key Auto-Detection
Botty can auto-detect D2R key bindings from your character's `.key` / `.keyo` file in `~\Saved Games\Diablo II Resurrected`. Run `python tools/test_key_detector.py` to test parsing. The key detector reads the binary key file format and populates `config.char` values (inventory, potions, teleport, skills, etc.) so you don't need to set them manually in params.ini.
### Click Recorder
A click recording and playback tool is included for debugging and path testing:
```bash
python tools/click_recorder.py record # F11=start/stop, F12=exit
python tools/click_recorder.py playback # replay with human-like timing
```
Records mouse clicks with timestamps and replays them with configurable speed and repeat count.
### Asset Manager
A comprehensive tool for managing bot assets (templates). It helps with inventorying, auditing, searching, and optimizing template images.
```bash
python asset_manager.py inventory # List all templates
python asset_manager.py audit # Check for missing/low-quality templates
python asset_manager.py search <name> # Search for a specific template
|| blessed_hammer | Hotkey for Blessed Hammer. (Optional. Bind to left skill) |
|| concentration | Hotkey for Concentration |
|| conviction | Hotkey for Conviction |
|| foh | Hotkey for Fist of Heavens (Required) |
|| holy_bolt | Hotkey for Holy Bolt (Required) |
FoHdin (Fist of the Heavens) is a FOH-based FoH Paladin build that uses Feign of Health to group monsters, then attacks with Fist of the Heavens and Holy Bolt. Works well for Hell difficulty farming runs.
- Replaced with `ctypes` + `user32.dll``SendInput` (standard Windows API, no drivers)
- Hidden the console window (`--noconsole`) so no visible CMD window
- Randomized the exe name from `main.exe` to something like `whvitjz2.exe`
**Mouse movement stealth (in `input_layer/mouse_impl.py`):**
- Every `mouse.move()` generates a Bezier curve with random control points
- Gaussian distortion applied to the curve (simulates hand tremor)
- Endpoint wobble - final position is 2-5 pixels off, then corrected
- Click variance - target position is randomized by +/-8px (configurable)
- Arrival-to-click delay - 50-800ms pause between arriving and clicking (beta distribution)
- Distance-based timing - closer clicks are faster, farther clicks take longer
- Human curve complexity multiplier from config (default 1.0)
**Keyboard stealth (in `input_layer/__init__.py`):**
- Every `keyboard.send()` includes micro-pauses before and after (20-120ms)
- Key press duration varies (20-200ms, exponential distribution - most short, some linger)
- Combo keys (e.g. `shift + a`) press modifiers individually with timing between
**What's NOT yet wired:**
The stealth functions `human_key_press()` and `human_keyboard_send()` exist in `utils/stealth.py` but are called via the input layer shim automatically. Every `keyboard.send()` now gets stealth timing.
**Functions defined but NOT called from anywhere in char code:**
| Function | What it does | Where it SHOULD be used |
|---|---|---|
| `should_wrong_waypoint()` | 2.5% chance of clicking wrong waypoint | `ui/waypoint.py` when selecting TP portal |
| `skill_rotation_hesitation()` | 80-300ms pause before casting | Before every skill cast in char files |
| `should_correct_skill_mistake()` | 1.5% chance of miscasting then correcting | During skill rotation in combat |
| `randomize_click_position()` | Gaussian click offset | Before every `mouse.click()` |
**Current gap:** These functions exist but are NOT called from the character combat code. The char files call `keyboard.send("1")` directly without going through a stealth wrapper. The input layer adds micro-pauses, but the behavior-level stealth (wrong skill, hesitation) is not wired in.
**What IS wired:**
- `mouse.stealth_move()` calls `randomize_click_position()` and `endpoint_wobble()` automatically
| Run timing | WORKING | 10% skip chance, 5% AFK break, +/-15% duration variance |
| Wait jitter | WORKING | 0.85x-1.20x on all waits |
| Wrong waypoint | WORKING (2.5%) | Wired in waypoint.py |
| Skill mistakes | WORKING (1.5%) | Wired in input_layer/__init__.py |
| Skill hesitation | WORKING (80-300ms) | Wired in input_layer/__init__.py |
## Remaining Risks
1. **Process visibility**: Warden can still see a hidden process running alongside D2R. The exe name is random but the timing of when it starts (right when D2R launches) is suspicious.
2. **Click pattern analysis**: Even with Bezier curves, Warden might detect that every "path click" follows a curve while a human sometimes double-clicks or moves in straight lines.
3. **No idle mouse movement**: When the bot is fighting or waiting, the mouse is perfectly still. Humans constantly fidget with the mouse.
4. **Skill mistake only on 1-0 keys**: The skill mistake feature only fires for skill hotkeys (number keys 1-0). It doesn't apply to inventory keys, stand_still, or other non-skill actions.
## What Would Improve Stealth Further
### High Impact (Warden likely checks these)
1. **Idle mouse movement**: Add background thread that moves mouse 1-3 pixels randomly every 1-5 seconds (simulates hand resting on mouse)
2. **Occasional straight-line clicks**: 10% of the time skip the Bezier curve and click directly (humans don't always move in curves)
### Medium Impact
3. **Process parent spoofing**: Launch the bot from a different process (e.g. from Explorer instead of directly) to hide the relationship with D2R
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.