Compare commits

...
194 Commits
Author SHA1 Message Date
alex ccef0d01ea Fix: add move method to _BridgeMouse, add tkinter
CI / Install & Test (push) Waiting to run
2026-09-05 11:29:49 +00:00
alex 8e92a88fec Fix: wrap bridge_input imports in try/except
CI / Install & Test (push) Waiting to run
2026-09-05 11:24:19 +00:00
alex e2da98d474 Fix: wrap bridge_input imports in try/except for Linux
CI / Install & Test (push) Waiting to run
2026-09-05 11:18:52 +00:00
alex 34ad263ef6 Add libgl1 and libglib2.0-0 for opencv
CI / Install & Test (push) Waiting to run
2026-09-05 11:13:16 +00:00
alex d9f68bef33 Fix: move pywintypes import into Windows-only block with Linux stub
CI / Install & Test (push) Waiting to run
2026-09-05 11:07:49 +00:00
alex ecb8418df8 Add platform marker to pywin32
CI / Install & Test (push) Waiting to run
2026-09-05 09:22:45 +00:00
alex e66896e499 Skip checkout (workspace pre-populated)
CI / Install & Test (push) Waiting to run
2026-09-05 09:16:48 +00:00
alex 70d7c7dcf2 Fix: use requirements.txt, disable pip cache
CI / Install & Test (push) Waiting to run
2026-09-05 09:16:06 +00:00
alex bc5ff03970 Fix GITHUB_API_URL
CI / Install & Test (push) Waiting to run
2026-09-05 08:45:13 +00:00
alex 545fd501d5 Disable npm/pip cache (act_runner cache bug)
CI / Install & Test (push) Waiting to run
2026-09-05 08:38:57 +00:00
alex edb59dd5a4 Fix CI for ubuntu-latest
CI / Install & Test (push) Failing after 6m25s
2026-09-05 06:57:34 +00:00
3ed47ed822 fix: raise maintenance budget for cross-act routes; document Trav findings (#40)
CI / Install & Test (Windows) (push) Canceled after 0s
* fix: raise maintenance budget for cross-act routes; document Trav findings

Travincal leaves the character in Act 3, but maintenance relocates it to
Act 4 (stash at a4_tyrael_stash, repair at a4_halbu) and the next run then
needs the A3 waypoint again -- two cross-act waypoint trips per game.

That travel is not attributed to any timed step, so a FAIL> trail can show
~38s of work inside a 250s maintenance window. The 240s budget (tuned when
Pindle kept all town business in one act) was tripped 5 times in one
session, each costing a whole game. Raised to 420s.

Also documents, in HANDOVER.md:

- The open_wp failures are NOT a stale template. Scored a5_wp.png against
  all three real failure frames: 0.96-0.98 full-frame, but that match is
  the belt/mana-orb HUD false positive the a5.py comment already documents.
  Inside the real cut_skill_bar ROI it scores 0.436-0.480 against a 0.55
  threshold at scattered positions -- noise. The waypoint is genuinely not
  on screen; the character never reaches it. Recapturing would fix nothing.
  Scoring a template without the ROI the code actually uses produces a
  confident wrong answer.

- PR #39's pather abort is merged, live, and finally firing (1 abort in 21
  games, after being 0-for-152 while it looked correct).

- Keeping Trav town business in Act 3 is the real fix for both the timeouts
  and the waypoint failures. a3.py already reports can_buy_pots/can_heal/
  can_stash as True, so the Act 4 trip is not a missing capability.

- FoH vs hammerdin on the council, and the [fohdin]/[paladin] section trap:
  concentration and redemption lived in [fohdin], which hammerdin does not
  read, so both would have gone silently unbound after a respec.

Co-Authored-By: Claude Opus 5 <[email protected]>

* docs: post-mortem for the hell Travincal / hammerdin session

Narrative write-up of 29 Aug 2026: run_trav on hell, FoHdin -> hammerdin.

Outcome: health chickens 3-in-8 -> 0-in-11, battle 64-68s -> 41-60s,
failure rate 12% -> 9%, zero deaths throughout.

The document covers the four times the obvious answer was wrong:

1. Battle time did not move after the respec, which looked like failure.
   hammerdin.kill_council casts for a FIXED duration with no kill detection,
   so the clock cannot report damage -- loot proved the respec worked.

2. a5_wp.png scored 0.96-0.98 full-frame on every failure frame, which reads
   as "template fine, search broken". That match is the belt/mana-orb HUD
   false positive the a5.py comment already documents. Inside the real
   cut_skill_bar ROI it scores 0.436-0.480 against a 0.55 threshold at
   wandering positions -- noise. The waypoint was never on screen.

3. Ormus: not masked (4-channel but fully opaque, so no mask is passed), and
   not a threshold problem (in-ROI best 0.342, background level -- lowering
   the bar would recreate the Bug 4 false-positive clicks). A3 town has
   frames with ZERO pather landmarks over threshold, so the character never
   arrives. One cause, three symptoms.

4. Reverses this session's own earlier recommendation to move Trav town
   business into Act 3. That was reasoned from a3.py reporting can_buy_pots/
   can_heal/can_stash as True -- a capability check, not evidence about
   pathing. A3 is the worst-supported town in the project.

Also documents three settings that were live but inert: binds under [fohdin]
which hammerdin never reads, casting_frames=8 (unreachable -- a paladin's
fastest cast is 10 frames, so every cast was cut short), and repair_npc=
a5_larzuk, which Bug 12 set to keep repair in-act for Pindle but which forces
a cross-act trip on Trav (35.8s vs 9.0s).

And a caution on the key auto-detector: it reported the wrong stand_still
bind both before and after a live remap, consistent with a stale .keyo read.

Co-Authored-By: Claude Opus 5 <[email protected]>

* docs: correct stale hammerdin migration notes in HANDOVER (Codex review)

Addresses Codex feedback on PR #40: the handover still described the character
as needing conversion to hammerdin with both skills blank, while the same commit
verified type=hammerdin and concentration='f6' loading live.

The conflict was worse than reported. The stale "Two things only you can do"
section instructed the operator to rebind F7 -> Concentration, but F7 is
Battle Orders (CTA). Following it would have overwritten the CTA bind and cost a
large share of the life pool -- the exact problem this session was fixing.

- Replaces the migration paragraph with the verified end state, and records that
  Blessed Hammer lives on LEFT-CLICK, not an F-key (_cast_hammers puts the aura
  on the right slot, so a hammer hotkey would replace it).
- Notes that concentration/redemption had to move into [paladin]; under [fohdin]
  a hammerdin never reads them and both would be silently unbound.
- Adds the full verified bind map so F7 cannot be reassigned by mistake.
- Rewrites the operator TODOs: fire resist is superseded (Zaka + Mara took
  chickens 3-in-8 -> 0-in-11); the live items are lightning/poison resist and
  the 75% FCR breakpoint, with the casting_frames mapping (75%=11, 60%=12,
  <48%=13) so cast timing tracks any gear change.

Co-Authored-By: Claude Opus 5 <[email protected]>

* feat: baal_xp farm — game browser join, hide-and-wait state, config

- src/ui/game_browser.py: retry-wrapped join flow (Play -> Join Game tab ->
  OCR game list -> click row -> loading/InGame confirm), wait_for_in_game
- src/bot.py: 'baal_xp' state + run_baal_xp/end_run transitions;
  on_run_baal_xp: fast_save_and_exit -> hero select -> join public game ->
  corpse/nopickup/pre_buff -> walk to hide spot -> wait loop (XP every 30s,
  HP check, death recovery, InGame-miss re-anchor) -> leave -> recover to
  own game; on_end_run short-circuit straight to maintenance
- src/config.py: [baal_xp] section (enabled, game_name_filter, max_wait_s,
  xp_threshold, min_hp_pct, hide_x/y, join_timeout_s) with profile overrides
- config/params.ini: [baal_xp] section, order=run_baal_xp, run_trav
- profile paladalla: order=run_baal_xp, run_trav (profile routes override
  base config — without this the route never activates)
- stealth: daily budget cap (persisted, restart-proof), buy at A4 Jamella
  from A3 too (Ormus measured unreliable), session budget 20h

* fix: import mouse in bot.py (baal_xp hide-spot click)

* feat(stealth): hard per-day runtime cap that survives restarts, and quit D2R

session_budget_h is per PROCESS. Every restart re-rolls it, so a bot that gets
restarted -- by the user, or by the restart-on-crash path -- can run all day and
never trip it. That is exactly the signal a runtime cap is meant to remove. It
also silently failed in practice: params.ini still carried the temporary
levelling value session_budget_h = 20, which rolls 13-27h, so the live config
could not stop the bot within a day at all. Restored to 8.

Adds daily_budget_h, keyed on the CALENDAR DATE and persisted to
log/.daily_runtime.json, so restarts cannot hand out a fresh allowance.

Design points that matter:

- The rolled target is stored WITH the date. Re-rolling per process would make
  restarting a way to draw a bigger budget; once a day's target is chosen it is
  fixed until the date changes.
- Jitter subtracts ONLY. daily_budget_h is a ceiling, not an average: a
  two-sided roll on 8h could hand out 9h, which is not what "cap it at 8 hours"
  means. 8 with 0.12 jitter now runs 7.0-8.0h and never more. Verified over 300
  rolls: min 7.04h, max exactly 8.00h.
- Time before the first tick in a process is not counted, so a crash
  under-counts rather than over-counts -- the safe direction for a cap.

Also closes D2R when the cap trips (daily_budget_close_game, default on). The
check runs in on_end_game AFTER save-and-exit, so the character is at the menu
with nothing in progress and nothing is lost -- this is not the mid-game force
kill that leaves D2R unenterable. A bot parked at character select for 16h is
itself the pattern the cap exists to remove; a real player quits the game.

The stealth manifest reports daily_budget by checking the CALL SITE in bot.py,
not just the config value -- the lesson from chicken_variance and the AFK break
that was 0-for-225 while the manifest said "wired".

Co-Authored-By: Claude Opus 5 <[email protected]>

---------

Co-authored-by: alexpolo1 <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-02 10:13:46 +02:00
Alex ec6599f6d3 Merge pull request #39 from alexpolo1/fix/pather-abort-never-fired
fix: the heading-rejection abort never actually fired
2026-08-29 00:52:03 +02:00
alexpolo1andClaude Opus 5 b8a36e8231 docs: operating handover — run, diagnose, and what is still outstanding
Written so the bot can be run without me: control commands and their gotchas,
how to tell a normal break from a stuck bot, the break-length multiplier, the
health-check greps, the temporary settings to revert, and the two things only
the user can do (F7 rebind, fire resist).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-29 00:16:52 +02:00
alexpolo1andClaude Opus 5 1c19b8bdc8 fix(pather): scan for the threshold BEFORE the anti-stuck move
Review catch. The previous commit checked the counter after
find_abs_node_pos, which is still too late.

The anti-stuck block sits BEFORE the scan in the loop body:

    790  if _heading_rejects >= MAX:  abort        <- top-of-loop check
    799  if not did_force_move and now - last_move > 3.1:
    808      char.move(...)                        <- the wall-driving guess
    826  node_pos_abs = self.find_abs_node_pos(...)  <- 3rd rejection recorded
    833  if _heading_rejects >= MAX:  abort        <- too late

With two rejections banked, the moment 3.1s elapses the anti-stuck block
force-moves along last_direction — driving a wall-wedged character further in —
before the third rejection has been recorded. The exact guess this guard exists
to prevent stayed reachable on the threshold iteration.

The scan and the abort decision now both run ahead of the anti-stuck block, so
the counter is current when that decision is made.

The ordering test could not catch this: it searched for "taking a random guess"
only in the source AFTER find_abs_node_pos, while the guess block sits before
that call, so the comparison was against nothing. It now locates the anti-stuck
block explicitly and asserts BOTH the scan and the abort precede it.

Verified by falsification: restoring the scan-after-guess order fails with
"the node scan must run BEFORE the anti-stuck force-move".

That is now four times in this codebase where a check was verified by where it
sat in the source rather than by whether it ran at the deciding moment.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 22:21:06 +02:00
alexpolo1andClaude Opus 5 f43824f11d fix(pather): the heading-rejection abort never actually fired
Across 79 hell games: 4 random guesses, 0 aborts — including this sequence
inside a SINGLE traverse:

    Traverse from a5_town_start to a5_nihlathak_portal
    rejecting low-confidence A5_TOWN_1 (66.4%) for node 3
    rejecting low-confidence A5_TOWN_1 (65.9%) for node 3
    rejecting low-confidence A5_TOWN_1 (64.1%) for node 3
    taking a random guess towards (-218, 23)
    Wanted to select A5_RED_PORTAL, but could not find it

Three rejections is the threshold, so it should have aborted. The check existed,
was correctly indented inside the while loop, and sat before the anti-stuck
block — the placement I verified with a test when I added it. But the loop does
not reliably come back round to the top of the body after a rejection, so the
check was never evaluated at the moment the counter crossed.

That is why the earlier fix looked right and changed nothing: the ordering test
asserted where the check SAT in the source, not that it ever RAN.

Now checked immediately after find_abs_node_pos, in the same iteration the
counter trips, which removes the dependence on control flow entirely. The
original top-of-loop check is left in place as a second chance.

The character ended up outside the Harrogath battlements again, and the game
was lost to a 82s approach — the exact failure the abort was written to
prevent, still happening because the abort was inert.

Tests now assert the counter TRIPS at the threshold, not merely that the code is
ordered correctly.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 22:03:44 +02:00
Alex b5922b6b19 Merge pull request #38 from alexpolo1/fix/game-menu-guard
fix: the in-game ESC menu — one bug behind three separate incidents
2026-08-28 19:30:08 +02:00
alexpolo1andClaude Opus 5 a139fb3230 fix(view): pause the panel check during save_and_exit — the menu guard raced the shutdown
Follow-up to the GameMenu guard, caught by its own instrumentation: 75 menu
escapes across 15 games, ~5 per game, all clustered around game end.

save_and_exit deliberately opens the in-game ESC menu, but callers only pause
the panel check AFTER it returns:

    18:58:48.142  game | end | ok
    18:58:48.334  In-game menu open - closing it (1/6)      <- the guard
    18:58:48.487  Clicking SAVE_AND_EXIT_NO_HIGHLIGHT       <- the bot
    18:58:48.736  In-game menu open - closing it (2/6)      <- the guard again
    18:58:49.098  Health Manager is now paused              <- too late

Games still completed, so this was noise rather than breakage — but it is a
race, and the guard was pressing esc while the shutdown clicked the menu.

save_and_exit now pauses the panel check for the whole sequence and restores it
in a finally, so it cannot leak the paused state if save/exit raises.

The guard itself is working: 15 games, 0 failures, 0 portal failures, and the
loot-filter/Chronicle/Options incidents have not recurred.

MANA> instrumentation has also settled #23 — see the issue.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 19:26:45 +02:00
alexpolo1andClaude Opus 5 914a107a72 fix(health): detect and close the in-game ESC menu — one bug behind three incidents
The bot was found sitting in OPTIONS -> VIDEO with a "settings have changed,
apply or discard?" modal. Discarding revealed the cause: the in-game ESC menu
carries these buttons at SCREEN CENTRE.

    OPTIONS / SAVE AND EXIT / RETURN TO GAME / LOOT FILTER / CHRONICLE

A stray esc opens the menu and the bot's next movement click lands on one of
them. The HUD mask deliberately leaves screen centre clickable, so nothing
stops it.

That single mechanism explains three incidents previously treated as separate:

  - the LOOT FILTER being toggled (blamed on vigor=f4, which was a real but
    different bug)
  - CHRONICLE blanking every template match for a whole run, costing a 66s
    click_red_portal failure
  - the video OPTIONS being opened and a setting changed, which could have
    altered resolution and broken every template in the project

The menu has NO close button, so the CenterPanel guard (CLOSE_PANEL_2) cannot
see it. Bug 31 warned about precisely this: "just send esc is WORSE: with
nothing open, esc opens the GAME MENU, which LeftPanel/RightPanel do not
match".

SAVE_AND_EXIT_NO_HIGHLIGHT scores 1.000 on the menu frame and does not match a
normal town frame, so detection is unambiguous. Escaped without counting toward
a chicken, bounded like the waypoint and centred-panel cases.

Also adds MANA> threshold-crossing logging for #23. That issue measured "1 mana
potion per game, never 2" over 70 games but was undecidable, because mana is
only logged when a potion is DRUNK — a second dip that failed to trigger looks
identical to mana never dipping twice. Every crossing is now logged with the
gate state, edge-triggered so it fires once per crossing rather than per poll.

NOT fixed here: whatever sends the stray esc. This is the safety net; the source
is still unknown.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 18:53:15 +02:00
Alex 9c06cd8474 Merge pull request #37 from alexpolo1/release/v0.10.0
release: v0.10.0
2026-08-28 17:05:38 +02:00
alexpolo1andClaude Opus 5 e17b8c6c94 release: v0.10.0 — the stealth subsystem actually runs
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]>
2026-08-28 17:04:38 +02:00
Alex 3597c01b17 Merge pull request #36 from alexpolo1/fix/walk-hud-guard
fix: walk() had the same unguarded HUD click as move()
2026-08-28 17:04:11 +02:00
alexpolo1andClaude Opus 5 ee39aa5dbf test: scope the HUD-click invariant to every movement method, not just move()
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]>
2026-08-28 16:57:01 +02:00
alexpolo1andClaude Opus 5 e6ba9ad830 fix(char): guard walk() against HUD clicks too, and set a >=5h session budget
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]>
2026-08-28 16:54:40 +02:00
Alex 48f46fba7f Merge pull request #35 from alexpolo1/fix/hud-click-guard
fix: move() clicked into the HUD and toggled the loot filter
2026-08-28 15:09:53 +02:00
alexpolo1andClaude Opus 5 bc398c471f fix(char): apply move jitter BEFORE the HUD guard, not after
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]>
2026-08-28 15:04:01 +02:00
alexpolo1andClaude Opus 5 91e201c5f6 fix(char): move() clicked into the HUD and toggled the loot filter
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]>
2026-08-28 14:57:18 +02:00
Alex 74c3050163 Merge pull request #31 from alexpolo1/feat/prices
prices: diablo2.io scraper + discord report + FG date fix
2026-08-28 14:47:10 +02:00
Alex ce394c2cb6 Merge pull request #34 from alexpolo1/fix/restart-loop-cap
fix: bound the restart loop, and cap the real AFK idle at ~15m
2026-08-28 14:46:54 +02:00
Alex 0ce92b4e7c Merge pull request #32 from alexpolo1/tools/spellbook-mapper
tools: spellbook mapper — read skill binds from the game and check them against config
2026-08-28 14:46:40 +02:00
alexpolo1andClaude Opus 5 3942ad4cf5 docs: how to tell an AFK break from a stuck bot, and why break lengths lie
Two things that cost real time today and are invisible from the screen.

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

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

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-28 14:42:12 +02:00
alexpolo1andClaude Opus 5 913eeeff75 fix(stealth): cap the real AFK idle at ~15m, not ~26m
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]>
2026-08-28 14:36:33 +02:00
alexpolo1andClaude Opus 5 dfd64b8f4d fix(bot): bound the restart loop — a persistent failure restarted forever
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]>
2026-08-28 14:35:54 +02:00
Alex a7e9f4e50f Merge pull request #33 from alexpolo1/feat/stealth-wiring
fix: 8 commits PR #29 left behind, plus the AFK break that never fired
2026-08-28 14:15:50 +02:00
alexpolo1andClaude Opus 5 1f440cfed2 fix(health): detect and close CENTRED panels — Chronicle blocked every template match
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]>
2026-08-28 13:28:57 +02:00
alexpolo1andClaude Opus 5 c8e63bc9fa fix(stealth): roll the AFK break on the path a single-route rotation actually takes
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]>
2026-08-28 12:46:41 +02:00
alexpolo1andClaude Opus 5 fd889a4df7 tools(spellbook): detect grid state, template-match the hotkey labels
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]>
2026-08-28 10:42:36 +02:00
alexpolo1andClaude Opus 5 2feb13f44a tools: add spellbook mapper — read skill binds from the game, compare to config
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]>
2026-08-28 10:27:07 +02:00
alexpolo1 5b386391df prices: diablo2.io scraper + discord report + FG date fix + refreshed data
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
2026-08-28 10:19:02 +02:00
alexpolo1andClaude Opus 5 963b19dbe5 fix(control): accepted hermes connections were non-blocking — the root of every socket timeout
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]>
2026-08-28 06:50:59 +02:00
alexpolo1andClaude Opus 5 93185cb93c fix(control): make hermes 'start' idempotent and report pause state in status
'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]>
2026-08-28 06:40:19 +02:00
alexpolo1andClaude Opus 5 2162046dac fix(pather): abort after repeated heading rejections instead of guessing
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]>
2026-08-28 01:25:55 +02:00
alexpolo1andClaude Opus 5 55075d2c23 fix(town): never fall back to Qual-Kehk when Tyrael is unreachable
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]>
2026-08-28 01:19:27 +02:00
alexpolo1andClaude Opus 5 83c211bd5f fix(pickit): stealth pickup-skip spun on one item until the phase timed out
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]>
2026-08-28 01:11:49 +02:00
alexpolo1andClaude Opus 5 31dacab9e3 test: pin the act-margin invariant for BOTH act-deciding functions
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]>
2026-08-28 01:03:01 +02:00
alexpolo1andClaude Opus 5 0a4575ea6a fix(town): apply the Bug 28 act-margin rule to wait_for_town_spawn too
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]>
2026-08-28 00:59:13 +02:00
alexpolo1andClaude Opus 5 f0f50a9332 perf(town): stop pending sales from triggering the A5->A4 vendor trip
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]>
2026-08-28 00:54:49 +02:00
Alex 2966ba4f7b Merge pull request #29 from alexpolo1/feat/stealth-wiring
fix(stealth): make the stealth subsystem actually execute
2026-08-28 00:53:11 +02:00
alexpolo1andClaude Opus 5 8aa150d6a3 test: use a neutral character name in the personality seed test
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]>
2026-08-28 00:47:23 +02:00
alexpolo1andClaude Opus 5 371abafa35 fix(stealth): actually wire the per-game chicken roll, and make the manifest check call sites
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]>
2026-08-28 00:43:01 +02:00
alexpolo1andClaude Opus 5 00e047db98 feat(stealth): session rhythm, idle behaviour, and unproductive actions
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]>
2026-08-28 00:35:31 +02:00
alexpolo1andClaude Opus 5 4bdc5b90db fix(input): an unbound skill key is a no-op, not a thread-killing ValueError
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]>
2026-08-28 00:18:39 +02:00
alexpolo1andClaude Opus 5 5211564df4 fix(stealth): manifest reports a 0% behaviour as disabled, not wired
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]>
2026-08-28 00:17:20 +02:00
alexpolo1andClaude Opus 5 e42211231e fix(paladin): stop pressing unbound skill keys — F4 was toggling the loot filter
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]>
2026-08-28 00:15:33 +02:00
alexpolo1andClaude Opus 5 c3557678d5 feat(stealth): implement the four-phase remediation end to end
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]>
2026-08-28 00:02:55 +02:00
alexpolo1andClaude Opus 5 da83f71bef fix(stealth): make configured stealth behaviour actually run
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]>
2026-08-27 23:52:45 +02:00
Alex 3d153e67f5 Merge pull request #28 from alexpolo1/stable
fix: A5 waypoint HUD false-positive, safe merc-panel dismiss, Tyrael for merc resurrect
2026-08-27 22:29:49 +02:00
alexpolo1 f70e118f88 Merge remote-tracking branch 'origin/main' into stable 2026-08-27 22:25:22 +02:00
alexpolo1andClaude Opus 5 11186e2c7d fix(town): A5 waypoint HUD false-positive, safe merc-panel dismiss, Tyrael for resurrect
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]>
2026-08-27 21:37:33 +02:00
alexpolo1andClaude Opus 5 6ac1105fc7 fix(merc): always dismiss the panel the merc check opens (CHRONICLE stayed up all game)
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]>
2026-08-27 20:17:38 +02:00
Alex cad7d898a4 Merge pull request #27 from alexpolo1/stable
ci: don't fail the build on coverage artifact storage quota
2026-08-27 19:40:49 +02:00
alexpolo1 4de8f6192e Merge remote-tracking branch 'origin/main' into stable 2026-08-27 19:39:56 +02:00
alexpolo1andClaude Opus 5 7763afdcb8 ci: don't fail the build when the coverage artifact upload hits the storage quota
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]>
2026-08-27 19:22:35 +02:00
Alex c0e5241eb9 Merge pull request #26 from alexpolo1/stable
run_pindle repair: 100% failure to ~8%, plus run timing instrumentation
2026-08-27 19:13:50 +02:00
alexpolo1andClaude Opus 5 831e0d9319 docs: refresh post-mortem commit hashes after the pre-PR history rewrite
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]>
2026-08-27 19:10:46 +02:00
alexpolo1andClaude Opus 5 489f779849 merge: bring origin/main into stable ahead of the PR
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]>
2026-08-27 19:10:19 +02:00
alexpolo1andClaude Opus 5 402c768fc8 docs: add the run_pindle post-mortem (26-27 Aug 2026)
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]>
2026-08-27 19:03:26 +02:00
alexpolo1andClaude Opus 5 2e7517f9b8 docs: how to read FAIL> records and tune runs from the timing digest
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]>
2026-08-27 18:22:31 +02:00
alexpolo1andClaude Opus 5 9325c9cc63 feat(discord): 2-hourly timing + failure digest, and self-contained FAIL> records
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]>
2026-08-27 18:21:36 +02:00
alexpolo1andClaude Opus 5 8da4c8eb9d feat(log): time every phase and fold stealth into one spawn-to-loot timeline
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]>
2026-08-27 18:07:33 +02:00
alexpolo1andClaude Opus 5 f01610e53f feat(log): constant town timeline — every maintenance step start/ok/skip/fail
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]>
2026-08-27 17:38:33 +02:00
alexpolo1andClaude Opus 5 e976749910 fix(npc): raise grid-sweep name-tag threshold so it stops clicking noise
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]>
2026-08-27 17:12:17 +02:00
alexpolo1andClaude Opus 5 dfacb12393 fix(main): catch EOFError on shutdown so a clean exit stops logging a fake crash
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]>
2026-08-27 16:50:46 +02:00
alexpolo1andClaude Opus 5 2402c18e06 fix(inventory): stash protected items the pickit rejected instead of hoarding them
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]>
2026-08-27 16:04:26 +02:00
alexpolo1andClaude Opus 5 4a5459ab15 config: let the pickit decide on charms, freeing inventory slots for the cube
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]>
2026-08-27 15:56:53 +02:00
alexpolo1andClaude Opus 5 a213509a03 fix(a5): stop burning two 4s timeouts before walking to the waypoint
"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]>
2026-08-27 15:38:57 +02:00
alexpolo1andClaude Opus 5 938554e622 fix(maintenance): top up ID scrolls at 8 remaining instead of 2
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]>
2026-08-27 08:01:40 +02:00
alexpolo1andClaude Opus 5 b7ce002e4e config: stop blocking shield sales — 130 blocked sells clogged the pack
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]>
2026-08-27 06:40:15 +02:00
alexpolo1andClaude Opus 5 fa72da56e3 fix(merc): stop re-hunting an undetectable resurrect NPC every game (~80s each)
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]>
2026-08-27 06:26:01 +02:00
alexpolo1andClaude Opus 5 abe0a54849 fix(town): don't decide the act on a 1.7pp template margin — A5 pathing ran inside Act 4
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]>
2026-08-27 06:04:39 +02:00
alexpolo1andClaude Opus 5 4db82cfcd6 fix(pindle): don't hard-fail the portal retry when no town marker is visible
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]>
2026-08-26 20:58:58 +02:00
alexpolo1andClaude Opus 5 97703acf85 config: disable gem transmuting; document the live pickit set and keep/id/sell interaction
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]>
2026-08-26 19:04:50 +02:00
alexpolo1andClaude Opus 5 5df9ff4732 fix(transmute): gem conversion ran in the PERSONAL tab — tab click missed the tab bar
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]>
2026-08-26 18:26:55 +02:00
alexpolo1andClaude Opus 5 98e12c0e00 fix(pindle): restore Pindle runs — portal template, fake success, wall-walk, false chicken
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]>
2026-08-26 12:30:32 +02:00
Alex e9b6c71607 Merge pull request #25 from alexpolo1/baalxp
fix(skill_preflight): add fohdin build support alongside hammerdin
2026-08-26 06:03:27 +02:00
alexpolo1 c26f249268 fix(input): snap cursor to exact target before click (SetCursorPos can land 1-2px off)
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.
2026-08-25 00:26:44 +02:00
alexpolo1 a014fc538e fix(baal_xp): harden handler — real DeathManager, stealth hide-click,
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)
2026-08-24 23:13:00 +02:00
alexpolo1 09bfd330e6 feat(baal_xp): join public Baal games, hide, collect XP, leave
- 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.
2026-08-24 22:30:04 +02:00
alexpolo1andClaude Opus 5 ce930f2a76 feat(tools): add launch_d2r — direct exe launch with a Play-button fallback
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]>
2026-08-23 08:30:39 +02:00
alexpolo1 c8a6657b0a feat(cold_plains): map-based walk-back + self-sustain for no-TP levelling chars
- 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
2026-08-22 20:41:12 +02:00
alexpolo1andClaude Opus 5 1d8a6657e2 fix(melee_hunt): make move/scan/attack a fixed rotation, not a fallback
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]>
2026-08-22 20:09:20 +02:00
alexpolo1andClaude Opus 5 81c8d65375 fix(set_controls_keyo): also cover builds without skill_preflight rules
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]>
2026-08-22 19:44:46 +02:00
alexpolo1andClaude Opus 5 28b7c210d8 fix(set_controls_keyo): verify the active build's skills, not a hardcoded list
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]>
2026-08-22 19:44:46 +02:00
alexpolo1andClaude Opus 5 aa48c29df8 fix(set_controls_keyo): select .keyo by char_name, not the profile name
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]>
2026-08-22 19:44:46 +02:00
alexpolo1andClaude Opus 5 f1b0f6439c feat(tools): add melee_hunt — motion-based monster scan with staff attacks
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]>
2026-08-22 19:20:58 +02:00
alexpolo1 cf4c557a26 docs(bot): clarify walk-back town marker semantics in _walk_back_to_town 2026-08-22 14:51:07 +02:00
alexpolo1 7a7950e752 feat(bot): support no-TP levelling chars (cold-plains-only grinder)
- 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.
2026-08-22 14:11:05 +02:00
alexpolo1andClaude Opus 5 fc9a7bfae1 fix(misc): make focus_d2r_window actually win the foreground
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]>
2026-08-22 09:20:45 +02:00
alexpolo1andClaude Opus 5 fd82f0ff05 feat(tools): add firebolt_roam — standalone roam-and-cast for levelling
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]>
2026-08-22 09:00:55 +02:00
alexpolo1andClaude Opus 5 394aca35f4 fix(messages): don't let an optional Discord import take down the whole bot
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]>
2026-08-22 09:00:55 +02:00
alexpolo1andClaude Opus 5 e091a3cca4 feat(run): add cold_plains — roam an outdoor area and kill with one skill
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]>
2026-08-22 08:47:16 +02:00
alexpolo1 f69d5c01c4 fix(skill_preflight): add fohdin build support alongside hammerdin
- Changed char_type check from == to in (hammerdin, fohdin)
- Uses getattr(config_instance, char_type, {}) to load correct build config
- Enables proper skill preflight validation for FOH paladin builds
2026-08-08 13:48:02 +02:00
alexpolo1 14f5876524 fix: recapture A5 waypoint template (112x125, 24KB vs old 201x120, 68KB)
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.
2026-08-08 11:28:46 +02:00
alexpolo1 9e4ef6721a fix(test): skip log analyzer tests that require historical failure data on CI 2026-08-08 10:05:08 +02:00
alexpolo1 c04c60ead7 release: v0.9.1 — production cleanup
- 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
2026-08-08 09:59:31 +02:00
alexpolo1 35aa13473e Remove personal profile references from repo files
- config/params.ini: name=fistman -> name=profile1
- config/active_profile.txt: burr -> profile1
- src/ui/character_select.py: genericize OCR comment
- Add generic profile1 and profile2 profile configs
2026-08-07 20:59:52 +02:00
alexpolo1 e03f866fbb fix: improve resilience for pindle, A5 WP, stash, NPC, and Diablo battle
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
2026-08-07 16:21:07 +02:00
alexpolo1 968c75fefd feat: self-healing test framework with log analysis and auto-fixing
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.
2026-08-07 15:25:17 +02:00
alexpolo1 7839b4ed82 fix: replace GetAsyncKeyState polling with WH_KEYBOARD_LL hook
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
2026-08-07 14:59:22 +02:00
alexpolo1 75e88dbdc2 fix: don't skip bot startup when SetWindowPos is denied
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.
2026-08-07 14:46:47 +02:00
alexpolo1 c5d9fe4541 fix: catch SetWindowPos access denied in all window functions
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.
2026-08-07 14:33:07 +02:00
alexpolo1 648dc1b25c fix: add missing pywintypes import in misc.py
The SetWindowPos try/except catches pywintypes.error but the
module was never imported, causing NameError.
2026-08-07 14:30:30 +02:00
alexpolo1 00e759ec0a fix: catch SetWindowPos access denied when D2R runs elevated
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.
2026-08-07 14:15:05 +02:00
alexpolo1 6b044a17ea fix: add direct WP scan before pathing in A5 open_wp (#4)
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.
2026-08-07 13:48:41 +02:00
alexpolo1 8f9185cde3 test: add 11 tests for install.bat file references and version consistency; fix pyproject.toml version 0.8.1->0.8.4 2026-08-07 09:54:24 +02:00
alexpolo1 02e9f7856a test: add 36 tests for health_manager, death_manager, ui.meters, config, inventory.belt, game_controller, game_recovery, item.pickit, char 2026-08-07 09:12:21 +02:00
alexpolo1 15524e1608 ci: use 3-channel image for botty OCR test 2026-08-07 08:31:34 +02:00
alexpolo1 6bcfa1af35 ci: disable crop_pad in botty OCR test (grayscale image) 2026-08-07 08:28:10 +02:00
alexpolo1 523cb54e46 ci: fix f-string escape in botty OCR test 2026-08-07 08:24:47 +02:00
alexpolo1 7431807ee5 ci: set pytesseract.tesseract_cmd directly in test code 2026-08-07 08:22:48 +02:00
alexpolo1 40ee587fb0 ci: set PYTESSERACT_TESSERACT_CMD for choco-installed Tesseract 2026-08-07 08:18:52 +02:00
alexpolo1 d1ba551832 ci: install Tesseract and test OCR (pytesseract + botty ocr module) 2026-08-07 08:16:51 +02:00
alexpolo1 3f8e08296e ci: fix module names (run.diablo, run.pindle) and add more run imports 2026-08-07 08:10:06 +02:00
alexpolo1 4e730e0c5b ci: add core import verification and botty module import tests 2026-08-07 08:06:26 +02:00
alexpolo1 cef59a7df2 ci: fix coverage step, combine coverage run+xml, add artifact upload 2026-08-07 07:53:50 +02:00
alexpolo1 78f9d07545 fix(ci): combine coverage into test step so data persists 2026-08-07 07:10:40 +02:00
alexpolo1 eeb620696b fix(ci): add pywin32 to requirements.txt for pip installs 2026-08-07 07:06:47 +02:00
alexpolo1 0c246245c4 fix(ci): replace conda with pip, remove smoke test, build only on tags 2026-08-07 07:04:18 +02:00
alexpolo1andClaude Opus 4.8 3d12a75b72 feat(install): actionable error messages instead of dead ends
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]>
2026-08-06 22:29:23 +02:00
alexpolo1andClaude Opus 4.8 7cb15837d4 fix(install): window closed instantly on success, hiding the result
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]>
2026-08-06 21:22:36 +02:00
alexpolo1andClaude Opus 4.8 ab3f5633fc fix(test): bat-file tests failed on the stable branch
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]>
2026-08-06 19:08:43 +02:00
alexpolo1andClaude Opus 4.8 81f160d400 docs: record Bug 22 (tesserocr libdeflate DLL chain) in bug reference
Includes the pefile import-chain technique that found it, since WinError
126 names the importing DLL and never the missing dependency.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-06 19:00:01 +02:00
alexpolo1andClaude Opus 4.8 7fd0555468 fix(install): permanent fix for tesserocr DLL load failure
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]>
2026-08-06 18:59:31 +02:00
alexpolo1andClaude Opus 4.8 b627172f1e chore: gitignore install_log.txt
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]>
2026-08-05 22:05:43 +02:00
alexpolo1andClaude Opus 4.8 512f63e0e5 docs: record install.bat bugs 20 and 21 in the permanent bug reference
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]>
2026-08-05 22:00:59 +02:00
alexpolo1andClaude Opus 4.8 6a07865f43 docs(install): record measured Tesseract /D= behaviour
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]>
2026-08-05 21:59:38 +02:00
alexpolo1andClaude Opus 4.8 a539d3a236 fix(install): unescaped parens killed the conda direct-download path
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]>
2026-08-05 21:51:23 +02:00
alexpolo1andClaude Opus 4.8 710c1c709a fix(install): batch parse error aborted install.bat at OCR stage
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]>
2026-08-05 21:38:23 +02:00
alexpolo1andClaude Opus 4.8 61a88d2968 fix(install): make Tesseract/OCR setup work on clean Win10/Win11
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]>
2026-08-05 21:28:02 +02:00
Alex c1367dbfbd Merge pull request #3 from alexpolo1/fix/install-conda-user-scope
fix(install): install conda without admin + verify git download
2026-08-05 21:54:52 +02:00
alexpolo1andClaude Opus 4.8 a2e2acfbde fix(install): install conda without admin + verify git download
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]>
2026-08-05 17:07:50 +02:00
alexpolo1andClaude Sonnet 4.6 e8a9cdc6cd fix: resolve 'module object is not callable' in item parser
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]>
2026-08-01 12:05:23 +02:00
alexpolo1andClaude Sonnet 4.6 47e9344fd5 test: add regression tests for NPC interaction and TownManager
Adds 41 tests covering the highest-risk production paths:

test/npc/test_npc_manager.py (14 tests):
  - _action_btns_visible: missing NPC, white found, nothing found
  - open_npc_menu: fast path (Bug 6), name-tag in ROI (Bug 3),
    ROI gate blocks outside-ROI click (Bug 7), pose-distance gate (Bug 4),
    body+pose confirmed triggers click, timeout returns False
  - press_npc_btn: white/blue/grayscale fallback chain, red=cannot-afford, nothing found

test/town/test_town_manager.py (27 tests):
  - get_act_from_location: all five acts + sub-locations, bad input
  - identify: True never returned (Bug 1), A5 fallback (Bug 5),
    A5-also-fails returns new_loc, _cain_failed_acts skip
  - open_wp: budget increment, exhaustion fast-path (Bug 9), reset, success
  - buy_consumables: unknown loc, trade-menu failure (Bug 10), success, no-need skip
  - stash: current-act delegation, A5 travel when act can't stash

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-08-01 10:21:51 +02:00
alexpolo1andClaude Sonnet 4.6 0176f66a1c fix: add pyinstaller to requirements.txt so build job can find it
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]>
2026-08-01 09:38:31 +02:00
alexpolo1andClaude Sonnet 4.6 38c2bbdea6 fix: add coverage and pytest deps to requirements.txt for CI
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]>
2026-08-01 09:25:25 +02:00
alexpolo1andClaude Sonnet 4.6 682580e44c fix: use python -m coverage in CI (bare 'coverage' not on PATH in conda env)
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]>
2026-08-01 09:21:08 +02:00
alexpolo1andClaude Sonnet 4.6 34cc75c9e4 fix: downgrade async-timeout to 5.0.1 (5.1.0 does not exist on PyPI)
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]>
2026-08-01 09:15:08 +02:00
alexpolo1andClaude Sonnet 4.6 e62272b743 fix: use -1.0 budget in time_budget test to survive Windows clock resolution
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]>
2026-08-01 09:11:27 +02:00
alexpolo1 e797445cb9 fix: remove stale tesserocr_source submodule (not needed by install.bat) 2026-08-01 08:51:31 +02:00
alexpolo1 5d5b03ca76 production: prepare for clean install test
- requirements.txt: guard keyboard/mouse as Windows-only (sys_platform=win32)
- input_layer/__init__.py: add Docker bridge_input support (gated by os.name)
- main.py: add Docker mode detection, graceful fallback for non-Windows
- pather.py: add A5_NIHLATHAK_PORTAL path nodes for Pindle/Nihlathak TP-back recovery
- run/pindle.py: add temple entry verification, retry logic for red portal clicks
- transmute/transmute.py: ensure GEMS convert panel is empty before loading next batch
- .gitignore: ignore Docker/dev-only files (Dockerfile, docker-compose, botty_next, bridge_*)
2026-08-01 08:49:49 +02:00
FiskenPoul f5ea29489e Fix runaway gem transmute loop: verify stack presence before every click
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.
2026-07-13 18:44:11 +02:00
FiskenPoul abb06066d5 CTA pre-buff: poll for Battle Command instead of racing a fixed wait
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.
2026-07-13 13:35:48 +02:00
FiskenPoul 05df847933 Make protect_charms_from_sell actually configurable
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.
2026-07-12 18:29:58 +02:00
FiskenPoul 0eca544d2e stash_all_items: try other stash tabs before giving up on a transfer failure
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.
2026-07-12 12:56:46 +02:00
FiskenPoul 737636b644 Make pickup-drought health check window configurable (pickup_drought_window)
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.
2026-07-11 22:39:38 +02:00
alexpolo1andClaude Opus 4.8 17bcb95c0a fix: make the Diablo (Chaos Sanctuary) run complete end-to-end
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]>
2026-06-24 15:58:13 +02:00
alexpolo1 59b851b107 fix: stuck in town recovery improvements
- 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
2026-06-24 10:47:34 +02:00
alexpolo1andClaude Opus 4.8 0e90b36e14 fix: bound A5 open_wp so a missed waypoint never strands the bot in town
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]>
2026-06-24 08:39:20 +02:00
alexpolo1 71109e9532 Merge fix stash branch into main 2026-06-21 19:04:54 +02:00
alexpolo1 6012934e66 Add chipped gem conversion runner 2026-06-21 16:06:58 +02:00
alexpolo1 48d8445c05 Bootstrap visual test harness and fix GEMS transmute flow 2026-06-21 15:04:12 +02:00
alexpolo1andClaude Sonnet 4.6 44794fba67 logs: hard size cap on log.txt + kill install-log progress-bar balloon
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]>
2026-06-20 18:56:25 +02:00
alexpolo1andClaude Sonnet 4.6 c1997b438e build: use --conda_path arg instead of hardcoded user path
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]>
2026-06-20 00:59:20 +02:00
alexpolo1andClaude Sonnet 4.6 aa2f52f702 coverage: scope to src/, ignore missing sources in xml report
'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]>
2026-06-20 00:49:18 +02:00
alexpolo1andClaude Sonnet 4.6 0fbe480f38 Fix two pre-existing CI test failures
- 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]>
2026-06-20 00:44:25 +02:00
alexpolo1andClaude Sonnet 4.6 de2943fe7a Add run_install_capture.bat — capture install output to install_log.txt
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]>
2026-06-20 00:31:08 +02:00
alexpolo1andClaude Sonnet 4.6 41c230ad9c install.bat: fix discord import check for Windows SSL cert store bug
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]>
2026-06-20 00:19:24 +02:00
alexpolo1andClaude Sonnet 4.6 a4e3ff0e2f install.bat: fix Win11 detection, stale errorlevel in blocks, remove pause
- 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]>
2026-06-20 00:14:17 +02:00
alexpolo1andClaude Sonnet 4.6 6625c1e1d5 install.bat: use winget as primary Miniforge install method
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]>
2026-06-19 23:54:51 +02:00
alexpolo1andClaude Sonnet 4.6 fc8ef97a7a install.bat: add %LOCALAPPDATA%\miniforge3 to conda search paths
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]>
2026-06-19 23:51:41 +02:00
alexpolo1andClaude Sonnet 4.6 1a58006188 install.bat: fix Miniforge bootstrap — curl download + start /wait for installer
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]>
2026-06-19 23:46:24 +02:00
alexpolo1andClaude Sonnet 4.6 e18b81200f install.bat: fix Miniforge silent install — drop /D= to avoid spaces-in-path failure
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]>
2026-06-19 22:50:16 +02:00
alexpolo1andClaude Sonnet 4.6 03da8783b6 install.bat: auto-download and install Miniforge3 if conda not found
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]>
2026-06-19 22:44:31 +02:00
alexpolo1andClaude Sonnet 4.6 26ecb826ec fix tesserocr: bundle MSVC tesseract52.dll + conda-forge .pyd instead of broken DLL name aliases
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]>
2026-06-19 21:56:48 +02:00
alexpolo1andClaude Sonnet 4.6 3ed837e1ee testbed: add count to help docstring
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]>
2026-06-19 21:34:59 +02:00
alexpolo1andClaude Sonnet 4.6 175f8e342b install.bat: dual-backend OCR that works on Win10 and Win11 out of the box
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]>
2026-06-19 21:29:03 +02:00
alexpolo1andClaude Sonnet 4.6 c566519cd8 Revert misguided install.bat OCR commits
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]>
2026-06-19 21:27:17 +02:00
alexpolo1andClaude Sonnet 4.6 e712076f08 install.bat: make OCR setup work on Win10 and Win11
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]>
2026-06-19 21:10:33 +02:00
alexpolo1andClaude Sonnet 4.6 63d0a334cb install.bat: fix OCR setup — verify tesseract exe, drop broken patch, add pytesseract check
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]>
2026-06-19 21:02:36 +02:00
alexpolo1andClaude Sonnet 4.6 0facc84661 Add scripts/convert_chipped.py — inventory chipped-gem right-click converter
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]>
2026-06-19 20:40:25 +02:00
alexpolo1andClaude Sonnet 4.6 c3a2edb23c FG market: fix URL regex, expand to 60-day window, refresh estimates
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]>
2026-06-19 20:40:20 +02:00
alexpolo1andClaude Sonnet 4.6 a4498d2244 testbed: add count scenario, gem filter args, fix stash auto-open, dead return
- 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]>
2026-06-19 20:40:12 +02:00
alexpolo1andClaude Sonnet 4.6 0facc38613 Fix OCR digits whitelist syntax; broaden A5 stash-open detection
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]>
2026-06-19 20:40:06 +02:00
alexpolo1andClaude Sonnet 4.6 4bb460db2a Gem transmute v3: OCR counting, GEMS-tab cube flow, cube safety helpers
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]>
2026-06-19 20:40:00 +02:00
alexpolo1andClaude Sonnet 4.6 23f9488853 testbed gems_all: fall back to A5 when act detection fails
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]>
2026-06-17 07:58:23 +02:00
alexpolo1andClaude Sonnet 4.6 e79cf0470d Fix gem transmute: open cube from inventory + ctrl+shift from GEMS tab into cube
- 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]>
2026-06-17 07:55:46 +02:00
alexpolo1andClaude Sonnet 4.6 fffc0a4b56 Gem transmute: use cube from stash (PERSONAL tab) + ctrl+shift to send gems to cube
- _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]>
2026-06-17 07:45:47 +02:00
alexpolo1andClaude Sonnet 4.6 0e9c18e3e7 Fix gem transmute: stacked-slot clicks, search_all for inventory, Hermes key mapping
- _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]>
2026-06-17 07:31:14 +02:00
alexpolo1andClaude Sonnet 4.6 87c8dc5e16 convert_all_gems_to_perfect: use search_all for new GEMS tab UI
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]>
2026-06-17 06:40:49 +02:00
alexpolo1andClaude Sonnet 4.6 b84bdc8f4b Add convert_all_gems_to_perfect + fix hermes encoding
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]>
2026-06-17 06:04:03 +02:00
alexpolo1andClaude Sonnet 4.6 8ca96241bf Hermes: disable Qwen3 thinking mode, add repetition_penalty
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]>
2026-06-17 05:34:12 +02:00
alexpolo1andClaude Sonnet 4.6 f42637a5d8 Add Hermes: vision-LLM agent for live D2R game analysis
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]>
2026-06-16 20:44:51 +02:00
alexpolo1andClaude Sonnet 4.6 cbc797c14a Stash scanner: fix multi-slot dedup, add periodic bot hook + trade CSV
- 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]>
2026-06-16 20:40:05 +02:00
alexpolo1andClaude Opus 4.8 3522dedcee Stash: guard against transfer failure being mistaken for full stash
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]>
2026-06-14 04:05:01 +02:00
181 changed files with 14826 additions and 2899 deletions
+184 -142
View File
@@ -1,167 +1,209 @@
name: Botty - CI
on:
workflow_dispatch:
pull_request:
push:
branches: [main, mine]
# Pushing a version tag (e.g. `git tag v0.8.5 && git push --tags`) builds,
# smoke-tests, then creates the GitHub release with the zip attached — all
# in one run. If the build fails, no release is ever created.
tags: ['v*']
name: CI
# Default GITHUB_TOKEN is read-only; the build job's release step needs
# contents:write to create the release and attach the built zip
# (else HTTP 403 "Resource not accessible by integration").
permissions:
contents: write
on:
push:
branches: [main, stable]
pull_request:
branches: [main, stable]
# Cancel in-progress runs on new push
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
test:
runs-on: windows-latest
install-and-test:
name: Install & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout (skipped - workspace pre-populated)
run: echo "Using pre-populated workspace"
- name: Setup Miniconda Python 3.10
uses: conda-incubator/setup-miniconda@v3
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
activate-environment: botty
channel-priority: strict
environment-file: environment-win11.yml
use-only-tar-bz2: false
python-version: "3.10"
# cache disabled for act_runner
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Install Tesseract OCR
run: |
sudo apt-get update
sudo apt-get install -y tesseract-ocr libgl1 libglib2.0-0 python3-tk
- name: Python version
shell: powershell
run: |
C:\Miniconda\condabin\conda.bat activate botty
python -c "import sys; print(sys.version)"
run: python -c "import sys; print(sys.version)"
- name: Syntax check
shell: powershell
env:
PYTHONPATH: ./src
run: python -m compileall -q src tools test scripts
- name: Verify core imports
env:
PYTHONPATH: ./src
run: |
$ErrorActionPreference = "Stop"
C:\Miniconda\condabin\conda.bat activate botty
python -m compileall -q src tools test scripts
python -c "
import sys
sys.path.insert(0, 'src')
modules = [
'config',
'logger',
'screen',
'pather',
'template_finder',
'game_stats',
'health_manager',
'death_manager',
'd2r_image',
'item',
'item.pickit',
'transmute',
'shop',
'char',
'utils',
'utils.os_detect',
'messages',
]
for mod in modules:
try:
__import__(mod)
print(f' {mod}: OK')
except ImportError as e:
print(f' {mod}: FAILED - {e}')
sys.exit(1)
print('All core imports successful.')
"
- name: Tests
shell: powershell
- name: Verify botty runs (import main modules)
env:
PYTHONPATH: ./src
run: |
python -c "
import sys, os
sys.path.insert(0, 'src')
import ssl
ssl.SSLContext.load_default_certs = lambda *a, **k: None
from version import __version__
print(f' Version: {__version__}')
from config import Config
print(' Config: OK')
from game_controller import GameController
print(' GameController: OK')
from bot import Bot
print(' Bot: OK')
from run.diablo import Diablo
print(' Diablo run: OK')
from run.pindle import Pindle
print(' Pindle run: OK')
from run.arcane import Arcane
print(' Arcane run: OK')
from run.vizier import Vizier
print(' Vizier run: OK')
print('All botty entry modules import successfully.')
"
- name: Verify OCR (pytesseract)
env:
PYTHONPATH: ./src
run: |
python -c "
import sys, os, tempfile
sys.path.insert(0, 'src')
import pytesseract
pytesseract.pytesseract.tesseract_cmd = '/usr/bin/tesseract'
import cv2
import numpy as np
version = pytesseract.get_tesseract_version()
print(f' Tesseract version: {version}')
# Create a simple test image with text
img = np.full((50, 200), 255, dtype=np.uint8)
cv2.putText(img, 'Hello Botty', (20, 35),
cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 0), 1)
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
cv2.imwrite(f.name, img)
text = pytesseract.image_to_string(f.name, lang='eng').strip()
print(f' OCR result: {text}')
print('OCR pytesseract: OK')
"
- name: Verify OCR (botty ocr module)
env:
PYTHONPATH: ./src
PYTESSERACT_TESSERACT_CMD: /usr/bin/tesseract
run: |
python -c "
import sys, os
sys.path.insert(0, 'src')
# Import the botty OCR module - it reads PYTESSERACT_TESSERACT_CMD from env
from d2r_image.ocr import image_to_text, pytesseract
import cv2
import numpy as np
import tempfile
if pytesseract is None:
print(' pytesseract not available — skipping')
sys.exit(0)
# Create test image (3-channel for invert)
img = np.full((50, 200, 3), 255, dtype=np.uint8)
cv2.putText(img, '123', (80, 35),
cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 0), 2)
# Run through botty's image_to_text
results = image_to_text(
[img],
model='hover-eng_inconsolata_inv_th_fast',
psm=7,
crop_pad=False,
invert=True,
threshold=25,
)
result_text = results[0].text if results else 'empty'
print(f' OCR result: {result_text}')
print('OCR botty module: OK')
"
- name: Log analyzer tests (self-healing framework)
env:
PYTHONPATH: ./src:.
RUN_ENV: test
run: |
$ErrorActionPreference = "Stop"
C:\Miniconda\condabin\conda.bat activate botty
coverage run -m pytest -v
python -m pytest test/auto/test_log_analyzer.py -v --tb=short
- name: Coverage report
shell: powershell
- name: Tests with coverage
env:
PYTHONPATH: ./src
PYTHONPATH: ./src:.
RUN_ENV: test
run: |
$ErrorActionPreference = "Stop"
C:\Miniconda\condabin\conda.bat activate botty
coverage xml --ignore-errors
python -m coverage run -m pytest -v --tb=short --ignore=test/auto/test_self_healing.py --ignore=test/smoke_test.py --ignore=test/test_version_consistency.py
python -m coverage xml --ignore-errors || true
build:
needs: test
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup Miniconda Python 3.10
uses: conda-incubator/setup-miniconda@v3
with:
python-version: '3.10'
activate-environment: botty
channel-priority: strict
environment-file: environment-win11.yml
use-only-tar-bz2: false
- name: Install Tesseract (bundled into the release for click-and-run OCR)
shell: powershell
run: |
$ErrorActionPreference = "Stop"
choco install tesseract --no-progress -y
if (-not (Test-Path "C:\Program Files\Tesseract-OCR\tesseract.exe")) {
throw "Tesseract install did not produce tesseract.exe"
}
- name: Build exe
shell: powershell
env:
BOTTY_NO_RENAME: '1'
PYTHONPATH: ./src
run: |
C:\Miniconda\condabin\conda.bat activate botty
python build.py --conda_path C:\Miniconda
- name: Verify Tesseract was bundled
shell: powershell
run: |
$ErrorActionPreference = "Stop"
$BOTTY_DIR = Get-ChildItem -Directory -Name | Where-Object { $_ -match '^botty_v' } | Select-Object -First 1
$tess = Join-Path $BOTTY_DIR "tesseract\tesseract.exe"
if (-not (Test-Path $tess)) { throw "Tesseract was not bundled into $BOTTY_DIR" }
Write-Host "Bundled: $tess"
- name: Launch smoke test (built executables)
shell: powershell
run: |
$ErrorActionPreference = "Stop"
$BOTTY_DIR = Get-ChildItem -Directory -Name | Where-Object { $_ -match '^botty_v' } | Select-Object -First 1
if (-not $BOTTY_DIR) { throw "No botty_v* build directory found." }
$mainExe = Join-Path $BOTTY_DIR "main.exe"
$shopperExe = Join-Path $BOTTY_DIR "shopper.exe"
if (-not (Test-Path $mainExe)) { throw "Missing $mainExe" }
if (-not (Test-Path $shopperExe)) { throw "Missing $shopperExe" }
$procs = @()
try {
$mainProc = Start-Process -FilePath $mainExe -PassThru -WindowStyle Hidden
Start-Sleep -Seconds 6
if ($mainProc.HasExited) { throw "main.exe exited early with code $($mainProc.ExitCode)" }
$procs += $mainProc
$shopperProc = Start-Process -FilePath $shopperExe -PassThru -WindowStyle Hidden
Start-Sleep -Seconds 6
if ($shopperProc.HasExited) { throw "shopper.exe exited early with code $($shopperProc.ExitCode)" }
$procs += $shopperProc
}
finally {
foreach ($p in $procs) {
if ($p -and -not $p.HasExited) {
Stop-Process -Id $p.Id -Force
}
}
}
- name: Prepare release zip
shell: powershell
run: |
$BOTTY_DIR = Get-ChildItem -Directory -Name | Where-Object { $_ -match '^botty_v' } | Select-Object -First 1
Write-Host "Botty dir: $BOTTY_DIR"
Get-ChildItem -Path $BOTTY_DIR -Recurse | Select-Object -Property FullName, Length
$ZIP = "${BOTTY_DIR}.zip"
Compress-Archive -Path "${BOTTY_DIR}\*" -DestinationPath $ZIP -Force
Write-Host "Release zip: $ZIP"
Get-Item $ZIP | Select-Object -Property FullName, Length
- name: Upload build artifacts
- name: Upload coverage
uses: actions/upload-artifact@v4
if: always()
continue-on-error: true
with:
name: botty-build
path: botty_v*/
name: coverage-report
path: coverage.xml
retention-days: 7
# On a version-tag push, create the release (if absent) and attach the
# zip atomically. Runs only after the build + smoke test above succeed.
- name: Create release and upload zip
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: botty_v*.zip
fail_on_unmatched_files: true
generate_release_notes: true
+37
View File
@@ -30,8 +30,29 @@ data/d2jsp_pages/
data/d2jsp_topic_urls.txt
# Debug files
debug_*.png
debug_forum.html
# Dev tools (moved to tools/)
asset_extractor.py
asset_manager.py
build.py
desktop_snap.py
development.md
quest_debug.py
quest_plan.md
quest_screenshot_tool.py
references/improvements.md
run_asset_extractor.bat
run_chipped_gems.bat
run_gems_all.bat
run_quest_debug.bat
screenshot_tool.py
start_bot_detached.bat
start_botty.ps1
test_plan.md
update.bat
# Generated/runtime price data (updated automatically by bot)
config/fg_daily_estimates.json
config/fg_prices.json
@@ -87,3 +108,19 @@ botty_debug/
# per-user character profiles (survive git pulls)
config/profiles/
config/active_profile.txt
# Docker / dev-only files (not production)
.dockerignore
Dockerfile
docker-compose.yml
docker-*.bat
DOCKER.md
botty_next/
src/bridge_server.py
src/input_layer/bridge_input.py
.hermes/
fixtures/
test/run/
# Installer output (generated by run_install_capture.bat)
install_log.txt
-140
View File
@@ -1,140 +0,0 @@
# Botty - Open Issues & TODO Plan
Generated: 2026-06-01
---
## HIGH PRIORITY (affects botting reliability)
### 1. BNIP transpiler broken code (`src/bnip/transpile.py:369`)
- **Status:** `# TODO FIX THIS SHIT``remove_quantity()` function is hacky
- **Problem:** Expression splitting by `#` is fragile; can corrupt BNIP expressions with multiple `#` delimiters
- **Impact:** Pickit rules may silently misparse quantity operators
- **Fix:** Rewrite `remove_quantity()` to properly handle `#`-delimited expressions with edge cases
- **File:** `src/bnip/transpile.py`
### 2. FOH cast delay missing (`src/char/paladin/fohdin.py:99`)
- **Status:** `# TODO: add delay between FOH casts--doesn't properly cast each FOH in sequence`
- **Problem:** FOH casts fire too fast; some casts don't land in sequence
- **Impact:** Reduced DPS, wasted FOH rotations
- **Fix:** Add `wait()` between FOH casts to ensure each cast completes before next
- **File:** `src/char/paladin/fohdin.py`
### 3. Chest opening telekinesis workaround (`src/chest.py:51`)
- **Status:** `# TODO: Act as picking up a potion to support telekinesis`
- **Problem:** Chest open simulates potion pickup to work around telekinesis skill
- **Impact:** Fragile interaction; may break with game updates
- **Fix:** Implement proper chest interaction that accounts for telekinesis
- **File:** `src/chest.py`
### 4. Inventory full handling (`src/item/pickit.py:236`)
- **Status:** `#TODO Create logic to handle inventory full`
- **Problem:** When inventory fills, pickit just stops — doesn't try to stash, sell, or prioritize
- **Impact:** Bot stops picking up items mid-run; lost gold/runes
- **Fix:** Add fallback logic: stop picking, trigger town run to stash/sell
- **File:** `src/item/pickit.py`
### 5. Overburdened handling (`src/ui/view.py:109`)
- **Status:** `#TODO: handle "Overburdened"`
- **Problem:** `pickup_corpse()` doesn't detect "Overburdened" state after clicking
- **Impact:** Bot may get stuck trying to pickup corpse when overweight
- **Fix:** Add template detection for "Overburdened" UI and handle gracefully
- **File:** `src/ui/view.py`
---
## MEDIUM PRIORITY (code quality / edge cases)
### 6. BNIP parenthesis cross-section check (`src/bnip/transpile.py:199`)
- **Status:** `# TODO Backtrace until the last opening to make sure it wasn't from the past section.`
- **Problem:** Parenthesis validation doesn't catch `(` in one BNIP section and `)` in another
- **Impact:** Silent BNIP syntax errors that pass validation
- **Fix:** Implement backtrace to reject cross-section parentheses
- **File:** `src/bnip/transpile.py`
### 7. BNIP lexer misplaced checks (`src/bnip/lexer.py:319`)
- **Status:** `# TODO: The second checks seem a little misplaced`
- **Problem:** `NTIPAliasClass` and `TokenType.CLASS:` checks should be in transpiler validation, not lexer
- **Impact:** Code organization; potential missed validation
- **Fix:** Move validation logic to `transpile.py` and emit warnings
- **File:** `src/bnip/lexer.py`, `src/bnip/transpile.py`
### 8. BNIP actions error handling (`src/bnip/actions.py:209`)
- **Status:** `# TODO look at these errors`
- **Problem:** BNIP load errors are printed but not properly logged or categorized
- **Impact:** Hard to diagnose BNIP parse failures
- **Fix:** Replace `print()` with proper `Logger.error()` and structured error reporting
- **File:** `src/bnip/actions.py`
### 9. Pickit return type (`src/item/pickit.py:165`)
- **Status:** `TODO :return: return a list of the items that were picked up`
- **Problem:** Docstring says it should return a list, but function returns `bool`
- **Impact:** Inconsistent API; callers can't know what was actually picked
- **Fix:** Return `list[Item]` of picked items instead of `bool`
- **File:** `src/item/pickit.py`
### 10. Consumable auto-belt (`src/inventory/personal.py:350`)
- **Status:** `# TODO: logic for trying to add potion to belt if there are needs`
- **Problem:** Consumables found during inventory management aren't auto-added to belt
- **Impact:** Bot doesn't restock belt potions from inventory during runs
- **Fix:** Add logic to detect belt needs and move potions from inventory
- **File:** `src/inventory/personal.py`
### 11. Merc blocking templates (`src/run/nihlathak.py:45`)
- **Status:** `# TODO: We might need a second template for each option as merc might run into the template`
- **Problem:** Merc can stand on template match location, causing detection failure
- **Impact:** Nihlathak run fails to detect layout variant
- **Fix:** Add backup templates with offset ROIs for each layout variant
- **File:** `src/run/nihlathak.py`
---
## LOW PRIORITY (cleanup / refactoring)
### 12. Character select cleanup (`src/ui/character_select.py:109`)
- **Status:** `# TODO: can cleanup logic here, can we utilize a generic ScreenObject or use custom locator?`
- **Problem:** Character selection uses ad-hoc template search instead of reusable ScreenObject
- **Fix:** Refactor to use `ScreenObjects` pattern
- **File:** `src/ui/character_select.py`
### 13. Screen utility functions (`src/screen.py:104`)
- **Status:** `# TODO: Move the below funcs to utils(?)`
- **Problem:** `convert_monitor_to_screen()` and related functions live in `screen.py` but could be in utils
- **Fix:** Move coordinate conversion functions to `src/utils/`
- **File:** `src/screen.py`
### 14. Graphic debugger re-init (`src/utils/graphic_debugger.py:60`)
- **Status:** `# TODO: these two layers variable needs to be reassigned because F10 will not re-init`
- **Problem:** Debugger layers don't reinitialize properly on F10 toggle
- **Fix:** Move layer state into controller class; reinit on stop/start
- **File:** `src/utils/graphic_debugger.py`
### 15. mttkinter logging (`src/utils/mttkinter.py:62-63`)
- **Status:** `# TODO: Replace custom logging functionality with standard logging.Logger`
- **Problem:** Custom logging in tkinter utils instead of standard library
- **Fix:** Replace with `logging.Logger`
- **File:** `src/utils/mttkinter.py`
### 16. Pickit test note (`test/nip/keep_item_test_cases.py:978`)
- **Status:** `# TODO: I had to change from [defense] >= 47 to [plusdefense] >= 47`
- **Problem:** `[defense]` is a calculated property; `[plusdefense]` is the raw value. Note for future reference.
- **Fix:** Document in BNIP docs that `[defense]` is calculated; `[plusdefense]` is the raw modifier
- **File:** `test/nip/keep_item_test_cases.py`
### 17. New route scaffolding (`src/utils/new_route.py`)
- **Status:** Multiple TODO placeholders (by design — it's a code generator template)
- **Problem:** Template placeholders are intentional; not bugs
- **Fix:** No action needed — these are scaffolding placeholders
- **File:** `src/utils/new_route.py`
---
## Summary
| Priority | Count | Files |
|----------|-------|-------|
| HIGH | 5 | transpile.py, fohdin.py, chest.py, pickit.py, view.py |
| MEDIUM | 6 | transpile.py, lexer.py, actions.py, pickit.py, personal.py, nihlathak.py |
| LOW | 6 | character_select.py, screen.py, graphic_debugger.py, mttkinter.py, keep_item_test_cases.py, new_route.py |
**Total: 17 items across 13 files**
-87
View File
@@ -1,87 +0,0 @@
# Botty Improvements Implementation Plan
## Status Legend
- [ ] Not started
- [~] In progress
- [x] Done
- [-] Cancelled / low priority
---
## Phase 1: Key Auto-Detection (Issue #940)
Read D2R .key file and auto-fill hotkeys.
- [x] Create src/utils/key_detector.py module
- [x] VK code mapping (partial — needs review for accuracy)
- [x] Parse .key file (text format: VK action_type param)
- [x] Auto-fill empty [char] hotkeys from detected bindings
- [x] Auto-fill build-specific skill hotkeys (fohdin, hammerdin, etc.)
- [x] Wire into config.py load_data()
- [ ] REVIEW: Verify VK_MAP accuracy (D2R uses its own VK offset scheme)
- [ ] REVIEW: Skill slot-to-config matching is heuristic — may misassign
- [ ] TEST: Verify against actual D2R .key file on user's machine
## Phase 2: Target Detection False Positives (Issues #959/#964)
Health bars and "immune to X" text mistaken for targets.
- [ ] Analyze current get_visible_targets() in target_detect.py
- [ ] Add shape/size filtering: health bars are thin horizontal strips, immune text is small
- [ ] Add aspect ratio check: real targets (poison/freeze auras) are roughly circular/elliptical
- [ ] Add minimum bounding box height constraint (filter out thin text)
- [ ] Optionally: add color temperature check (immune text is yellow/gold, not blue/green)
- [ ] Test with screenshots of edge cases
## Phase 3: Pickit Timing Fix (Issue #939)
Items skipped because bot teleports away before grabbing.
- [ ] Review pickit.py _yoink_item() for timing issues
- [ ] Add configurable pickup_delay parameter (current: fixed timing)
- [ ] Add retry logic: if item still visible after pickup attempt, re-try
- [ ] Add "slow mode" for large/heavy items (framed/magic items may animate longer)
- [ ] Ensure bot doesn't teleport until pickup animation completes
- [ ] Test: verify no "Attempt to pick xyz" warnings followed by teleport
## Phase 4: Parallel Template Search (Issue #848)
Speed up template_finder.search() with threading.
- [ ] Add ThreadPoolExecutor-based search_all_parallel()
- [ ] Keep existing search() for single-template (no overhead)
- [ ] Only parallelize when searching >3 templates simultaneously
- [ ] Benchmark: measure speedup on typical 1280x720 grab
## Phase 5: Async Mouse Moves (Issue #955)
Non-blocking mouse movement.
- [ ] Add async_move() to utils/custom_mouse.py
- [ ] Run movement in background thread
- [ ] Add is_moving() / wait_for_move() synchronization
- [ ] Integrate into game_controller.py for smoother action chains
## Phase 6: Hardcore Chicken Loop Fix (Issue #942)
Prevent infinite death loops on Hardcore characters.
- [ ] Review death_manager.py chicken logic
- [ ] Add max_chicken_count config parameter (default: 3)
- [ ] If max chicken count exceeded on HC, exit gracefully instead of re-entering
- [ ] Add defensive chicken config option (chicken to TP instead of full chicken)
- [ ] Test: verify HC character exits cleanly after N deaths
## Phase 7: Auto-Label NPCs (Issue #950)
Learn vendor identities automatically during gameplay.
- [ ] During town states, detect NPC name plates via OCR
- [ ] Cross-reference detected names with known NPC list
- [ ] Auto-capture NPC templates when confidence is high
- [ ] Store learned templates in assets/npc/
- [ ] This is a long-term feature — lower priority
---
## Priority Order (implement in this order)
1. **Phase 1** - Key auto-detection (already partially done, needs review + test)
2. **Phase 3** - Pickit timing (high impact on loot collection)
3. **Phase 2** - Target detection (high impact on kill reliability)
4. **Phase 6** - Hardcore chicken fix (safety critical)
5. **Phase 4** - Parallel template search (performance)
6. **Phase 5** - Async mouse moves (quality of life)
7. **Phase 7** - Auto-label NPCs (long-term feature)
-114
View File
@@ -1,114 +0,0 @@
# Dia-run test #2 — session state & plan (2026-06-11 ~20:45)
## OUTCOME (21:29) — ALL FIXES VERIFIED ✅
Full Diablo run completed with every fix live: game 1 (21:14 session) ran Pindle (45s, clean,
no BC double-swap... BC retried once — template marginal, see below) then run_diablo SUCCEEDED
(21:17:28→21:29:04): A5 WP found FIRST TRY at new 0.62 threshold (was 0-for-5 before),
vendor trip skipped by gating, CS layout matched 91.5%, all seals, Diablo killed, game ended
clean at 872s. Implemented beyond the original plan: quick-mode for open_wp (failure 1 → direct
path only on next call; failure 2 → instant fail), sweep 10→6 steps, select timeout 4s, and
A5 threshold drops (stash 0.60/0.45, WP 0.62 — safe because success_func gates every click).
Remaining known marginals (non-blocking): BC/BO skill icon template (1 extra swap ~2s, 2/3 games),
A5_TOWN_0/1 town markers (detect_current_act warns but soft-falls-back), npc body templates
(Larzuk/Cain flaky, fallbacks work), numpy truthiness bug in missing-template debug screenshot
helper, inventory-full pickit skips until next successful stash. Char left at char-select/lobby,
D2R running, no bot processes.
## Goal
User asked: "trigger a full dia run and monitor it for loops and mistakes". A full Diablo run
(WP → ROF → CS → 3 seals → kill) must complete while logging all loops/mistakes, then deliver
an analysis report.
## Current state
- Bot was F12-stopped at 20:37 (was stuck in A5_WP search loop, game 3, char wandered to town wall).
- D2R is OPEN, character "fistman" is IN the stuck game with the **Options→Video menu open**.
- Next immediate steps: Esc out of options, click SAVE AND EXIT at **physical (650, 424)**,
relaunch bot, F11, re-arm monitor, wait for a full dia run (stealth may randomly skip runs).
- After run completes: F12 stop, kill leftover `cmd`/`pwsh` with `run_botty` in commandline,
delete `log/_*.png` and `log/_run2_out.txt` scratch files, deliver findings report.
## How to drive (hard-won specifics)
- Display is 1920x1200 physical, 125% scaling (1536x960 logical). **Use the botty env python**
(`C:\ProgramData\miniforge3\envs\botty\python.exe`) with `ctypes.windll.user32.SetProcessDPIAware()`
+ `src/input_layer/win_input.py` `mouse_move/mouse_click/mouse_wheel` for clicks (physical coords;
cwd must be C:\Users\alex\my-botty with sys.path.insert(0,"src")).
PowerShell `SetCursorPos`/`mouse_event` are DPI-virtualized → clicks land 1.25x off — do NOT use.
- Click into D2R twice (first click only activates the window).
- F11/F12 hotkeys work via `keybd_event` from anywhere (GetAsyncKeyState polling).
- Screenshots: PIL `ImageGrab.grab(all_screens=True)` in the DPI-aware python = physical pixels.
- Launch: `cmd /c C:\Users\alex\my-botty\run_botty.bat *> log\_run2_out.txt` (PowerShell bg task).
- Watch `log/stats/events_*.jsonl` (newest) + `log/log.txt`.
## Findings so far for the final report (test #2, started 20:23)
1. **A5_WP selection loop (CRITICAL, 3/3 occurrences after Pindle returns)**: every A5 WP open
after a Pindle run fails first try ("Wanted to select A5_WP"); anchor retries (qual_kehk, malah)
sometimes recover (~25s cost), but in game 3 (~20:30) ALL anchors + directed sweep failed,
char wandered to the town wall off all pather nodes, looped 15+ times until manual intervention.
Hypothesis: after Pindle TP return, pather position estimate is wrong; traverses compound the error.
2. **NPC detection failures**: Cain (A4) timed out → fell back to A5 Cain (worked); Tyrael resurrect
timed out once, retry worked. Town maintenance took ~3 min in game 1 due to these.
3. **Battle Command prebuff retry fired 3/3 games** ("Failed to find Battle Command, swapping
weapons again") — CTA buff icon detection systematically needs a second swap.
4. **Mouse misses (relative mode)**: ~6 occurrences, 12-80px off, all self-corrected via SetCursorPos
retry (win_input fallback working as designed).
5. **Player chicken at Pindle** game 2 (HP 37.2%, 59s game) — survivability, not logic.
6. **Stealth random skip** skipped Diablo in game 1 — by design but reduces dia throughput.
7. **D2R settings now verified correct** (in-game screenshots 20:42): DLSS OFF, 1280x720 windowed,
texture HIGH, details LOW, AA/AO off → matches assets/d2r_settings.json (startup warning gone).
Previous session's CS template failures should be fixed; pentagram matched 95% last session.
8. Earlier fixes this session (all verified live): hotkey.wait() no-arg blocking bug, edge-triggered
hotkeys, OCR tesseract_cmd wiring, NipSyntaxError→BNipSyntaxError + Schaefershammer typo.
## PERMANENT FIX PLAN (user-approved direction: fix properly, prefer smarter designs)
### Fix 1 — A5_WP loop: fail-fast + fresh game (CRITICAL, the 10-min wander)
`a5.py:open_wp` already has 3 escalation layers (direct path → 3 anchors → directed sweep,
~3.5 min total). The death loop is the OUTER chain: `bot.on_maintenance` retry sites call
`buy_consumables`/`go_to_act` again → `town_manager.open_wp` again → full 3.5-min escalation
again, from an ever-worse position estimate. Each failed cycle compounds.
**Smart fix:** position estimates can't be trusted after a failure, but a NEW GAME gives a
guaranteed-known spawn in ~40s. Add a per-game WP-failure budget on the `Bot` instance:
- `self._wp_fail_count` reset in `on_init`; `town_manager.open_wp` failure increments it
(thread the signal via return or a callback).
- In `on_maintenance`/`on_end_run`: if `_wp_fail_count >= 2``_save_error_screenshot` +
`trigger_or_stop("end_game", failed=True)` immediately. No more wandering retries.
- Also cap `a5.open_wp` layer 3 (sweep) to run only on the FIRST failure per game; subsequent
calls in the same game go straight to fail (the sweep from an unknown spot is what walked the
char onto the town wall).
### Fix 2 — same family: A5_RED_PORTAL first-click miss (Pindle approach, seen 20:47)
Same position-estimate root cause, already has a "retry from town start" recovery that works.
Include its failure in the same per-game budget rather than new mechanisms.
### Fix 3 — reduce A5→A4 Jamella trips (exposure reduction, smarter)
`buy_consumables: in A5 — traveling to A4 Jamella (Malah unreliable)` runs every game even when
only selling 1-2 junk items. Gate the trip: only travel to A4 if (pots needed below threshold)
OR (tp/id tomes low) OR (inventory has >N sell items). Selling junk can wait; stash is in A5.
Fewer WP trips = fewer chances to hit Fix-1 territory.
### Fix 4 — Battle Command prebuff double-swap (3/3 games)
`Failed to find Battle Command, swapping weapons again` every game. The buff check runs too
soon after weapon swap (buff icons fade in). In the prebuff code (char/hammerdin.py or
i_char.pre_buff): add ~0.4-0.6s wait after CTA casts before checking the buff bar, and lower
the BC icon threshold slightly (capture shows icons render fine). Saves a full swap cycle/game.
### Fix 5 — Cain ID: sticky act preference
A4 Cain timed out (20s wasted) then A5 Cain worked. Cache `self._last_good_cain_act` on Bot;
try that act first next game. One-line behavioral memory, halves ID time after first game.
### Fix 6 — leave as-is (verified fine)
- Mouse misses: ~6/session, all self-corrected by SetCursorPos retry (stealth Bezier primary
path is intentional). No change.
- Stealth random run skip: by design.
- Pindle chicken @ 37% HP: gear/survivability, not code. Mention to user only.
### Verification after implementing
- Unit-light: run 3+ games (`run_pindle`+`run_diablo`), grep log for: no second consecutive
`Wanted to select A5_WP` burst per game; `Battle Command` retry absent; Jamella trip skipped
when nothing needed; failed-WP game ends < 90s instead of 900s timeout.
## Stats so far (test #2)
- Game 1: Pindle OK (46s) + Diablo stealth-skipped. Maintenance ~3min (Cain/Tyrael/A5_WP issues).
- Game 2: Pindle chicken @ HP 37% (59s, failed).
- Game 3: Pindle OK (43s), then A5_WP loop before Diablo → manually stopped 20:37.
- Diablo run not yet completed in test #2.
-561
View File
@@ -1,561 +0,0 @@
# Quest Framework + Den of Evil Plan
## Goal
Build a quest automation framework in botty that can interact with D2R NPCs, handle dialogue,
track quest progress, and run Den of Evil as the first quest -- all usable by a low-level FoHdin.
---
## Architecture
The quest framework is a new subsystem that plugs into the existing botty state machine.
It follows the same patterns as existing runs (approach -> battle -> return to town) but adds
NPC dialogue interaction and quest state persistence.
### New files
```
src/quest/
__init__.py # Exports
quest_manager.py # Quest state machine + persistence (JSON)
quest_dialogue.py # OCR-based NPC dialogue interaction
quest_items.py # Quest item detection/pickup
quest_combat.py # Lightweight combat wrapper (killing trash)
a1/
__init__.py
q_den_of_evil.py # Den of Evil run
```
### Modified files
```
src/npc_manager.py # Add TOWN_MAIDEN NPC constant + templates
src/pather.py # Add A1_ROARING_CANYON + DoE entrance locations
src/bot.py # Add quest state, transitions, handler
src/run/__init__.py # Export DenOfEvil
src/town/a1.py # (optional) Add can_do_den_of_evil method
config/params.ini # Add run_doe to [routes]
config/bnip/ # Add town_maiden.png template
```
---
## Phase 1: Foundation
### 1.1 `src/quest/quest_manager.py`
Purpose: Track which quests are done, persist between sessions, dispatch to quest modules.
```python
class QuestManager:
"""Manages quest state: tracks done/available quests per act, persists to JSON."""
# Quest definitions per act
QUESTS = {
"a1": ["den_of_evil"],
"a2": [], # future: radament, horadric_staff, etc.
...
}
def __init__(self):
self._state_file = "config/quest_state.json"
self._state = self._load()
def is_done(self, quest_name: str) -> bool:
return self._state.get(quest_name, False)
def mark_done(self, quest_name: str):
self._state[quest_name] = True
self._save()
def mark_all_done(self, act: str):
for q in self.QUESTS.get(act, []):
self._state[q] = True
self._save()
def next_pending(self, act: str) -> str | None:
for q in self.QUESTS.get(act, []):
if not self.is_done(q):
return q
return None
def all_done(self, act: str) -> bool:
return all(self._state.get(q, False) for q in self.QUESTS.get(act, []))
def _load(self) -> dict:
if os.path.exists(self._state_file):
with open(self._state_file) as f:
return json.load(f)
return {}
def _save(self):
with open(self._state_file, "w") as f:
json.dump(self._state, f, indent=2)
```
JSON format (config/quest_state.json):
```json
{
"den_of_evil": true,
"search_for_smith": true,
...
}
```
### 1.2 `src/quest/quest_dialogue.py`
Purpose: Talk to NPCs, read dialogue options via OCR, click the right branch.
This is the core of quest automation -- it makes the bot "converse" with NPCs.
```python
class QuestDialogue:
"""OCR-based NPC dialogue interaction for quest conversations."""
# ROI at 1280x720
DIALOGUE_TEXT_ROI = (200, 470, 680, 100) # NPC speech text
DIALOGUE_OPTIONS_ROI = (200, 560, 680, 140) # Player response buttons
DIALOGUE_CLOSE_Y = 670 # Close button area
@staticmethod
def open_dialogue(npc_name: str) -> bool:
"""Walk to NPC and open their dialogue menu."""
from npc_manager import Npc, open_npc_menu
return open_npc_menu(getattr(Npc, npc_name.upper()))
@staticmethod
def read_dialogue() -> dict:
"""OCR the current dialogue box. Returns:
{
'npc_text': str, # What the NPC said
'options': [str, ...], # Response options (may be empty if no choice)
'has_continue': bool # True if just need to click continue
}
"""
img = grab()
npc_text = ocr_roi(img, self.DIALOGUE_TEXT_ROI)
options_text = ocr_roi(img, self.DIALOGUE_OPTIONS_ROI)
# Parse options: split by line, filter out empty, return list
options = [line.strip() for line in options_text.split('\n') if line.strip()]
has_continue = len(options) == 0 or "continue" in options_text.lower()
return {
'npc_text': npc_text.strip(),
'options': options,
'has_continue': has_continue
}
@staticmethod
def click_option(option_text: str) -> bool:
"""Find and click a specific dialogue option by matching text via OCR.
Searches the options ROI for a template match of the option text."""
img = grab()
options_img = cut_roi(img, self.DIALOGUE_OPTIONS_ROI)
# Use template_finder or OCR to locate which button matches
# Then click at that position
...
@staticmethod
def continue_dialogue() -> bool:
"""Click the close/continue button to advance dialogue."""
# Click in the close button area
x, y, w, h = self.DIALOGUE_CLOSE_Y
mouse.click at center of close area
...
@staticmethod
def follow_conversation(expected_options: list[str]) -> bool:
"""Follow a multi-step conversation:
- Read NPC text
- If options present, click the expected one
- If no options, click continue
- Repeat until dialogue closes or unexpected text appears
"""
max_steps = 20 # Safety limit
for i in range(max_steps):
dialogue = self.read_dialogue()
if not dialogue['has_continue'] and dialogue['options']:
# We have a choice - click the expected option
for opt in expected_options:
if opt.lower() in ' '.join(dialogue['options']).lower():
if not self.click_option(opt):
return False
break
else:
Logger.warning(f"Unexpected dialogue options: {dialogue['options']}")
return False
else:
# Just continue
if not self.continue_dialogue():
return False
wait(1.0, 1.5)
# Check if dialogue box is still visible
if not is_visible(ScreenObjects.NPCDialogue):
return True # Done
return False # Hit max steps
```
Key design: `follow_conversation()` takes a list of expected response text. It will
match against whatever options the NPC presents and click the right one. This handles
multi-branch dialogues without hardcoding step-by-step clicks.
### 1.3 `src/quest/quest_combat.py`
Purpose: Lightweight combat for clearing trash during quests. Reuses existing char methods.
```python
class QuestCombat:
"""Combat helpers for quest areas -- reuses existing character combat logic."""
@staticmethod
def clear_area(pather: Pather, char: IChar, path_nodes: list[int],
timeout: float = 60) -> bool:
"""Walk a path while killing monsters until timeout or all nodes cleared.
This is the core of DoE: walk down, kill, walk back."""
return pather.traverse_nodes(path_nodes, char, timeout=timeout, do_combat=True)
@staticmethod
def wait_for_clear(char: IChar, timeout: float = 15) -> bool:
"""Wait until no monsters are visible (area is clear)."""
start = time.time()
while time.time() - start < timeout:
targets = get_visible_targets()
if not targets or len(targets) == 0:
return True
# Attack if enemies present
char.attack()
wait(0.5)
return False
```
### 1.4 `src/quest/quest_items.py`
Purpose: Detect and pick up quest items (gold glow detection).
```python
class QuestItems:
"""Quest item detection and management."""
@staticmethod
def detect_quest_items(img: np.ndarray) -> list[tuple[float, float]]:
"""Detect gold-glowing items on screen (quest items).
Returns list of (x, y) positions in monitor coords."""
quest_item_mask, _ = color_filter(img, Config().colors.get("gold_glow", [
(180, 140, 0), (255, 220, 80)
]))
# Find contours, return centers
...
@staticmethod
def pick_up_quest_items(char: IChar, img: np.ndarray = None) -> bool:
"""Find and pick up any quest items currently visible."""
if img is None:
img = grab()
items = self.detect_quest_items(img)
for pos in items:
char.pick_up_item(pos, item_name="Quest Item")
wait(0.5)
return len(items) > 0
```
---
## Phase 2: NPC & Location additions
### 2.1 Add Town_Maiden to `src/npc_manager.py`
```python
# In class Npc:
TOWN_MAIDEN = "town_maiden" # Act 1, Roaring Canyon
# In _build_npcs():
Npc.TOWN_MAIDEN: {
"head": "town_maiden.png", # Need to capture template
"actions": {} # No trade/identify - just dialogue
}
```
The Town Maiden sits in Roaring Canyon (eastern part of Act 1 town). She has a simple
dialogue: you talk to her to "unlock" the Den of Evil entrance, then you talk to her
again after clearing it to get the XP reward and reset it for another run.
### 2.2 Add locations to `src/pather.py`
```python
class Location:
# ... existing locations ...
# Act 1 Roaring Canyon / Den of Evil
A1_ROARING_CANYON = "a1_roaring_canyon" # Town area where Maiden is
A1_DEN_OF_EVIL_ENTRANCE = "a1_doe_entrance" # Stairs down to DoE
A1_DEN_LEVEL_1 = "a1_doe_level_1"
A1_DEN_LEVEL_2 = "a1_doe_level_2"
A1_DEN_LEVEL_3 = "a1_doe_level_3"
A1_DEN_LEVEL_4 = "a1_doe_level_4"
# (DoE has 3-5 levels depending on game version - need to confirm)
```
Path nodes will need to be added for the Roaring Canyon area and each DoE level.
These are captured via quest_debug.py by walking the path and recording waypoints.
---
## Phase 3: Den of Evil run module
### 3.1 `src/quest/a1/q_den_of_evil.py`
```python
class DenOfEvil:
"""Den of Evil run - Act 1 repeatable quest for XP.
Flow:
1. Ensure character is in Act 1
2. Walk to Roaring Canyon (Town Maiden)
3. Talk to Town Maiden (unlock entrance if needed)
4. Enter Den of Evil
5. Pre-buff (FoH + Conviction for FoHdin)
6. Walk through each level, killing trash
7. Exit back to Roaring Canyon
8. Talk to Town Maiden again for reward
9. Return to town center
"""
name = "run_doe"
# Path nodes per level (to be filled in via quest_debug.py)
LEVEL_PATHS = {
1: [], # Entrance to level 1 stairs
2: [], # Level 1 to level 2
3: [], # Level 2 to level 3
4: [], # Level 3 to level 4 (or final area)
}
def __init__(self, pather, town_manager, char, pickit, runs):
self._pather = pather
self._town_manager = town_manager
self._char = char
self._pickit = pickit
self._runs = runs
self._quest_manager = QuestManager()
self._dialogue = QuestDialogue()
def approach(self, curr_loc: Location, do_buff: bool) -> Location | bool:
"""Get to Roaring Canyon and talk to Town Maiden."""
Logger.info("Run Den of Evil")
# Ensure we're in Act 1
if TownManager.get_act_from_location(curr_loc) != Location.A1_TOWN_START:
curr_loc = self._town_manager.go_to_act(1, curr_loc)
if not curr_loc:
return False
# Walk to Roaring Canyon (Town Maiden area)
if not self._pather.traverse_nodes(
(curr_loc, Location.A1_ROARING_CANYON), self._char, force_move=True
):
return False
# Talk to Town Maiden to unlock/open the Den
if not self._dialogue.open_dialogue("town_maiden"):
return False
# Follow the conversation (expect "Oh no, not again" or similar)
if not self._dialogue.follow_conversation(["Tell me more", "I'll help you"]):
return False
# Enter the Den
if not self._pather.traverse_nodes(
(Location.A1_ROARING_CANYON, Location.A1_DEN_OF_EVIL_ENTRANCE),
self._char, force_move=True
):
return False
return Location.A1_DEN_OF_EVIL_ENTRANCE
def battle(self, do_pre_buff: bool) -> bool | tuple[Location, bool]:
"""Fight through the Den of Evil."""
# Pre-buff
if do_pre_buff:
if not self._char.pre_buff():
return False
# Clear each level
for level in sorted(self.LEVEL_PATHS.keys()):
Logger.info(f"Clearing Den of Evil level {level}")
if not self._pather.traverse_nodes(
self.LEVEL_PATHS[level], self._char, timeout=120, do_combat=True
):
Logger.error(f"Failed to clear DoE level {level}")
return False
# Pick up any quest items / loot
self._pickit.pick_up_items(self._char)
QuestItems.pick_up_quest_items(self._char)
# Walk back to Roaring Canyon
if not self._pather.traverse_nodes(
(Location.A1_DEN_OF_EVIL_ENTRANCE, Location.A1_ROARING_CANYON),
self._char, force_move=True
):
return False
# Talk to Town Maiden for reward
if not self._dialogue.open_dialogue("town_maiden"):
return False
if not self._dialogue.follow_conversation(["Yes", "Thank you"]):
Logger.warning("Failed to collect DoE reward from Town Maiden")
# Mark as done (for non-repeatable quests) or just return success
# Note: DoE is repeatable once per real-day, so we DON'T mark permanently done
# self._quest_manager.mark_done("den_of_evil") # Only if non-repeatable
return (Location.A1_ROARING_CANYON, True)
```
---
## Phase 4: Bot integration
### 4.1 `src/bot.py` changes
```python
# Add import
from quest.a1.q_den_of_evil import DenOfEvil
# In __init__:
self._do_runs["run_doe"] = Config().routes.get("run_doe")
self._doe = DenOfEvil(self._pather, self._town_manager, self._char, self._pickit, self._do_runs)
# In _states list:
# (No new state needed - DoE uses the existing pattern: town -> doe -> end_run -> town)
# In _transitions list (add):
{ 'trigger': 'run_doe', 'source': 'town', 'dest': 'doe', 'before': "on_run_doe" },
# Add 'doe' to end_run source list:
{ 'trigger': 'end_run', 'source': [..., 'doe'], 'dest': 'town', 'before': "on_end_run" },
# Add end_game source:
{ 'trigger': 'end_game', 'source': [..., 'doe'], 'dest': 'initialization', 'before': "on_end_game" },
# Add handler method:
def on_run_doe(self):
res = False
self._do_runs["run_doe"] = False
self._game_stats.update_location("DoE")
self._curr_loc = self._doe.approach(self._curr_loc, not self._pre_buffed)
if self._curr_loc:
set_pause_state(False)
res = self._doe.battle(not self._pre_buffed)
self._ending_run_helper(res)
```
### 4.2 `src/run/__init__.py` changes
```python
# No change needed if DoE lives in src/quest/ (not src/run/)
# But if we want consistency, add:
from quest.a1.q_den_of_evil import DenOfEvil
```
### 4.3 `config/params.ini` changes
```ini
[routes]
; ... existing runs ...
; run_doe (Act 1 Den of Evil - repeatable daily XP)
order=run_doe
```
### 4.4 `config/params.ini` FoHdin config
For a lvl 1 Paladin running DoE, the params.ini needs:
```ini
[char]
type=fohdin
...
[fohdin]
; FoHdin-specific config for low-level DoE runs
teleport=
; No teleport at lvl 1-9, so pathing is on foot
```
---
## Phase 5: Testing workflow
### What needs user input (I cannot see D2R):
1. **Capture Town_Maiden template:**
- Go to Roaring Canyon in Act 1
- Stand near the Town Maiden
- Run `quest_debug.py`, press F4 (NPC detection)
- Paste output so I can save the template
2. **Capture DoE path nodes:**
- Enter the Den of Evil
- Run `quest_debug.py`, press F1 at each waypoint
- Walk from entrance through each level
- Paste outputs so I can build the path arrays
3. **Capture dialogue:**
- Talk to Town Maiden (both before and after clearing)
- Run `quest_debug.py`, press F2 (dialogue OCR)
- Paste output so I can code the conversation flow
4. **Test run:**
- After I write the code, you run botty with `run_doe` in the route order
- Report what happens / paste terminal output
- I iterate based on results
### Lvl 1 Paladin specifics:
- **FoHdin requires FOH skill lvl 6 for Feign of Life passive** -- this needs 3 skill points
in FoH, meaning character level 9 minimum (or level 4 with a +1 skill weapon)
- Before reaching lvl 9, the bot can still run DoE but will be much more fragile
- Recommended: manually level Paladin to ~lvl 4-5 (short runs in Area 1 or 2) before
letting the bot solo DoE with FoH
- The bot pathing should handle the walk-through at low speed with heavy FoH spam
---
## Implementation order
1. Write `quest_manager.py` (simple JSON state tracker)
2. Write `quest_dialogue.py` (OCR-based NPC interaction)
3. Write `quest_items.py` + `quest_combat.py` (lightweight helpers)
4. Add Town_Maiden NPC to npc_manager.py
5. Write `q_den_of_evil.py` (skeleton with placeholder paths)
6. Integrate into bot.py (state, transitions, handler)
7. Update params.ini
8. **USER TESTS** -- captures templates, paths, dialogue
9. I fill in the actual path nodes and dialogue based on your captures
10. Full test run and iterate
---
## File tree after implementation
```
my-botty/
├── config/
│ ├── params.ini # Modified: +run_doe in routes
│ ├── quest_state.json # New: auto-created by QuestManager
│ └── bnip/
│ └── town_maiden.png # New: captured template
├── src/
│ ├── quest/ # New directory
│ │ ├── __init__.py
│ │ ├── quest_manager.py
│ │ ├── quest_dialogue.py
│ │ ├── quest_items.py
│ │ ├── quest_combat.py
│ │ └── a1/
│ │ ├── __init__.py
│ │ └── q_den_of_evil.py
│ ├── npc_manager.py # Modified: +TOWN_MAIDEN
│ ├── pather.py # Modified: +A1_ROARING_CANYON, +A1_DEN_* locations
│ ├── bot.py # Modified: +doe state, transitions, handler
│ └── run/__init__.py # Modified: +DenOfEvil export
```
+21
View File
@@ -53,6 +53,27 @@ Handles D2R window detection and screenshot capture via MSS library. Converts be
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 |
+589 -4
View File
@@ -8,11 +8,12 @@ This file is auto-read by Claude Code at session start. It covers what you need
`my-botty` is a Python bot that automates Diablo II: Resurrected boss runs using computer vision and native Windows API input. No kernel drivers — only `SendInput`, `GetCursorPos`, `SetCursorPos` via `ctypes`.
**Active setup (as of 2026-06-08):**
- Character: Hammerdin (`fistman`), Hell difficulty
- Runs: `run_diablo`, `run_pindle` (see `config/params.ini`)
- OS: Windows 11, D2R 1280×720 windowed
**Active setup (as of 2026-08-26):**
- Character: FoHdin (`paladalla`, lvl 32), Normal difficulty — profile `config/profiles/paladalla/profile.ini`
- Runs: `run_pindle` (see `config/params.ini`)
- OS: Windows 11 (desktop at 125% scaling), D2R 1280×720 windowed, forced to `(5, 98)` on init
- Start bot: `run_botty.bat` (runs `python src/main.py` from the conda env — always current with source; no exe build exists/needed)
- Control without the F11 hotkey: `python scripts/hermes_bot_control.py start|pause|stop|status` (TCP 127.0.0.1:18899)
**Always start a session by reading logs first:**
```
@@ -77,11 +78,24 @@ ERROR|WARNING|failed|Approach failed|starting from True|DAMAGED|SendInput.*misse
| `SendInput.*missed target` | Win11 pointer acceleration amplifying relative mouse moves — fixed in `win_input.py` |
| `Repair/vendor interaction failed` | Flaky NPC detection, best-effort — bot continues |
| `Could not identify act from location` | `_curr_loc` is `None` or `True`, not a `Location` string |
| `Wanted to select A5_RED_PORTAL, but could not find it` | Red portal template stale/masked — see Bug 23 |
| `Loot from <run>: nothing picked up` on **every** run | Boss never actually fought — check XP delta, see Bug 24 |
| `already in Pindle area before portal click` | Should no longer exist; if seen, the fake-success shortcut is back — Bug 24 |
| `rejecting low-confidence .* implies reversal` | Working as intended — pather refused a phantom node match (Bug 25) |
| `Found open panels .* Chickening to be safe` | If the char is at full HP, it walked onto the waypoint — Bug 26 |
| `Got stuck exit pather` / char against town wall | Fabricated node position from the low-confidence fallback — Bug 25 |
| `expected result .* not found in GEMS convert panel` repeating | Stash tab never switched — gems being handled in the wrong tab, Bug 27 |
| `could not activate the .* stash tab` | Tab bar geometry drifted; re-measure tab centres against the live client — Bug 27 |
---
## Known Bugs and Fixes (permanent reference)
> Narrative write-up of the 2026-08-26/27 run_pindle collapse (what happened, in what order,
> and the four times the obvious answer was wrong): `docs/postmortem_pindle_2026-08-27.md`.
> Bugs 23-30 below are the per-bug detail for that incident.
### Bug 1: `_curr_loc = True` propagation
**File:** `src/town/town_manager.py``identify()` method (~line 236)
@@ -325,6 +339,529 @@ numbers, ending in `All stash is full, quitting`.
**Test tip:** `tools/testbed.py stash` exercises the gold + open-stash path live; add `all` to scan
all 10 inventory columns and exercise the keep-item transfer branch on existing charms.
### Bug 20: unescaped `)` in an echo killed the conda direct-download path (2026-08-05)
**File:** `install.bat` — Miniforge install error branch
`echo ERROR: Miniforge3 installer failed (exit code %errorlevel%).` sat inside a parenthesised
`if (...)` block. An unescaped `)` inside a block **terminates the block**, leaving `.` as a stray
token. cmd parses the whole block when it reaches it, so this aborted the script **even when the
installer succeeded and the branch was never taken** — verified with a minimal repro (unescaped form
exits 255 on a false condition; escaped form exits 0).
Effect: the direct-download fallback — the only path on a machine without winget — installed conda
and then died before creating the `botty` env, leaving the bot unusable.
**Fix:** escape as `^(exit code %errorlevel%^)`, the convention already used elsewhere in the file
(`^(fast path^)`).
**Symptom to grep for:** `. was unexpected at this time.` right after `Installing Miniforge3`.
**Two batch pitfalls that keep recurring in `install.bat` — check both when editing it:**
1. A `::` comment line **inside** a `( )` block is a parse error. Put comments above the block.
2. Any unescaped `(` or `)` in an `echo` inside a block breaks it. Escape as `^(` / `^)`.
Audit both across every `.bat` with:
```
awk '{ if ($0 ~ /^[ \t]*::/) { if (d>0) print FILENAME": "NR": "$0; next }
t=$0; gsub(/\^[()]/,"",t); d += gsub(/\(/,"(",t) - gsub(/\)/,")",t); if (d<0) d=0 }' *.bat
```
### Bug 21: installer needed admin, so it silently failed on a normal double-click (2026-08-05)
**Files:** `install.bat`, `src/d2r_image/ocr.py`, `run_botty.bat`
`winget install` defaulted to **machine scope**, putting conda in `%ProgramData%\miniforge3` — which
requires elevation. Double-clicking `install.bat` without admin failed silently and conda never
installed. The same bug applied to the Tesseract install.
**Fix:** `--scope user` (conda now lands in `%USERPROFILE%\miniforge3`, no admin), plus re-scanning
for `conda.exe`/`tesseract.exe` after winget instead of trusting its exit code — winget returns
non-zero when a package is *already installed*. Tesseract additionally falls back to a direct NSIS
download. `ocr.py` and `run_botty.bat` now also look in `%LOCALAPPDATA%\Programs\Tesseract-OCR`.
**Known limitation:** the official Tesseract installer self-elevates and discards `/D=`, so it
always installs machine-wide and **does require admin/UAC**. There is no per-user Tesseract install.
Conda has no such limitation.
**Note:** deleting a conda folder without running its uninstaller leaves stale Add/Remove-Programs
entries, which make winget treat the next install as an *upgrade* instead of a fresh install.
### Bug 22: tesserocr never loaded → bot silently ran on the slow OCR fallback (2026-08-06)
**File:** `install.bat` — OCR backend 1 section
`install.bat` always printed `tesserocr: not available (DLL issue)` and the bot logged
`OCR backend: pytesseract (fallback)`. pytesseract spawns `tesseract.exe` as a **subprocess per OCR
call**; tesserocr uses the in-process C++ API, so this was a permanent, silent performance loss on
every item hover.
**Root cause** (found by walking the PE import table with `pefile`, not by guessing):
```
tesserocr.pyd → tesseract52.dll → leptonica-1.78.0.dll → tiff.dll → libdeflate.dll ← MISSING
```
Current conda-forge `libdeflate` (>=1.20) installs the library as **`deflate.dll`**, but the older
`tiff.dll` from the `tesseract=4.*` stack still imports the previous name **`libdeflate.dll`**.
Nothing provided that name, so `tiff.dll` failed to load and every DLL above it failed with
**WinError 126 — "The specified module could not be found"**. That message made it look like a
missing *module*, which is why earlier fixes chased `os.add_dll_directory` / PATH instead. Adding
every DLL directory does NOT help; the file genuinely does not exist under that name.
**Fix:** install `libdeflate` explicitly next to `tesseract=4.*`, then copy `deflate.dll` to the
legacy name when it is absent:
```bat
if not exist "%BOTTY_ENV_DIR%\Library\bin\libdeflate.dll" (
if exist "%BOTTY_ENV_DIR%\Library\bin\deflate.dll" (
copy /y "...\deflate.dll" "...\libdeflate.dll" >nul
)
)
```
**Verify:** `install.bat` should print `tesserocr: OK (fast path)` and the bot should log
`OCR backend: tesserocr (primary)`. Deleting `libdeflate.dll` reproduces the failure exactly.
**Debugging tip for any future "DLL load failed" error:** don't assume it's a search-path problem.
Walk the real import chain and try loading each DLL directly — WinError 126 names the *importer*,
never the missing dependency:
```python
import pefile, ctypes
pe = pefile.PE(r"...\some.dll", fast_load=True)
pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_IMPORT"]])
print([e.dll.decode() for e in pe.DIRECTORY_ENTRY_IMPORT])
```
### Bug 23: masked `a5_red_portal.png` → portal never clickable (2026-08-26)
**File:** `assets/templates/a5_town/a5_red_portal.png`
`A5_RED_PORTAL` peaked at **0.500.60** — under the 0.68 `select_by_template` needs — and its
best match was always a false positive on bottom-right scenery near `(934, 62x)`, never the
portal. Every Pindle run died on `Wanted to select A5_RED_PORTAL, but could not find it`.
**Root cause:** it was the **only masked template in `a5_town/`** (4-channel, 60.9% opaque).
Every working A5 template is fully opaque:
```
a5_red_portal.png 4 chan 60.9% opaque MASKED <- the only one that failed
a5_town_1..12.png 3 chan 100.0% opaque no mask <- every one that works
```
The original capture had the "…thak's Te…" hover tooltip baked in, and alpha was used to hide
it. `alpha_to_mask` therefore returned a mask, routing it to
`cv2.matchTemplate(..., TM_CCOEFF_NORMED, mask=...)` — a combination **OpenCV does not
properly support** (masks are only valid for `TM_SQDIFF` / `TM_CCORR_NORMED`). That produced
both the depressed scores and the wandering match positions.
**Fix:** recaptured as a plain **3-channel opaque** crop of the portal's **upper arch** (the
lower ring is occluded by branches on most approaches, which wrecks matching). Verified by
held-out validation *and* against portal-absent frames:
| | score | position |
|---|---|---|
| portal present (5 frames + live) | **0.949 1.000** | correct every time |
| portal absent (6 real failure frames) | **0.398 0.514** | `valid=False` |
**Do not "fix" a template by masking part of it.** Recapture it clean. See the asset
conventions in `ARCHITECTURE.md` → Image Recognition.
### Bug 24: Pindle "already in Pindle area" fired in town → silent zero-XP fake success (2026-08-26)
**File:** `src/run/pindle.py` — `approach()` pre-click shortcut
The worst bug of the set, because it **reported success**. `approach()` opened with a
"are we already there?" shortcut calling `_verify_in_pindle_area()` before clicking the
portal. Harrogath scenery near the portal scores **0.760.79 on `PINDLE_7`** — well above its
0.62 bar — so the shortcut fired *in town*, returned `Location.A5_PINDLE_START` **without ever
clicking the portal**, and the bot ran `kill_pindle()` in the middle of Harrogath.
Five consecutive games logged `confirmed temple entry (PINDLE_7 @ 75.7%)`, `End game`,
`runs_failed_total: 0` — with **zero loot and zero XP**. The logs looked perfect.
**Fix:** the shortcut is deleted. Entry is proven only by the portal click's loading screen.
Real temple entries score **85.888.7%**, cleanly above the town false positives.
**Two traps this bug teaches:**
1. **`failed:false` in the event stream is the bot's opinion, not evidence.** Verify a boss
run with **XP delta** (`log/stats/mini_stats_*.json` → `current_exp`) and **loot lines**.
`Loot from run_pindle: nothing picked up` on *every* run is a red flag, not bad luck.
2. **A "we might already be there" fast path is dangerous** when its test can false-positive
at the origin. It converts a loud failure into a silent one.
**Do NOT try to fix this with a red-portal visibility check.** The Nihlathak portal is
visible from **both sides** — the return portal renders inside the temple too — so
"portal on screen ⇒ still in town" is false and blocks every genuine entry.
### Bug 25: pather 0.55 fallback fabricated node positions → char walked into the town wall (2026-08-26)
**File:** `src/pather.py` — `find_abs_node_pos()`, `traverse_nodes()`
When the primary 0.68 ROI search missed, `find_abs_node_pos` fell back to a **0.55
first-match** full-image search. That fallback invented node positions: `A5_TOWN_1` scored
0.600.62 on unrelated scenery at `(532,110)`, `(857,522)` and `(1209,86)` across three
consecutive failure frames. The pather steered toward each phantom and wedged the character
against the Harrogath battlements. Note the landmarks themselves were **healthy** where
actually visible (`A5_TOWN_6 @ 0.93`, `A5_TOWN_4 @ 0.85`) — only the fallback was lying.
**Fix (three parts):**
1. Fallback threshold `0.55` → **`0.62`** (`Pather._FALLBACK_THRESHOLD`).
2. Fallback forced to **`best_match=True`** — it was `best_match=False`, so an ambiguous
frame was decided by **node-dict ordering** rather than by score.
3. New `_heading_is_plausible()` gate: a **low-confidence** match implying a >90° reversal
from the current heading is rejected (`Pather: rejecting low-confidence X — implies
reversal`) and the recovery sweep runs instead. Confident matches are never gated.
Heading is tracked **per node** (`node_last_dir`, reset each node) because a >90° turn
*between* nodes is normal.
Fired for real on the very first live session: two `PINDLE_4` phantoms at 62.2%/62.9% were
rejected, and that game completed (73 s instead of ~50 s) rather than being lost.
### Bug 26: waypoint panel counted as a chicken → healthy games thrown away (2026-08-26)
**File:** `src/health_manager.py` — panel check in the monitor loop
A mis-steered pather walks the char **over the Harrogath waypoint stone**, which opens the WP
panel. The panel check saw `LeftPanel`/`RightPanel`, counted twice, and killed the game:
`Found open panels (inv/quest/stats) twice. Chickening to be safe.` — **3 of 6 games died this
way at full health.**
**Fix:** if `ScreenObjects.WaypointLabel` is visible the panel is self-inflicted, not a
threat: send `esc` **without** incrementing `_count_panel_detects`. Bounded by
`_MAX_WP_PANEL_ESCAPES = 6` so a WP panel that genuinely will not close still falls through
to the normal chicken path, and `_count_wp_panel_detects` resets as soon as a poll sees no
panel at all.
### Bug 27: gem conversion ran in the PERSONAL tab — tab-switch click missed the tab bar (2026-08-26)
**Files:** `src/transmute/transmute.py`, `config/params.ini`
`convert_all_gems` ran its entire routine in whichever stash tab happened to be open —
PERSONAL — never the GEMS tab. It logged `[819/999] … expected result
INVENTORY_TOPAZ_PERFECT not found in GEMS convert panel; trying first slot fallback` **on
every one of 800+ iterations** and kept going, ctrl+shift+clicking blind into the personal
stash grid.
**Root cause:** `GEMS_TAB_Y = 100`. Measured off the live client, the stash tab labels
occupy the row **y = 6378** (selected-tab underline at y=80) and the **stash grid starts at
y≈87**. So every `_switch_to_gems_tab()` click at y=100 landed on a *stash slot*, not a tab.
The X values were already correct — only Y was wrong, by ~30px.
Measured tab centres on this client (5 tabs: PERSONAL SHARED GEMS MATERIALS RUNES):
| PERSONAL | SHARED | GEMS | MATERIALS | RUNES |
|---|---|---|---|---|
| (68, 70) | (144, 70) | (220, 70) | (295, 70) | (370, 70) |
**Second, independent bug found alongside it:** `params.ini` had `stash_tabs=6` with only
**5** tabs on screen. `inventory/common.tab_properties()` divides the tab bar by that number,
so it computed centres of `63/127/192/256/320/384` against real centres of
`68/144/220/295/370` — tabs 2, 3 and 4 clicked the **gaps between tabs**. Set to `5`.
**Fix:**
1. `TAB_Y = 70`, plus named X constants for all five tabs.
2. `stash_tabs=5`.
3. **Tab switches are now verified, not fire-and-forget.** `_switch_to_tab()` clicks, confirms
the tab actually became active, retries up to 3×, and returns False. `convert_all_gems`
aborts on False instead of converting in the wrong tab.
**How to detect the active tab** — measure the **cell background**, not the glyphs:
`np.percentile(gray_label_cell, 30) > 52` (active ≈ 6768, inactive ≈ 3839). A
brightness-of-text test does **not** work: label brightness scales with label length, so an
active `GEMS` peaks at 167 while an active `PERSONAL` hits 215 — any glyph threshold that
catches PERSONAL misreads GEMS as inactive. This was a real wrong turn during the fix.
**The recurring lesson (same as Bug 24):** an unverified click that "should" have worked will
fail silently forever. Any click that changes UI *mode* — a tab, a page, a panel — must be
confirmed before the code acts as if it took effect.
### Bug 28: `detect_current_act` decided the act on a 1.7pp margin → A5 pathing inside Act 4 (2026-08-27)
**File:** `src/town/town_manager.py` — `detect_current_act()`
The single biggest cause of run failures: **25% of games failed**, dominated by
`Approach failed for run_pindle [step: click_red_portal]`. The character was running A5
pathing while physically standing in **Act 4**.
**The chain:**
1. `buy_consumables` travels A5 → A4 for Jamella (`in A5 — traveling to A4 Jamella (Malah
unreliable)`) — 32 times in one session.
2. Repair then runs in A4 too (`TownManager repair: starting from a4_town_start`, 32×).
3. `Run Pindle` calls `go_to_act(5, a4_town_start)`, which asks `detect_current_act()` to
verify. It answered **A5** while in Act 4, so `go_to_act` "corrected" the assumption and
**skipped the travel entirely**.
4. A5 node pathing ran in Act 4 → portal never found → failure. 11 failures traced directly
to step 1 in one session.
**Root cause:** `detect_current_act` used `search_and_wait(TOWN_MARKERS, best_match=True)`
and committed to whatever cleared 0.68 first. Measured on a real Act 4 failure frame:
```
A5_TOWN_1 0.636 <- phantom, always at (1046, 40), the top-right corner
A4_TOWN_5 0.619 <- the genuine marker for the act actually occupied
```
A **1.7 percentage point** gap decided which act the bot believed it was in. Pure coin flip.
**Fix:** the winner must now beat the best marker **from any other act** by
`_ACT_DETECT_MARGIN` (0.05) as well as clearing 0.68. Below that it logs
`detect_current_act: ambiguous … refusing to guess the act` and returns None.
**Why returning None is the safe answer:** every caller treats None as "keep the assumed
act". In `go_to_act` that means `curr_act` stays `a4_town_start`, which no longer equals the
target, so it **actually travels** via waypoint. Refusing to answer produces correct
behaviour; guessing wrong does not. Never make this function more willing to commit.
**Result:** failure rate 25% → 9% over the validation run. The first game of that run was the
exact failing case (repair started from `a4_town_start`) and completed successfully.
**Residual, different cause:** the failures that remain are NPC-detection flakiness — e.g.
`open_npc_menu: timed out finding qual_kehk` during merc resurrect burns ~40s and strands the
char. Same family as Bugs 3/4/6/7, not act desync. See Bug 29.
### Bug 29: undetectable resurrect NPC burned ~80s in EVERY game (2026-08-27)
**Files:** `src/bot.py`, `src/game_stats.py`
In nightmare the merc dies most games, so `resurrect_merc` runs constantly — and Qual-Kehk
detection was failing **100% of the time** (5 timeouts in 5 attempts, 107 hover attempts
across 12 games). Each failed hunt costs ~40s, and the code retried once, so a dead merc cost
**~80s per game, forever**. Game length blew out to 185-250s versus a normal ~60s.
**Why the existing guard didn't help:** `GameStats._merc_resurrect_failed` is reset in
`log_start_game`, so it only suppresses a second attempt *within one game*. Nothing carried
the knowledge across games.
**CORRECTED 2026-08-27 — the template was NOT the problem.** The identical 0.424 score at
unrelated positions looked like a degenerate template, but `QUAL_NAME_TAG_WHITE` scores
**1.000** on a frame where the tag is actually rendered, and **0.997** through the
`color_filter` path `npc_manager` really uses. The template is fine. The real cause was
`SWEEP_TAG_THRESHOLD` — see Bug 30. The lesson stands but points the other way: a repeated
identical score means the search is **settling for noise**, so check the threshold before
blaming the asset.
**Fix (cost containment, not detection):** a cross-game circuit breaker on `GameStats`,
deliberately NOT reset by `log_start_game`:
- `_merc_resurrect_fail_streak` — consecutive failed resurrects
- `_merc_resurrect_skip_until` — game number to resume trying at
- `Bot._MERC_RESURRECT_FAIL_LIMIT = 2`, `_MERC_RESURRECT_SKIP_GAMES = 15`
- the retry is skipped once the streak is ≥1 (a second guaranteed-futile 40s hunt)
- both counters reset on any successful resurrect, so a transient failure can't permanently
disable resurrecting
Over 30 games with an undetectable NPC: **60 hunts → 4** (~40 min of waste → ~2.7 min).
Measured live: game times went 250s / 185s → **14s, 14s, 43s, 71s, 111s** once the breaker
engaged. The bot runs mercless for 15 games, then tries again.
**Resolved by Bug 30.** The breaker stays as a safety net, but resurrect should now succeed.
### Bug 30: NPC grid sweep settled for noise — `SWEEP_TAG_THRESHOLD` too low (2026-08-27)
**File:** `src/npc_manager.py` — `open_npc_menu()` grid sweep
`SWEEP_TAG_THRESHOLD = 0.4`. A rendered name tag matches almost perfectly, so anything
mediocre is noise — and at 0.4 the noise won. Every sweep stopped at the first thing over
0.4, clicked empty ground, and gave up. Scores measured across a full day of sweeps:
| NPC | score | outcome |
|---|---|---|
| akara | 0.980 | real — dialogue opened |
| halbu | 0.995 | real |
| malah | 0.990 | real |
| larzuk | 0.996 | real |
| **qual_kehk** | **0.424** | **false — clicked nothing** |
| **malah** | **0.501** | **false** |
| **larzuk** | **0.494** | **false** |
Real hits cluster at 0.981.00, false ones at 0.420.50. Raised to **0.7**, which sits in
the gap with margin on both sides. This is why qual_kehk failed 100% (5 timeouts in 5
attempts) while akara succeeded 177 times — nothing was wrong with the Qual-Kehk asset.
**How the template was cleared of suspicion:** walked the char to the NPC with the project's
own Pather, hovered a grid of positions capturing full-res frames, found the one where
`QUAL-KEHK` renders, and scored the stored template against it — 1.000 raw, 0.997 through
`color_filter`. Worth repeating for any "stale template" claim: **prove the template fails on
a frame where the subject is definitely visible** before recapturing anything.
Note `NAME_TAG_THRESHOLD = 0.26` (the hover path, line ~289) is deliberately much lower and
was NOT changed — Akara genuinely hovers at ~0.28 (Bug 3). The two thresholds serve different
paths; don't unify them.
### Bug 31: merc panel check left the CHRONICLE panel open for the whole game (2026-08-27)
**File:** `src/bot.py` — `on_maintenance()`, merc-alive confirmation
`resurrect_merc` confirms a live merc by pressing `o` and looking for `MercPanelText`. It
then closed the panel **only when that check passed**:
```python
keyboard.send("o")
merc_panel_open = is_visible(ScreenObjects.MercPanelText)
if merc_panel_open:
keyboard.send("o") # the ONLY path that closed anything
```
On this client `o` (skill slot 54) opens the **CHRONICLE** collection panel, not the merc
panel. So `MercPanelText` never matched, the closing keypress was never sent, and Chronicle
stayed open for the rest of the game — a large centred panel that blanks every subsequent
template match. The run then died on `click_red_portal` after ~66s of clicking at a covered
screen.
**Why nothing caught it:** the health manager's panel guard looks for `LeftPanel` /
`RightPanel`. Chronicle is centred and matches neither, so it was never auto-escaped.
**Fix:** always dismiss whatever appeared. If `MercPanelText` is not found, send `esc`, then
re-check `LeftPanel`/`RightPanel` and send `esc` again if something is still up.
**The general rule:** *any* keypress that may open a panel must be paired with an
unconditional dismiss. Closing only on the happy path leaves the UI wedged on every other
path — and here that cost a whole run each time.
**Symptom to grep for:** `FAIL>` records showing `step=resurrect_merc` followed by
`run.approach!` with a long duration, or an error screenshot with a UI panel covering the map.
---
## The run timeline — `grep "TL>"`
One fixed-width, machine-readable line per step, covering the whole cycle from spawn to
loot. Every step is **timed**.
```bash
grep "TL>" log/log.txt
```
```
TL> g2 r1 | game | start | start | char=fohdin difficulty=nightmare routes=['run_pindle']
TL> g2 r1 | game | spawn | ok | at a5_town_start (act a5_town_start)
TL> g2 r1 | town | maintenance | start | at a5_town_start
TL> g2 r1 | town | inspect_inventory | ok | took=3.0s | in pack=2 keep=0 sell=2 gold_full=False
TL> g2 r1 | town | stash_items | skip | nothing kept and gold not full
TL> g2 r1 | town | repair | ok | took=19.3s | at a5_larzuk
TL> g2 r1 | town | item_sell | ok | SOUL IMPALER @ (928, 465)
TL> g2 r1 | town | resurrect_merc | fail | took=113.6s | NPC not reachable — continuing mercless
TL> g2 r1 | town | maintenance | ok | took=137.6s | at a5_larzuk
TL> g2 r1 | run | run_pindle | start | from a5_larzuk
TL> g2 r1 | run | approach | fail | took=71.9s | step=click_red_portal
TL> g2 r2 | game | end | fail | Approach failed for run_pindle [step: click_red_portal]
```
**Columns:** `game rN | phase | step | status | took= | detail`
- **phase**: `game` | `town` | `run` | `stlth`
- **status**: `start` | `ok` | `skip` | `fail`. `skip` states *why*, so a step that did
nothing is distinguishable from one that never ran.
- **took=**: emitted on every terminating line. `start` stamps the clock in
`Bot._tl_starts`, keyed by `(phase, step)`. This is what makes the slow phase findable —
the example above shows a failed game spending **113.6s** of its 137.6s town visit on a
merc resurrect that failed.
**Emitters:**
- `Bot.tl()` — the instance method (`src/bot.py`).
- `Bot.timeline()` — a **static** entry point for modules that cannot import `Bot` without
a circular import. `utils/stealth.py` and `inventory/personal.py` both use it via a lazy
guarded import, so item sells/stashes/drops and stealth decisions land in the same
stream. It is a no-op when no `Bot` is live.
**Stealth** appears as phase `stlth`: `afk_break` (start/ok around the sleep, so the break
duration is timed), `skip_run`, and `wrong_waypoint`.
**Keep the format stable** — it is meant to be grepped, not read as prose. The `>` in the
prefix matters: a bare `TL`/`TOWN` also matches template names like `A5_TOWN_0`.
---
## Is it AFK, or is it stuck?
The bot sits at the D2R **character-select menu** during a normal AFK break —
breaks happen *between* games, after save-and-exit, so the menu is expected.
The stuck case looks identical on screen. Do not judge by the menu.
```bash
LAST=$(grep -n "control socket listening" log/log.txt | tail -1 | cut -d: -f1)
tail -n +$LAST log/log.txt | grep -cE "select_char|Restarting bot|Uncaught exception"
```
| | Normal AFK break | Stuck |
|---|---|---|
| At character select | yes, by design | yes |
| `status` | `running=True paused=False` | the same |
| `select_char: Could not find online/offline tabs` | none | present |
| `Restarting bot` | none | every ~20s |
| Log | quiet | a new process, repeatedly |
**The tell is the log filling with restart lines, not the menu.** `status` cannot
distinguish them — it reports the game controller, not what the bot is doing.
### The configured break length is not the real one
`maybe_afk_break` calls `wait(minutes*60, minutes*60*1.5)` and `wait()` then
applies its own jitter (up to 1.44x). The two compound:
| planned | actual |
|---|---|
| 3.9m | ~5m+ |
| 11.9m | **19.5m** (`took=1167.7s`) |
| 20:56 scheduled | **25.5m** (`took=1531.1s`) |
So `afk_break_max_m = 12` really meant "up to ~26 minutes". A ~25 minute idle is
what left D2R unable to re-enter (`select_char`), and 19.5m resumed fine — the
tolerated limit is between them. Capped at 7 (=> ~15m worst case) on 2026-08-28.
**When changing any break duration, multiply by 1.5 x 1.44 before deciding
whether it is safe.**
## Diagnosing a failure — `grep "FAIL>"`
Each failed game emits a self-contained record. Use this before opening screenshots.
```
FAIL> g7 r5 | Approach failed for run_pindle [step: click_red_portal]
FAIL> g7 r5 | at=a5_larzuk | step=resurrect_merc | shot=./log/screenshots/error/...png
FAIL> g7 r5 | slowest: town.resurrect_merc=114s, run.approach=72s, town.repair=19s
FAIL> g7 r5 | trail: game.spawn > town.repair(19s) > town.resurrect_merc!(114s) > run.approach!(72s)
FAIL> g7 r5 | note: 2 failing steps this game: town.resurrect_merc, run.approach
```
**Read the trail, not just the reason.** The step named in the reason is the one that blew
up; it is frequently *not* the one that caused the problem. In the example the approach
failed only after a merc resurrect had already burned 114s and stranded the character. `!`
marks a failing step; the trail holds the last 12 timed steps of that game.
`note:` appears when more than one step failed in the same game — a strong hint the first
failure caused the second rather than them being independent.
---
## Using the timing data to tune runs
The 2-hourly Discord digest (`general.discord_timing_report_h`, 0 disables) aggregates the
same timeline, so log and report cannot disagree.
```
Games: 92 (81 ok, 11 failed - 12.0%)
Avg town 31s | approach 46s | battle 21s | cycle ~98s
__Slowest steps (avg)__
`town.resurrect_merc ` 103s x7 (5 fail)
`run.approach ` 46s x92 (9 fail)
__Failures by step__
`run.approach ` 9
```
**How to read it:**
| Signal | What it means |
|---|---|
| High avg + low count + high fail | A broken step retrying into a timeout. Worst kind — pure waste. `resurrect_merc` at 103s x7 with 5 fails is the textbook case. |
| High avg + high count | The real cost centre. Tuning this moves runs/hour more than anything else. |
| Fail count climbing between reports | A regression, or something drifting (template scores, NPC positions). Compare consecutive digests. |
| A step that vanishes from the list | It stopped running at all — check for a `skip` reason in `TL>` before assuming it was fixed. |
**Rankings deliberately exclude** umbrella entries (`maintenance`, the `run_<name>` step)
because they contain the others and would always come first, and the `stlth` phase because
an AFK break is intentional idling — it gets its own section.
Cross-check a suspicious step with:
```bash
grep "TL>" log/log.txt | grep "resurrect_merc"
```
---
## Verifying a boss run actually worked
Learned the hard way (Bug 24). Log lines and `failed:false` are **not** proof of a kill.
| Signal | Trustworthy? |
|---|---|
| `End game` / `failed:false` | **No** — the bot's own belief; a false-positive area check fakes it |
| `confirmed temple entry (X @ nn%)` | **No** on its own — town scenery hit 0.78 on `PINDLE_7` |
| `Loot from <run>: <item>` | **Yes** — items only drop from real kills |
| `current_exp` delta in `log/stats/mini_stats_*.json` | **Yes** — but a gain only proves *something* died (minions, merc kills), not that the boss did |
| A frame from a screen recording | **Yes** — decisive; costs one screenshot |
Record before/after XP around a verification run:
```bash
ls -t log/stats/mini_stats_*.json | head -1 | xargs python -c "import json,sys;d=json.load(open(sys.argv[1]));print(d['current_exp'],d['runs_failed_total'])"
```
**A running bot does not pick up source edits.** Python loads modules at process start, so
after changing `src/`, kill and restart `main.py` — otherwise you are testing the old code and
the log will show messages that no longer exist in the source.
---
## How to Add a New Run
@@ -371,6 +908,37 @@ Convert via `screen.py`: `convert_monitor_to_screen()`, `convert_screen_to_abs()
---
## Pickit / loot rules — which file is actually live
**The active pickit set is `config/bnip/Den gode.bnip`, which is gitignored** (`.gitignore:89`
= `config/bnip/*`). `config/default.bnip` is only a fallback and is NOT in use — editing it
has no effect on runs. Resolution order is in `bnip/actions.py::_resolve_bnip_dir()`:
```
config/profiles/<active>/pickit/ > config/pickit_profiles/<general.pickit_profile>/ > config/bnip/ > default.bnip
```
Confirm which file loaded by the startup line `Loaded N nip files with M total expressions`
and re-check M after editing — if the count doesn't move, you edited the wrong file.
**How the three decisions interact** (`bnip/actions.py`):
| Function | Evaluates | Notes |
|---|---|---|
| `should_pickup` | only the part **before** `#` | so a rule with stat conditions still picks the item up unidentified |
| `should_id` | returns **False** only if a rule **without** a `#` matches | any rule carrying `#` leaves the item to be identified |
| `should_keep` | full expression, returns on the **first** match | so ordering matters: specific rules must sit above any catch-all |
That combination is what lets a catch-all work: `[Quality] == Rare # [Strength] >= 999`
placed last picks up and identifies every rare, can never itself keep one, and lets every
specific rare rule above it win. Anything falling through is vendored (`sell_junk=1`).
**Current setup (2026-08-26):** gem transmuting is disabled (`[transmute] transmute=` empty
in `params.ini` — this also defeats `force=True`, unlike `transmute_every_x_game=0`).
Flawless and Perfect gems are picked up and stashed; chipped/flawed/standard are ignored.
---
## Config System
Singleton `Config()` merges in priority order: `custom.ini` > `params.ini` > `game.ini` > `shop.ini` > `transmute.ini`. First instantiation loads; subsequent calls return the same object. User overrides go in `custom.ini` (not tracked in git).
@@ -415,6 +983,23 @@ discord_hook_url=https://discord.com/api/webhooks/...
**Adding step tracking to a new failure point** — just set `self.approach_fail_step = "descriptive_name"` before `return False`. No other wiring needed.
**Screen-record a failure instead of guessing.** A 1 fps DPI-aware grab of the D2R client
rect is cheap and settles "where is the character actually standing?" in one frame — that is
what proved the wall-walk (Bug 25) and the in-town fake kill (Bug 24). Two gotchas: make the
recorder **DPI-aware** (`shcore.SetProcessDpiAwareness(2)`) or a 125%-scaled desktop silently
crops your capture to 1024×576 of a 1280×720 window; and re-read the window rect **after**
the bot starts, since it resizes D2R to 1280×720 at `(5, 98)` on init.
**Score templates offline against saved frames** rather than re-running the bot. The error
screenshots in `log/screenshots/error/` are full-res PNGs — load them and call
`template_finder.search(...)` directly to compare primary vs fallback thresholds.
Note `import discord` currently fails in the `botty` env with an `ssl.SSLError`, so a
standalone diagnostic must stub `sys.modules["discord"]` before importing `src/` modules.
**Only one bot instance at a time.** Two `main.py` processes both bind the Hermes socket and
fight over the `start`/`pause` toggle — one will pause the other mid-run and the logs become
nonsense. Check with `tasklist` before starting.
---
## File Quick Reference
+171
View File
@@ -0,0 +1,171 @@
# Botty First Run Guide
After running `install.bat` and seeing "Installation complete!", follow these steps.
---
## Step 1 — Create your character profile
Botty uses **profiles** to store your character setup. Each profile is a single file in `config/profiles/`.
### Find your character type
Open `config/params.ini` in Notepad, scroll to `[char]`, find `type=`:
| If you play... | Set `type=` to |
|---|---|
| Hammerdin Paladin | `hammerdin` |
| FoH Paladin | `fohdin` |
| Blizzard Sorceress | `sorceress` |
| Bone Necromancer | `necro` |
| Traps Assassin | `trapsin` |
| Amazon | `amazon` |
| Barbarian | `barbarian` |
| Warlock Druid | `warlock` |
### Set your keybinds
In `config/params.ini`, scroll to `[char]` and match these to your D2R key bindings:
```ini
[char]
type=hammerdin ; your build (see table above)
tp_key=b ; Teleport key
stand_still=capslock ; Hold to stand still (capslock is common)
show_belt=k ; Open belt
weapon_switch=w ; Swap weapons (for CTA builds)
force_move=e ; Force movement through mobs
```
**Important:** `stand_still` cannot be `shift` (conflicts with merc healing). `capslock` is the recommended default.
### Pick your runs
Scroll to `[routes]` in `config/params.ini`:
```ini
[routes]
; Enable the runs you want (1 = on, 0 = off)
run_pindle=1 ; Act 5 Nihlathak temple red portal boss
run_countess=0 ; Act 1 Forgotten Tower
run_andariel=0 ; Act 1 Catacombs
run_arcane=0 ; Act 2 Arcane Sanctuary / Summoner (teleport recommended)
run_trav=0 ; Act 3 Travincal council
run_mephisto=0 ; Act 3 Durance of Hate
run_diablo=0 ; Act 4 Chaos Sanctuary (teleport recommended)
run_vizier=0 ; Act 4 Chaos, Vizier only (faster than full Diablo)
run_nihlathak=0 ; Act 5 Halls of Vaught (teleport recommended)
run_eldritch=0 ; Act 5 Frigid Highlands, Eldritch only
run_eldritch_shenk=0 ; Act 5 Eldritch then Shenk in the same game
; Execution order (only includes enabled runs)
order=run_pindle
```
> There is no `run_shenk` option — Shenk is Act 5 and is farmed via
> `run_eldritch_shenk`. The commented route list at the top of the `[routes]`
> section in `params.ini` is always the authoritative set of names.
Common combinations:
- **Pindle only:** `run_pindle=1`, `order=run_pindle`
- **Pindle + Arcane:** `run_pindle=1`, `run_arcane=1`, `order=run_pindle, run_arcane`
- **Full farm:** Enable all you want, set `order=` to your preferred sequence
### Set difficulty
In `[general]`:
```ini
difficulty=hell ; normal, nightmare, or hell
```
Start with `nightmare` if you're testing. Use `hell` for serious farming.
---
## Step 2 — Save as a profile (optional but recommended)
Profiles survive git pulls. To save your setup:
1. Create a folder: `config/profiles/mychar/`
2. Create `config/profiles/mychar/profile.ini` with:
```ini
[general]
name=mychar
difficulty=hell
[char]
type=hammerdin
tp_key=b
stand_still=capslock
show_belt=k
weapon_switch=w
force_move=e
[routes]
run_pindle=1
order=run_pindle
```
3. Select it. The profile is chosen by `config/active_profile.txt`, **not** by
`name=` in `params.ini` (that field is only a display/label value). Either:
- start the bot and press **`end`** to cycle to your profile, or
- put the profile folder name on the first line of `config/active_profile.txt`
At startup the bot logs which one it loaded:
`Profile active: mychar (config\profiles\mychar\profile.ini)`
---
## Step 3 — Launch
1. **Start D2R** — set to 1280x720 windowed mode
2. **Log in** and select your character
3. **Double-click `run_botty.bat`**
4. The bot shows a hotkey menu — press **`f11`** to start or pause it. Press **`f12`** to stop.
> The hotkey menu printed at startup is always the source of truth — it reflects
> your actual `config/params.ini` keys. Do **not** press `insert`: that is
> `restore_settings_from_backup_key`, which overwrites your current D2R settings
> with a previously saved backup.
---
## Step 4 — Optional: Discord notifications
To get bot status in Discord:
1. Create a Discord webhook: Server Settings → Integrations → Webhooks → New Webhook → Copy Webhook URL
2. In `config/params.ini`, under `[general]`:
```ini
custom_message_hook=https://discord.com/api/webhooks/YOUR/WEBHOOK_URL
discord_log_chicken=1
discord_log_errors=1
```
---
## Troubleshooting
**"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.
---
## Quick reference: what to edit
| File | What to change |
|---|---|
| `config/params.ini` | `type=`, keybinds, `difficulty=`, routes, Discord webhook |
| `config/profiles/*/profile.ini` | Per-character overrides (optional) |
| `config/game.ini` | **Don't edit** — templates and UI coordinates |
| `config/shop.ini` | **Don't edit** — vendor buy/sell lists |
+323
View File
@@ -0,0 +1,323 @@
# Handover — 2026-08-29
Everything you need to run this yourself. Written after a long debugging session;
`CLAUDE.md` has the deep detail, this is the operating manual.
---
## Running the bot
```bash
run_botty.bat # starts the process (idle)
python scripts/hermes_bot_control.py start # begins playing
python scripts/hermes_bot_control.py status # running=X paused=Y
python scripts/hermes_bot_control.py stop # exits the process
```
**`start` is idempotent** (fixed 2026-08-28) — pressing it twice is safe. `pause`
and `toggle` are the toggle. Before the fix, a repeated `start` paused the bot
and `status` still said `running=True`; that cost ~5 hours once.
**Always verify with `status`, not the "OK: command sent" reply.** And check the
log actually moves — `=== BOT START ===` is the proof it began a game.
### Restarting after a code or config change
A running bot does **not** pick up edits. Python loads modules at process start.
```bash
python scripts/hermes_bot_control.py stop
# wait until nothing is listening:
netstat -ano | grep 18899
run_botty.bat
python scripts/hermes_bot_control.py start
```
If `stop` times out, retry it — the socket occasionally needs two attempts. Only
force-kill as a last resort: killing mid-game leaves D2R in a state the bot
cannot re-enter, and you then have to save+exit to the main menu by hand.
**Only ever run one instance.** Two both bind the control socket and fight over
start/pause, and the logs become nonsense. Check with `tasklist | grep -i python`.
---
## Is it stuck, or just idling?
The bot sits at the D2R **character-select menu** during a normal break. The
stuck case looks identical. Do not judge by the screen.
```bash
LAST=$(grep -n "control socket listening" log/log.txt | tail -1 | cut -d: -f1)
tail -n +$LAST log/log.txt | grep -cE "select_char|Restarting bot|Uncaught exception"
```
| | Normal break | Stuck |
|---|---|---|
| `status` | `running=True paused=True` | the same |
| `select_char` errors | none | present |
| `Restarting bot` | none | every ~20s |
| Log | quiet | new process repeatedly |
**The tell is the log filling with restart lines, not the menu.**
### Break lengths are longer than they look
`maybe_afk_break` calls `wait(m, m*1.5)` and `wait()` applies its own jitter (up
to 1.44x). They compound:
| planned | actual |
|---|---|
| 3.9m | 7.1m |
| 11.9m | 19.5m |
| 20:56 | 25.5m |
So **multiply any break setting by 1.5 x 1.44** before deciding it is safe. A
~25 minute idle is what left D2R unable to re-enter once. `afk_break_max_m` is
capped at 7 for this reason (=> ~15m worst case).
---
## Health check
```bash
LAST=$(grep -n "control socket listening" log/log.txt | tail -1 | cut -d: -f1)
tail -n +$LAST log/log.txt > /tmp/c.log
echo "games $(grep -c 'game | start' /tmp/c.log) | failed $(grep -c 'game | end .*fail' /tmp/c.log) | deaths $(grep -c 'You have died' /tmp/c.log) | crashes $(grep -c 'Uncaught exception' /tmp/c.log)"
```
Useful greps:
| what | grep |
|---|---|
| Step-by-step timeline | `grep "TL>" log/log.txt` |
| Failure records | `grep "FAIL>" log/log.txt` |
| Stealth manifest at startup | `grep "STEALTH>" log/log.txt` |
| Mana threshold crossings | `grep "MANA>" log/log.txt` |
| Level / exp | `ls -t log/stats/mini_stats_*.json \| head -1` |
**`FAIL>` gives the whole story of a failed game** — reason, location, the three
slowest steps, and a breadcrumb trail with `!` marking failures. Read the trail,
not just the reason: the step that blew up is often not the one that caused it.
---
## Temporary settings to revert
| file | setting | now | should be |
|---|---|---|---|
| `config/params.ini` | `session_budget_h` | **20** | 8 |
| `config/params.ini` | `difficulty` | hell | your call |
`session_budget_h = 20` was raised for a levelling push. It rolls to 13-27h,
which largely disables the stop-for-the-day behaviour that the session-rhythm
work exists to provide. **Put it back to 8 once you have the levels.** It is
uncommitted, so `git checkout config/params.ini` reverts it.
---
## Outstanding
**PR #39 is merged and live** (`ec6599f`). Confirmed working 2026-08-29: the
pather abort fired for the first time (`aborting traverse` 1, `taking a random
guess` 8, over 21 games) after being 0-for-152 while it looked correct.
**Travincal (hell) status — 2026-08-29:** 21 games, 2 failed (9.5%), 0 deaths.
Council dies; 10 of 13 runs produced loot. Both failures were `open_wp`, but
with *different* waypoints — `A1_WP` once, `A5_WP` once — because the character's
act varies between games. The A5 case follows a Larzuk repair (stale believed
location, Bug 16 family). Waypoint template matching is now on the critical path
for every Trav game, where Pindle never used it at all.
**FoH is the wrong build for Travincal.** Blessed Hammer does magic damage and
the council are not magic immune; FoH's holy-bolt half only damages *undead*, so
against the living council half its output does nothing. That is the 2-3k vs
~10k gap — not a tuning issue. `atk_len_trav` was raised 6.0 -> 10.0 in the
profile as a stopgap (40s min / 120s max across the four attack sequences).
**Done 2026-08-29** — respecced and verified live: `type=hammerdin`,
`concentration=f6`, `redemption=f3`. Note **Blessed Hammer is NOT on an F-key**:
`_cast_hammers` puts the aura on the right slot and spams left-click, so Hammer
lives on left-click permanently — pressing a hammer hotkey would replace the aura.
`concentration` and `redemption` had to move into `[paladin]`; they were under
`[fohdin]`, which a hammerdin never reads, so both would have been silently
unbound. `atk_len_trav` is now 3.0 (the fight is a fixed clock, so lower = less
exposure, not less damage).
**Trav costs two cross-act waypoint trips per game.** The run leaves the
character in Act 3; maintenance relocates it to Act 4 (stash at `a4_tyrael_stash`,
repair at `a4_halbu`) and the next run then needs the A3 waypoint again. That
travel is *not attributed to any timed step*, so a `FAIL>` trail can show ~38s of
work inside a 250s maintenance window — do not read the trail as the whole cost
here. This blew the 240s budget 5 times; `max_maintenance_time_s` raised to 420.
It also explains the `open_wp` failures: Trav exercises waypoint templates on
every game, where Pindle walked to an in-act portal and never touched them.
Not yet investigated: `a3.py` reports `can_buy_pots`/`can_heal`/`can_stash` all
True, so the trip to Act 4 is *not* a missing A3 capability — something in the
maintenance chain relocates the character. Keeping town business in Act 3 would
remove both waypoint trips and is the real fix if Trav becomes the main route.
(Cosmetic: `a3.py` defines `can_identify` twice, identically — harmless.)
**The `open_wp` failures are NOT a template problem — proven 2026-08-29.**
Scored `a5_wp.png` against all three real failure frames:
* full-frame, no ROI: 0.96-0.98 at (924, 594) — but that is the **belt/mana-orb
HUD false positive** the `a5.py` comment already documents, not the stone. The
`cut_skill_bar` ROI exists precisely to exclude it. Do not "confirm" this
template by scoring without the ROI; it produces a confident wrong answer.
* inside the real `cut_skill_bar` ROI (0,0,1284,653): **0.436-0.480** against a
0.55 threshold, at scattered positions (1,401) / (712,519) / (32,520). Wandering
match positions = noise. `a5_wp_2` (masked, 4-channel) scores 0.26-0.29.
The waypoint is genuinely **not on screen**: the character never reaches it.
Recapturing the template would fix nothing. The cause is stale believed location
after town business happens in a different act (Bug 16 family), consistent with
the `taking a random guess` lines in the same failures.
**Therefore: keeping Trav town business in Act 3 is the real fix**, not a
performance tweak. It removes the A5 waypoint dependency, the two cross-act
trips, and the maintenance-timeout pressure in one change. 7 of 8 waypoint
misses were `A5_WP`; `A1_WP` was a single one-off.
**A3 town landmark coverage is the root cause of the Trav failures (2026-08-29).**
Measured against the two `info_npc_menu_timeout` frames where the bot was stuck:
| frame | a3_town landmarks >=0.68 | >=0.62 | best |
|---|---|---|---|
| 191640 | 2 | 3 | 0.728 |
| 191758 | **0** | **0** | 0.607 |
With zero landmarks over threshold the pather cannot localise at all — hence
6 `taking a random guess` across 2 traverses to `a3_ormus`. The character then
ends up somewhere arbitrary, which produces all three symptoms from one cause:
Ormus not in his ROI, the waypoint not on screen, and maintenance burning its
budget.
Ormus himself is fine: best match INSIDE his ROI (444,13,372,318) is 0.342/0.334
— noise — while the global best (0.479/0.508) sits *outside* it. Do NOT lower
the body threshold; 0.34 is background level and dropping the bar there invites
the Bug 4 false-positive clicks. The NPC is not there to be found.
**CORRECTION to the earlier entry in this file:** "keeping Trav town business in
Act 3 is the real fix" was wrong. That was based on `a3.py` reporting
`can_buy_pots`/`can_heal`/`can_stash` as True — a capability check, not evidence
about pathing. A3 is in fact the worst-supported town in the project; moving more
work into it makes things worse. The fix is A3 landmark coverage.
New pather node templates need the character's absolute position at capture time,
so they cannot be made from saved frames — this needs a live capture pass in A3.
**BELT WAS HALF EMPTY — found 2026-08-30 after the session's only death.**
```ini
belt_hp_columns=1 # 4 healing potions
belt_mp_columns=1 # 4 mana potions
belt_rejuv_columns=2 # 8 slots for potions VENDORS DO NOT SELL
```
Rejuvenation potions cannot be bought in D2 — they only drop. So the bot asked
for `rejuv=8` on every restock, never got them, and ran Travincal on **4 healing
potions** with half the belt permanently empty. 50 `Failed to drink rejuv` events
in 200 games. The death: drank at 47%, chickened at 33.8%, died in the gap with
nothing left to drink. Many of the 37 chickens were likely "belt empty", not burst
damage.
Now `belt_hp_columns=2 / mp=1 / rejuv=1` (8 healing, 4 mana, 4 slots for dropped
rejuvs — `convert_rejuv=1` cubes them to Full). **Verify after restart that the
HP columns actually fill.**
**Repair NPC — settled by measurement (2026-08-30):**
| NPC | result |
|---|---|
| `a5_larzuk` | 6 fail / ~250 — **2.4%** ← use this |
| `a4_halbu` | 6 fail / ~50 — **12%** |
| `a1_charsi` | 0 for 3 — pathing never reaches her |
Bug 12's original reasoning holds: Halbu detection is unreliable even after Bug
30's threshold fix. Charsi failed for a different reason — `Traverse from
a1_wp_north to a1_charsi` fails, so `open_npc_menu` never runs and her "she does
not move" advantage is never tested. A1's wp->charsi route is broken like A3.
**A `repair_npc` code fix went in:** the cross-act destination used to be
hardcoded to A5, so the setting was silently ignored on every route not already
standing in A5. It is now honoured (`town_manager.repair()`).
**Damage profile at hell Travincal** (confirmed from screenshots): council cast
**Hydra (fire), Lightning/Charged Bolt, and Frost Nova (cold)**. Lightning is both
the most common and the weakest resist (~48% with Mara) — **Thundergod's Vigor**
(+10 max lightning res, 20% absorb, +20 vit) is the targeted upgrade, ahead of
Verdungo's. Fire is well covered by Dwarf Star's absorb.
**Do NOT raise `atk_len_trav` to reduce the "running around".** Hydras are
stationary fire turrets, so `kill_council`'s repositioning is actively dodging
sustained damage. 3.0 is correct.
**Merc blocks gold pickups.** `Mizan says: I can't use that` = a pickit click
landing on the merc. ~4 gold piles lost per 18 games. The bot retries twice then
moves on; not worth fixing.
**Lightning charm rules added** to `config/bnip/Den gode.bnip` (gitignored,
backup at `.bak`), 558 -> 561 expressions. The existing resist rules sum all four
resists (`>= 12` for smalls), and a max single-res lightning small charm is 11 —
so pure lightning charms were picked up and then **vendored**. Two older rules
existed but each required a second stat (`[Maxhp] >= 10`, `[Fhr] >= 3`).
**Current bind map (verified live 2026-08-29) — do NOT rebind F7:**
| key | skill | | key | skill |
|---|---|---|---|---|
| F1 | Battle Command (CTA) | | F5 | Teleport |
| F2 | Holy Shield | | F6 | **Concentration** |
| F3 | Redemption | | F7 | **Battle Orders (CTA)** |
| F4 | Town Portal (not a skill) | | F8 | free (Vigor if wanted) |
Blessed Hammer is on **left-click**, not an F-key. An earlier version of this file
said to bind Concentration to F7 — that would overwrite Battle Orders and cost a
large part of the life pool.
**Two things only you can do:**
1. **Resistances** — lightning 23% / poison 12% are the survivability ceiling.
Herald of Zakarum + Mara's took health chickens from 3-in-8 to **0-in-11**, so
this is largely addressed; the remaining holes are gear, not config.
2. **75% FCR** — currently 60% (HotO 40 + Trang's 20). The 48→75 breakpoint means
everything between is wasted; Arachnid Mesh in place of Goldwrap closes it.
**If FCR changes, update `casting_frames`**: 75% = 11 frames, 60% = 12, and
below 48% = 13. The bot derives its cast wait from that number, so a wrong
value cuts every buff short.
**Known-stale:** the `CONVICTION` preflight template scores 45.9% while the bind
is provably correct, so every startup logs a false alarm. Cosmetic.
---
## Checking your binds after any gear change
```bash
python tools/testbed.py spellbook --assets
```
Hovers the whole bind grid, reads each skill from its tooltip and each hotkey
from the icon corner, and prints which config keys disagree with the game. Exits
1 on a mismatch. This exists because an Enigma put Teleport on F5 and displaced
Conviction, and `conviction=f5` would have teleported the character mid-fight.
---
## The one habit worth keeping
Most of what went wrong here was **configured behaviour that never executed**,
and nothing reported it. AFK breaks were 0-for-225 at a configured 5%. The
manifest said `wired` throughout.
When something should be happening and you are not sure it is, **count it**:
```bash
grep -c "<the thing>" log/log.txt
```
A zero where you expected a number is the most informative result in this
project. It found the AFK break, the loot-filter clicks, the Chronicle panel,
and the pather abort — twice.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+70 -777
View File
@@ -1,18 +1,80 @@
{
"generated_at": "2026-06-17T07:19:33.572415+00:00",
"mode": "offline",
"generated_at": "2026-08-31T06:30:30.597153+00:00",
"mode": "offline-improved",
"offline_dir": "data\\d2jsp_pages",
"ladder_start_date": "2026-05-12",
"days": 60,
"topics_scanned": 853,
"ladder_start_date": "2026-05-20",
"days": 21,
"topics_scanned": 20,
"skipped": {
"blocked": 2,
"no_topic": 0,
"no_date": 848,
"no_item": 4
},
"filters": {
"min_price_fg": 5.0,
"trim_fraction": 0.2
},
"estimates": {
"day_1": {},
"day_2": {},
"day_3": {},
"day_4": {},
"day_5": {},
"day_6": {},
"day_7": {},
"day_6": {
"Cham Rune": {
"median_fg": 7.5,
"avg_fg": 10.0,
"trimmed_mean_fg": 7.5,
"trimmed_median_fg": 7.5,
"min_fg": 5.0,
"max_fg": 20.0,
"samples": 4,
"raw_samples": 6
}
},
"day_7": {
"Aldur's Advance": {
"median_fg": 20.0,
"avg_fg": 21.7,
"trimmed_mean_fg": 20.0,
"trimmed_median_fg": 20.0,
"min_fg": 20.0,
"max_fg": 25.0,
"samples": 3,
"raw_samples": 6
},
"Ist Rune": {
"median_fg": 70.0,
"avg_fg": 94.0,
"trimmed_mean_fg": 66.7,
"trimmed_median_fg": 70.0,
"min_fg": 20.0,
"max_fg": 250.0,
"samples": 5,
"raw_samples": 16
},
"Gul Rune": {
"median_fg": 65.0,
"avg_fg": 65.0,
"trimmed_mean_fg": 65.0,
"trimmed_median_fg": 65.0,
"min_fg": 20.0,
"max_fg": 110.0,
"samples": 2,
"raw_samples": 4
},
"Unid Anni": {
"median_fg": 575.0,
"avg_fg": 575.0,
"trimmed_mean_fg": 575.0,
"trimmed_median_fg": 575.0,
"min_fg": 550.0,
"max_fg": 600.0,
"samples": 2,
"raw_samples": 2
}
},
"day_8": {},
"day_9": {},
"day_10": {},
@@ -26,775 +88,6 @@
"day_18": {},
"day_19": {},
"day_20": {},
"day_21": {},
"day_22": {},
"day_23": {},
"day_24": {},
"day_25": {},
"day_26": {},
"day_27": {},
"day_28": {},
"day_29": {},
"day_30": {},
"day_31": {
"CTA": {
"median_fg": 100.0,
"avg_fg": 358.0,
"min_fg": 1.0,
"max_fg": 1199.0,
"samples": 5
},
"Cham Rune": {
"median_fg": 30.0,
"avg_fg": 121.0,
"min_fg": 5.0,
"max_fg": 450.0,
"samples": 5
},
"Lo Rune": {
"median_fg": 25.0,
"avg_fg": 38.8,
"min_fg": 5.0,
"max_fg": 100.0,
"samples": 4
},
"Um Rune": {
"median_fg": 75.0,
"avg_fg": 99.6,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 10
},
"Lem Rune": {
"median_fg": 60.0,
"avg_fg": 72.1,
"min_fg": 5.0,
"max_fg": 190.0,
"samples": 7
},
"Sorc Torch": {
"median_fg": 100.0,
"avg_fg": 100.0,
"min_fg": 100.0,
"max_fg": 100.0,
"samples": 1
},
"Aldur's Advance": {
"median_fg": 27.5,
"avg_fg": 43.8,
"min_fg": 20.0,
"max_fg": 100.0,
"samples": 4
},
"Stealth RW": {
"median_fg": 55.0,
"avg_fg": 55.0,
"min_fg": 10.0,
"max_fg": 100.0,
"samples": 2
},
"Ist Rune": {
"median_fg": 95.0,
"avg_fg": 112.2,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 12
},
"Mal Rune": {
"median_fg": 100.0,
"avg_fg": 112.0,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 13
},
"Pul Rune": {
"median_fg": 60.0,
"avg_fg": 72.1,
"min_fg": 5.0,
"max_fg": 190.0,
"samples": 7
},
"Unid Anni": {
"median_fg": 550.0,
"avg_fg": 416.7,
"min_fg": 100.0,
"max_fg": 600.0,
"samples": 3
},
"Dual Leech Ring": {
"median_fg": 85.0,
"avg_fg": 85.0,
"min_fg": 70.0,
"max_fg": 100.0,
"samples": 2
},
"Gul Rune": {
"median_fg": 25.0,
"avg_fg": 63.1,
"min_fg": 5.0,
"max_fg": 200.0,
"samples": 8
},
"Key": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Insight": {
"median_fg": 25.0,
"avg_fg": 91.0,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 6
},
"Ohm Rune": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Vampire Gaze": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"White": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Shako": {
"median_fg": 25.0,
"avg_fg": 91.0,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 6
},
"Black": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Monarch": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Vex Rune": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Java SK": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"BK Ring": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Skin": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Oculus": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Spirit": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Bone": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Jah Rune": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Moser": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Ber Rune": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Arach": {
"median_fg": 90.0,
"avg_fg": 163.7,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 3
},
"5/5 Facet": {
"median_fg": 90.0,
"avg_fg": 163.7,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 3
},
"Mara": {
"median_fg": 90.0,
"avg_fg": 163.7,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 3
}
},
"day_32": {},
"day_33": {},
"day_34": {},
"day_35": {},
"day_36": {},
"day_37": {
"Thresher": {
"median_fg": 32.5,
"avg_fg": 133.3,
"min_fg": 0.0,
"max_fg": 3500.0,
"samples": 210
},
"Monarch": {
"median_fg": 20.0,
"avg_fg": 71.5,
"min_fg": 0.0,
"max_fg": 1300.0,
"samples": 318
},
"Spirit": {
"median_fg": 25.0,
"avg_fg": 78.3,
"min_fg": 0.0,
"max_fg": 1300.0,
"samples": 237
},
"Wind": {
"median_fg": 30.0,
"avg_fg": 175.0,
"min_fg": 0.0,
"max_fg": 1200.0,
"samples": 60
},
"Arach": {
"median_fg": 40.0,
"avg_fg": 184.2,
"min_fg": 5.0,
"max_fg": 3500.0,
"samples": 210
},
"Sorc Torch": {
"median_fg": 50.0,
"avg_fg": 166.2,
"min_fg": 10.0,
"max_fg": 2500.0,
"samples": 64
},
"Griffon": {
"median_fg": 60.0,
"avg_fg": 254.7,
"min_fg": 5.0,
"max_fg": 3500.0,
"samples": 111
},
"Guardian's Light": {
"median_fg": 50.0,
"avg_fg": 342.6,
"min_fg": 0.0,
"max_fg": 3500.0,
"samples": 236
},
"Skin": {
"median_fg": 30.0,
"avg_fg": 206.8,
"min_fg": 0.0,
"max_fg": 3000.0,
"samples": 242
},
"Viper Gorge": {
"median_fg": 30.0,
"avg_fg": 75.9,
"min_fg": 0.0,
"max_fg": 1200.0,
"samples": 231
},
"Lo Rune": {
"median_fg": 30.0,
"avg_fg": 113.6,
"min_fg": 5.0,
"max_fg": 1500.0,
"samples": 105
},
"Enigma": {
"median_fg": 20.0,
"avg_fg": 182.4,
"min_fg": 0.0,
"max_fg": 1500.0,
"samples": 49
},
"Bone": {
"median_fg": 40.0,
"avg_fg": 244.3,
"min_fg": 0.0,
"max_fg": 3500.0,
"samples": 264
},
"Um Rune": {
"median_fg": 45.0,
"avg_fg": 187.3,
"min_fg": 0.0,
"max_fg": 3500.0,
"samples": 191
},
"Lem Rune": {
"median_fg": 20.0,
"avg_fg": 48.7,
"min_fg": 0.0,
"max_fg": 750.0,
"samples": 126
},
"Crescent": {
"median_fg": 10.0,
"avg_fg": 63.0,
"min_fg": 5.0,
"max_fg": 600.0,
"samples": 70
},
"Ist Rune": {
"median_fg": 45.0,
"avg_fg": 74.9,
"min_fg": 3.0,
"max_fg": 750.0,
"samples": 173
},
"Highlord": {
"median_fg": 25.0,
"avg_fg": 113.0,
"min_fg": 5.0,
"max_fg": 1500.0,
"samples": 70
},
"BK Ring": {
"median_fg": 20.0,
"avg_fg": 70.0,
"min_fg": 0.0,
"max_fg": 1200.0,
"samples": 161
},
"Mal Rune": {
"median_fg": 30.0,
"avg_fg": 67.0,
"min_fg": 3.0,
"max_fg": 750.0,
"samples": 166
},
"Insight": {
"median_fg": 40.0,
"avg_fg": 189.9,
"min_fg": 5.0,
"max_fg": 3500.0,
"samples": 155
},
"Pul Rune": {
"median_fg": 30.0,
"avg_fg": 69.6,
"min_fg": 5.0,
"max_fg": 750.0,
"samples": 113
},
"CTA": {
"median_fg": 50.0,
"avg_fg": 251.5,
"min_fg": 5.0,
"max_fg": 3500.0,
"samples": 171
},
"Blessed": {
"median_fg": 30.0,
"avg_fg": 180.3,
"min_fg": 7.0,
"max_fg": 1200.0,
"samples": 36
},
"Ohm Rune": {
"median_fg": 25.0,
"avg_fg": 39.8,
"min_fg": 3.0,
"max_fg": 400.0,
"samples": 119
},
"Java SK": {
"median_fg": 20.0,
"avg_fg": 47.0,
"min_fg": 1.0,
"max_fg": 450.0,
"samples": 58
},
"Vampire Gaze": {
"median_fg": 10.0,
"avg_fg": 110.6,
"min_fg": 0.0,
"max_fg": 1500.0,
"samples": 80
},
"Giant": {
"median_fg": 20.0,
"avg_fg": 96.1,
"min_fg": 0.0,
"max_fg": 600.0,
"samples": 70
},
"Ber Rune": {
"median_fg": 60.0,
"avg_fg": 253.1,
"min_fg": 5.0,
"max_fg": 1200.0,
"samples": 58
},
"Key": {
"median_fg": 20.0,
"avg_fg": 83.1,
"min_fg": 1.0,
"max_fg": 1200.0,
"samples": 114
},
"Shako": {
"median_fg": 30.0,
"avg_fg": 113.6,
"min_fg": 0.0,
"max_fg": 1500.0,
"samples": 208
},
"Pcomb SK": {
"median_fg": 160.0,
"avg_fg": 685.0,
"min_fg": 5.0,
"max_fg": 3000.0,
"samples": 48
},
"Sacred": {
"median_fg": 35.0,
"avg_fg": 166.1,
"min_fg": 0.0,
"max_fg": 3500.0,
"samples": 244
},
"Light Jewel": {
"median_fg": 80.0,
"avg_fg": 234.0,
"min_fg": 20.0,
"max_fg": 1500.0,
"samples": 39
},
"Fist": {
"median_fg": 20.0,
"avg_fg": 51.7,
"min_fg": 15.0,
"max_fg": 120.0,
"samples": 9
},
"Vex Rune": {
"median_fg": 30.0,
"avg_fg": 97.2,
"min_fg": 5.0,
"max_fg": 1500.0,
"samples": 132
},
"Unid Anni": {
"median_fg": 35.0,
"avg_fg": 45.0,
"min_fg": 3.0,
"max_fg": 150.0,
"samples": 96
},
"Light SK": {
"median_fg": 40.0,
"avg_fg": 383.2,
"min_fg": 5.0,
"max_fg": 3500.0,
"samples": 41
},
"Mara": {
"median_fg": 50.0,
"avg_fg": 146.1,
"min_fg": 5.0,
"max_fg": 1500.0,
"samples": 132
},
"Cold SK": {
"median_fg": 50.0,
"avg_fg": 162.9,
"min_fg": 7.0,
"max_fg": 1200.0,
"samples": 45
},
"Black": {
"median_fg": 30.0,
"avg_fg": 106.7,
"min_fg": 0.0,
"max_fg": 2400.0,
"samples": 199
},
"Jah Rune": {
"median_fg": 60.0,
"avg_fg": 267.5,
"min_fg": 0.0,
"max_fg": 1200.0,
"samples": 87
},
"Troll": {
"median_fg": 30.0,
"avg_fg": 32.1,
"min_fg": 1.0,
"max_fg": 75.0,
"samples": 66
},
"Ward": {
"median_fg": 10.0,
"avg_fg": 21.8,
"min_fg": 1.0,
"max_fg": 50.0,
"samples": 45
},
"White": {
"median_fg": 20.0,
"avg_fg": 37.6,
"min_fg": 3.0,
"max_fg": 300.0,
"samples": 126
},
"Stealth RW": {
"median_fg": 32.5,
"avg_fg": 31.7,
"min_fg": 5.0,
"max_fg": 50.0,
"samples": 24
},
"Doom": {
"median_fg": 50.0,
"avg_fg": 285.3,
"min_fg": 45.0,
"max_fg": 1500.0,
"samples": 30
},
"Gul Rune": {
"median_fg": 30.0,
"avg_fg": 50.4,
"min_fg": 0.0,
"max_fg": 280.0,
"samples": 136
},
"Death's Fathom": {
"median_fg": 110.0,
"avg_fg": 96.7,
"min_fg": 60.0,
"max_fg": 120.0,
"samples": 12
},
"Unid Torch": {
"median_fg": 60.0,
"avg_fg": 49.5,
"min_fg": 5.0,
"max_fg": 120.0,
"samples": 39
},
"Sanctuary": {
"median_fg": 85.0,
"avg_fg": 82.5,
"min_fg": 5.0,
"max_fg": 170.0,
"samples": 24
},
"Grief": {
"median_fg": 50.0,
"avg_fg": 170.2,
"min_fg": 5.0,
"max_fg": 1500.0,
"samples": 56
},
"Night": {
"median_fg": 250.0,
"avg_fg": 209.0,
"min_fg": 20.0,
"max_fg": 700.0,
"samples": 15
},
"5/5 Facet": {
"median_fg": 50.0,
"avg_fg": 313.3,
"min_fg": 10.0,
"max_fg": 1300.0,
"samples": 15
},
"Treachery": {
"median_fg": 20.0,
"avg_fg": 157.0,
"min_fg": 5.0,
"max_fg": 1300.0,
"samples": 30
},
"Pala Torch": {
"median_fg": 50.0,
"avg_fg": 215.6,
"min_fg": 20.0,
"max_fg": 1500.0,
"samples": 43
},
"Bulwark": {
"median_fg": 50.0,
"avg_fg": 440.0,
"min_fg": 50.0,
"max_fg": 1300.0,
"samples": 10
},
"Fortitude": {
"median_fg": 70.0,
"avg_fg": 915.0,
"min_fg": 20.0,
"max_fg": 3500.0,
"samples": 16
},
"Cham Rune": {
"median_fg": 20.0,
"avg_fg": 22.5,
"min_fg": 5.0,
"max_fg": 60.0,
"samples": 53
},
"War Traveler": {
"median_fg": 20.0,
"avg_fg": 33.0,
"min_fg": 5.0,
"max_fg": 80.0,
"samples": 33
},
"Oculus": {
"median_fg": 20.0,
"avg_fg": 29.3,
"min_fg": 5.0,
"max_fg": 90.0,
"samples": 63
},
"Moser": {
"median_fg": 12.5,
"avg_fg": 12.5,
"min_fg": 5.0,
"max_fg": 20.0,
"samples": 16
},
"Hellfire": {
"median_fg": 60.0,
"avg_fg": 53.3,
"min_fg": 20.0,
"max_fg": 80.0,
"samples": 12
},
"Conviction": {
"median_fg": 20.0,
"avg_fg": 13.7,
"min_fg": 1.0,
"max_fg": 20.0,
"samples": 6
},
"Terror Key": {
"median_fg": 5.0,
"avg_fg": 6.7,
"min_fg": 5.0,
"max_fg": 10.0,
"samples": 12
},
"Hate Key": {
"median_fg": 5.0,
"avg_fg": 6.7,
"min_fg": 5.0,
"max_fg": 10.0,
"samples": 12
},
"Dual Leech Ring": {
"median_fg": 50.0,
"avg_fg": 198.8,
"min_fg": 20.0,
"max_fg": 1500.0,
"samples": 16
},
"Cyclone": {
"median_fg": 300.0,
"avg_fg": 306.7,
"min_fg": 20.0,
"max_fg": 600.0,
"samples": 12
},
"Fury": {
"median_fg": 30.0,
"avg_fg": 76.9,
"min_fg": 5.0,
"max_fg": 250.0,
"samples": 32
}
},
"day_38": {},
"day_39": {},
"day_40": {},
"day_41": {},
"day_42": {},
"day_43": {},
"day_44": {},
"day_45": {},
"day_46": {},
"day_47": {},
"day_48": {},
"day_49": {},
"day_50": {},
"day_51": {},
"day_52": {},
"day_53": {},
"day_54": {},
"day_55": {},
"day_56": {},
"day_57": {},
"day_58": {},
"day_59": {},
"day_60": {}
"day_21": {}
}
}
+7
View File
@@ -130,6 +130,13 @@ corpse=459,195,414,213
chat_icon=7,555,43,41
left_panel_header=0,0,455,56
right_panel_header=830,0,455,56
; A CENTRED panel (Chronicle, and anything else that opens mid-screen) puts its
; close button outside both header ROIs above — the Chronicle's X sits at
; (952, 56), inside right_panel_header's x-range but exactly at its 56px height
; boundary, so the match centre falls out. Nothing then closes it, and a
; centred panel blanks every later template search: measured as a 66s
; click_red_portal failure on 2026-08-28 (Bug 31's residual).
center_panel_header=400,0,620,92
npc_dialogue=456,0,30,150
bind_skill=516,619,251,29
quest_skill_btn=284,455,710,121
+219 -37
View File
@@ -20,7 +20,7 @@
; If you chicken/die repeatedly, drop to Nightmare first.
difficulty=hell
; name: bot profile name used in logs/messages and mod launch option replacement
name=fistman
name=profile1
; randomize_runs: 0 = run in listed order, 1 = shuffle run order
randomize_runs=0
; target_tz: target Terror Zone id (leave as default unless you know the mapping)
@@ -55,14 +55,23 @@ discord_log_errors=1
discord_status_runs=10
; discord_status_count: legacy fallback, send periodic status every X games (blank/0 disables)
discord_status_count=20
; discord_timing_report_h: post a timing + failure digest to Discord every N hours
; (0 disables). Aggregated from the same TL> timeline the log uses, so the report and the
; log can never disagree. The window resets on every send.
discord_timing_report_h=2
; pickup_drought_window: warn/alert after this many consecutive games with zero
; item pickups. Raise this if you run a strict pickit and 0-pickup streaks are
; expected/normal for you (a fast boss-only rush route with a tight filter can
; easily go 10 games without a keep-worthy drop).
pickup_drought_window=10
; message_api_type: "" disables messaging, "discord" or "generic_api"
message_api_type=discord
; breaks
; break_length_m: scheduled break duration in minutes (0 = disabled)
break_length_m=0
break_length_m=15
; max_runtime_before_break_m: runtime before taking scheduled break (0 = disabled)
max_runtime_before_break_m=0
max_runtime_before_break_m=120
; timers / fail handling
; d2r_path: Diablo II: Resurrected install path
@@ -78,7 +87,7 @@ max_game_length_s=900
; max_maintenance_time_s: if the town maintenance loop (heal/buy/stash/repair) takes
; longer than this many seconds, save-and-exit and rejoin a fresh game.
; Prevents the bot staying stuck in A5 town forever when NPCs or pathing fail.
max_maintenance_time_s=240
max_maintenance_time_s=420
; auto_downgrade_threshold: if combined chickens+deaths exceed this number within 1 hour,
; bot automatically lowers difficulty by one tier (hell->nightmare->normal) and restarts.
; Set to 0 to disable. (NOTE: feature is parsed but not yet active in bot logic)
@@ -130,12 +139,22 @@ error=1
[stealth]
; Multiplies all wait() calls by a random value in this range each call
; 1.0 = no change. Set range wider for more human-like timing variation.
wait_jitter_min = 0.85
; wait_jitter_* multiplies EVERY wait() in the codebase, not just mouse moves.
; The floor is clamped to jitter_min*0.8, so 0.85 allowed waits to come out 32%
; SHORT — the one place jitter stole time from an action instead of adding it
; between actions. 0.95 keeps waits at-or-longer (effective floor 0.76 vs the
; old 0.68, and the Gaussian centre sits above 1.0), so a wait can no longer
; expire before the UI it was waiting on has settled.
wait_jitter_min = 0.95
wait_jitter_max = 1.20
; Extra pixel variance added to every mouse click (on top of existing randomize=5)
; 0 = off, 10 = +/-10px extra random offset per click
click_variance = 8
; NOTE: click position randomization lives at the CALL SITES (npc_manager,
; ui/waypoint) where it is tuned to real button geometry — 2-3px for NPC
; hover targets, +/-9px inside a 47px waypoint button. There is deliberately
; no global click-offset knob: stacking one on top of those is what starts
; missing NPCs (see CLAUDE.md Bugs 3/4/6/7).
; Re-shuffle run order after completing a full rotation (vs only at session start)
; 0: keep [pindle, diablo] fixed so every game ENDS in A4 town (Diablo run TPs
@@ -151,7 +170,14 @@ skip_run_chance = 10
afk_break_chance = 5
; Break duration range in minutes
afk_break_min_m = 2
afk_break_max_m = 12
; 12 -> 7 (2026-08-28). The configured value is NOT the real ceiling:
; maybe_afk_break does wait(minutes*60, minutes*60*1.5) and wait() then applies
; its own jitter (up to 1.44x), so a "12 minute" break can idle for ~26 minutes.
; Measured: an 11.9m break ran 1167.7s = 19.5m, and a 20:56 scheduled break ran
; 25.5m. A ~25m idle is what left D2R at character select once, unable to
; re-enter, with the bot spawning a new process every ~20s.
; 7 caps the real-world worst case near 15m, inside the range proven to resume.
afk_break_max_m = 7
; Vary run/battle duration by a Gaussian factor (default +/-15%)
; 0.0 = no variation, 0.3 = up to +/-30% variation
@@ -162,6 +188,8 @@ micro_pause_min_ms = 20
micro_pause_max_ms = 120
; Vary kill time to avoid perfectly consistent boss fight durations
; Varies boss attack duration. Only ever LENGTHENS the fight (1.0-1.4x) —
; shortening it risks leaving a boss alive, which is a failed run, not stealth.
vary_kill_time = 1
; Human mouse curve complexity (1.0 = default, 0.5 = more direct, 1.5 = more winding)
@@ -169,8 +197,13 @@ human_curve_complexity = 1.0
; Arrival-to-click delay: human-like pause between mouse arriving and clicking (milliseconds)
; 50-800ms range simulates "is this the right thing?" hesitation
; Hesitation between arriving at a target and clicking it. OFF by default:
; it applies to EVERY click, so the old 800ms ceiling could add minutes per
; run. Enable with click_delay_enabled=1 if you want it; 250 is a realistic
; ceiling that stays affordable.
click_delay_enabled = 0
click_delay_min_ms = 50
click_delay_max_ms = 800
click_delay_max_ms = 250
; Key press duration variance: how long a key is held (milliseconds)
; Most presses are short (20-100ms), some linger (up to 200ms)
@@ -184,11 +217,57 @@ skill_hesitation_max_ms = 300
; Wrong waypoint chance: 2-3% of selecting wrong TP portal then correcting
; Humans occasionally misclick waypoint targets
; ── session rhythm ───────────────────────────────────────────────────────────
; The strongest remaining signal is not per-click timing: averaged over a
; session jitter converges, but a player who starts at the same hour, plays the
; same length and never does anything unproductive does not.
;
; session_budget_h: stop the bot after roughly this many hours (0 = unlimited).
; Actual length is rolled per run at 0.65-1.35x, so consecutive days differ.
; Raised to 10 for an overnight run: the budget is rolled at 0.65-1.35x, so 6
; could have stopped the bot after 3.9h and left it idle. 10 guarantees at
; least ~6.5h while keeping the day-to-day variation.
; Set to 8 for a >= 5h run (2026-08-28). The value is rolled at 0.65-1.35x, so
; session_budget_h=5 would average 5h but could stop after 3.25h. 8 gives a
; 5.2-10.8h window, guaranteeing the 5 hours while keeping day-to-day variation.
session_budget_h = 8
; daily_budget_h: HARD cap on total runtime per CALENDAR DAY (0 = unlimited).
; session_budget_h above is per PROCESS — a restart re-rolls it, so a bot that
; gets restarted (by you, or by the restart-on-crash path) can run all day and
; never trip it. This cap is persisted to log/.daily_runtime.json keyed on the
; date, so restarts cannot extend the day. The target is rolled ONCE per day
; (+/- daily_budget_jitter) and then FIXED, so restarting cannot re-roll a
; larger allowance. This is a CEILING, not an average: the jitter only ever
; subtracts, so daily_budget_h = 8 with 0.12 jitter runs 7.0-8.0h and NEVER
; more than 8. session_budget_h above can still stop a single process sooner.
daily_budget_h = 8
daily_budget_jitter = 0.12
; daily_budget_close_game: 1 = also close D2R when the daily cap trips (default).
; The cap fires in on_end_game, AFTER save-and-exit, so nothing is in progress and
; nothing is lost. A bot parked at character select for 16h is itself a pattern;
; a real player quits the game. Set 0 to leave D2R running.
; Small cursor movement during long idles. Between actions the cursor otherwise
; sits exactly where the last click left it.
idle_drift_enabled = 1
; Per-game chicken threshold spread. Only ever RAISES the threshold (safer) —
; a fixed 0.40 every game is a precise tell, but lowering it would cost deaths.
chicken_variance = 0.08
; Chance of an unproductive town action (open inventory, close it).
town_browse_chance = 0.06
; Chance of walking past an item the filter wanted. Costs real loot — keep low.
pickup_skip_chance = 0.02
wrong_waypoint_chance = 0.025
; Skill mistake chance: 1-2% chance of miscasting and correcting
; Simulates human error during combat
skill_mistake_chance = 0.015
; Presses a DIFFERENT bound skill before the intended one. Disabled: on a
; character mid-fight this swaps the active skill or aura, and the attack
; sequences rely on ending in a known skill state. The stealth value is small
; next to the risk of a stray keypress landing on something that is not a skill
; at all — which is exactly how the loot filter ended up being toggled all
; session (params.ini [paladin] vigor=f4, unbound on this char, F4 = filter).
; Set back to 0.015 to re-enable.
skill_mistake_chance = 0
[routes]
; Controls which farm runs Botty performs each game.
@@ -211,7 +290,8 @@ skill_mistake_chance = 0.015
; run_countess (Act 1 Forgotten Tower)
; run_mephisto (Act 3 Durance of Hate)
; run_baal (Act 5 Throne of Destruction)
order=run_pindle, run_diablo
; run_baal_xp (Join public Baal games, hide, collect XP, leave — see [baal_xp])
order=run_baal_xp, run_trav
[char]
; ==========================
@@ -224,9 +304,9 @@ type=hammerdin
; belt_rows: in-game belt size (2/3/4)
belt_rows=4
; casting_frames: your char cast breakpoint (affects action timing)
casting_frames=8
casting_frames=11
; cta_casting_frames: cast breakpoint on CTA swap (if used)
cta_casting_frames=8
cta_casting_frames=11
; attack_frames: base attack animation timing for non-cast attacks
attack_frames=15
; cta_available: 1 if Call to Arms swap exists, else 0
@@ -239,7 +319,19 @@ safer_routines=1
; num_loot_columns: Number of empty columns from left to right of inventory to be used for looting.
; Store charms, etc. to the right of the inventory.
num_loot_columns=4
;
; This drives ui_roi[restricted_inventory_area] in config.py: the columns to the RIGHT of
; this count are protected — mouse._is_clicking_safe() cancels any click landing there, so
; the bot never sells, drops or moves anything in them. Both tomes MUST live in that
; reserved area: common.tome_state() only searches restricted_inventory_area for them.
; 3 columns = 12 loot slots, 28 reserved
; 4 columns = 16 loot slots, 24 reserved <- now
; 6 columns = 24 loot slots, 16 reserved (tried 2026-08-27, reverted — shrinking the
; reserve is the wrong direction when the
; books and the charms both live there)
; Lower this if the tomes or charms are running out of room; raise it only if the reserved
; area is provably empty.
num_loot_columns=5
; game hotkeys:
; NOTE: each key must match your in-game binding exactly
@@ -255,13 +347,13 @@ show_items=alt
; stand_still cannot be the default "shift" as it would interfere with merc healing
stand_still=capslock
; teleport: leave empty if you can't use
teleport=b
town_portal=6
teleport=f4
town_portal=f8
; call to arms settings:
; weapon_switch/battle_orders/battle_command only used when cta_available=1
weapon_switch=w
battle_orders=7
battle_command=8
battle_orders=f6
battle_command=f5
; ==========================
; ==== Optional configs ====
@@ -306,7 +398,7 @@ atk_len_nihlathak=4.0
atk_len_pindle=8.0
atk_len_shenk=4.0
atk_len_diablo=3.0
atk_len_trav=4.0
atk_len_trav=5.0
; Boss run attack lengths (per-character defaults in kill_* methods)
; Adjust these if your build is faster/slower against these bosses
@@ -327,9 +419,9 @@ cs_town_visits=0
kill_cs_trash=1
; Belt settings
belt_hp_columns=1
belt_hp_columns=2
belt_mp_columns=1
belt_rejuv_columns=2
belt_rejuv_columns=1
; Potion/chicken settings
take_health_potion=0.60
@@ -349,23 +441,55 @@ fill_shared_stash_first=0
; to gamble, add any/all of the following: circlet, ring, coronet, talon, amulet
gamble_items=
; open_chests: 1 = open clickable chests along path when possible
open_chests=1
open_chests=0
; pre_buff_every_run: 1 = always recast buffs at run start
pre_buff_every_run=1
; runs_per_repair: visit repair vendor every X runs (blank/0 disables)
runs_per_repair=5
runs_per_repair=50
; repair_npc: preferred repair vendor strategy.
; - a5_larzuk (recommended: stays in A5, no cross-act trip; falls back to Halbu)
; - a4_halbu (requires WP trip to A4 every repair — act desync risk if it fails)
; 2026-06-10: switched to a5_larzuk — session logs showed Halbu detection failing
; 100% (body score ~0.39) and each failed A4 trip desynced the bot's act state.
repair_npc=a5_larzuk
; resurrect_npc: which NPC revives the mercenary. Blank = whichever act the character is
; already in. Set to a4_tyrael to always use Act 4 Tyrael.
; Measured 2026-08-27 on this setup:
; 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. A failed hunt costs more than the whole A5->A4 trip, and one 163s hunt pushed
; the town visit past max_maintenance_time_s and killed the game.
resurrect_npc=a4_tyrael
; runs_per_stash: stash/sell every X runs (blank/0 disables)
runs_per_stash=4
runs_per_stash=1
; sell_junk: 1 = vendor non-keep items automatically
sell_junk=1
; protect_shields_from_sell: 1 = never vendor items with "shield" in detected name (recommended safety)
protect_shields_from_sell=1
sell_junk=0
; protect_shields_from_sell: 1 = never vendor items with "shield" in detected name.
; DISABLED 2026-08-27. This is redundant: 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 of leaving it on: 130 blocked sells in one day (FIEND SHIELD 64, AERIN SHIELD 41,
; HERALDIC SHIELD 23...). The same shields were re-evaluated and re-blocked every single
; game — "Discarding FIEND SHIELD." then "Blocked sell for protected item: FIEND SHIELD" —
; so they could never leave the pack and permanently occupied slots.
protect_shields_from_sell=0
; protect_charms_from_sell: 1 = never drop/vendor charms regardless of pickit verdict
; (safety net — a misread charm can't be un-sold). Set to 0 to let your pickit rules
; decide keep/discard for charms same as any other item.
;
; KEEP THIS AT 1. Charms only give their bonus while they sit in the INVENTORY, and the
; resistances matter in nightmare. Briefly set to 0 on 2026-08-27 to reclaim slots and
; that was wrong — it vendored LARGE CHARM OF FIRE, STOUT SMALL CHARM, SMALL CHARM OF
; FLAME, STOUT SMALL CHARM OF STRENGTH and LAPIS SMALL CHARM OF VITA (+20 life,
; cold resist +7%) before it was reverted.
;
; The "673 blocked sells" in the log are NOT a bug to fix by selling — they are the guard
; doing its job, repeatedly, on charms the pickit does not have a keep rule for. If those
; slots are needed, the answer is a pickit rule that keeps the good charms and stops
; picking up junk ones, NOT disabling this guard.
protect_charms_from_sell=0
; pick_rares_for_gold: 1 = pick all yellow (rare) ground items; non-keep rares will be sold
; Requires sell_junk=1 to convert extra pickups into gold.
pick_rares_for_gold=0
@@ -377,14 +501,21 @@ pick_gold=1
stash_destination=0,1,2,3
; Add these possible gems to "transmute" to transmute them:
; chipped, flawed, standard, flawless
; DISABLED 2026-08-26: gems are stashed to the GEMS tab and left alone, no cube/convert
; runs. An empty value makes run_transmutes() bail at "No gem tiers configured", which
; also holds for force=True (tools/gem_transmute.py), unlike transmute_every_x_game=0.
; Re-enable by listing tiers again, e.g. transmute=flawless
transmute=flawless
;how often we want to run transmute routine(e.g. every 100 games)
transmute_every_x_game=60
; number of stash tabs (ROTW has more than 6, adjust as needed)
stash_tabs=6
transmute_every_x_game=800
; number of stash tabs — drives tab click geometry in inventory/common.tab_properties().
; MUST match the tab bar on screen. This client shows 5: PERSONAL SHARED GEMS MATERIALS RUNES.
; It was 6, which made tab_properties compute centres of 63/127/192/256/320/384 while the
; real label centres are 68/144/220/295/370 — tabs 2, 3 and 4 clicked the gaps between tabs.
stash_tabs=5
; potion transmute settings
; convert_rejuv: 1 = convert regular Rejuv Potions to Full Rejuv via cube (3 -> 1)
convert_rejuv=1
convert_rejuv=0
; min_rejuv_to_convert: minimum regular rejuv potions in inventory before starting conversion
min_rejuv_to_convert=6
@@ -434,10 +565,10 @@ hydra=f1
; ==== Builds: Paladin ====
; =========================
[paladin]
cleansing=f9
holy_shield=f2
redemption=f3
vigor=f4
cleansing=
holy_shield=f4
redemption=f2
vigor=f3
[fohdin]
; foh must be left skill, hotkey required
@@ -448,9 +579,9 @@ foh=f6
holy_bolt=f7
[hammerdin]
blessed_hammer=f1
concentration=f8
conviction=f5
blessed_hammer=f9
concentration=f1
conviction=
; =========================
; ==== Builds: Warlock ====
@@ -603,6 +734,53 @@ buff_1=
buff_2=
; ==== Run: Cold Plains clear ====
; Roams an outdoor area and kills with ONE configured skill. Works with any build
; and from clvl 1, so it is the levelling counterpart to the boss runs.
; Enable by adding run_cold_plains to [routes] order above.
;
; PREREQUISITE: the character must already have the destination waypoint.
; A fresh character has none - walk there once by hand first.
[cold_plains]
; Any Act 1 waypoint label from src/ui/waypoint.py _WAYPOINTS, e.g.
; "Cold Plains", "Stony Field", "Dark Wood", "Black Marsh".
area=Cold Plains
; Key holding the attack skill. NOTE Fireball is clvl 12 - on a fresh character
; put Fire Bolt (clvl 1) here and re-bind to Fireball later; nothing else changes.
attack_hotkey=
; Most sorc/caster skills cast on right click. Melee builds want "left".
attack_button=right
; How many roam-or-fight steps before heading back to town.
max_steps=12
; Casts per engagement before re-scanning for targets.
casts_per_target=4
; Ignore anything further than this many px from the character.
target_radius=600
; Delay between casts. Raise if you out-run your cast animation.
cast_delay=0.25
; Hard runtime cap per run, seconds - stops a bad area pinning the bot.
max_runtime_s=180
; Engagements per step, so an unkillable/misdetected target cannot stall the run.
max_engagements=8
[baal_xp]
; Baal XP farm: join public Baal games, hide, collect XP, leave. Repeat.
; Enable by adding run_baal_xp to [routes] order above.
enabled=1
; Substring to match against game names (case-insensitive). Empty = join first game.
game_name_filter=
; Max seconds to wait in a game before leaving (XP keeps ticking while you hide).
max_wait_s=900
; Leave early if XP gained in this game reaches this value.
xp_threshold=50000000
; Leave immediately if HP drops below this percentage (chicken).
min_hp_pct=40
; Screen position (client coords) to walk to and stand still while hiding.
hide_x=640
hide_y=360
; Max seconds to wait for a matching game to appear in the browser.
join_timeout_s=60
[advanced_options]
; startup hotkeys
; select_runs_key: open run selector UI
@@ -652,6 +830,10 @@ window_client_area_offset=0,0
; Each managed directory has a max file count and max total size.
; When limits are exceeded, oldest files are deleted automatically.
; Checked every 60 seconds (to avoid I/O overhead during runs).
;
; The text log (log/log.txt) is size-capped separately: it rotates at 50 MB,
; keeps 5 zipped backups in log/archive/ (max 30 archives). Override the per-file
; cap with the BOTTY_LOG_MAX_MB environment variable (e.g. set 100 for 100 MB).
; pickit directory (log/screenshots/pickit/):
; Can grow very fast — every item scan writes a PNG + JSON.
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Submodule dependencies/tesserocr_source deleted from fe9d88d4a6
+87
View File
@@ -0,0 +1,87 @@
# GEMS-tab transmute flow (rewritten in commit 48d8445)
`src/transmute/transmute.py``Transmute.convert_all_gems()` and helpers.
## What changed and why
The old flow drove gem upgrades through the **Horadric Cube UI**: it required the cube to live in
the PERSONAL stash tab, opened the cube, ctrl+shift+right-clicked 3 gems into it, clicked the cube's
Transmute button, then pulled the result back out. This was fragile — it depended on cube placement,
cube-open detection (`ScreenObjects.CubeOpened` via `wait_until_visible`), and a pre-flight
"empty the cube" pass.
The rewrite uses D2R's **native GEMS-tab convert panel** instead of the cube. Gems are moved
directly between the GEMS stack grid and the convert panel, and conversion uses the GEMS tab's own
convert button. The cube is no longer required to be in the stash and, if present in the character
inventory, is left untouched.
## Fixed stack-coordinate grid
`GEMS_TAB_STACK_COORDS` is a new module-level dict mapping every gem template
(`INVENTORY_<FAMILY>_<TIER>`) to a fixed `(x, y)` **screen** coordinate at 1280×720. The grid is:
- **Columns by family** (x): diamond 85, emerald 132, ruby 180, topaz 227, amethyst 275,
sapphire 322, skull 370.
- **Rows by tier** (y): chipped 154, flawed 195, standard 234, flawless 273, perfect 311.
Because gem stacks always render at these fixed slots on the GEMS tab, the flow can click them by
coordinate instead of template-searching for them each time. `_gems_stack_monitor_for(template)`
converts the screen coord to monitor coords (returns `None` if the template isn't in the grid, so
the caller can fall back to a template search).
## Panel constants (1280×720)
| Constant | Value | Meaning |
|----------|-------|---------|
| `GEMS_TAB_X` | `223` | GEMS tab click X (was `200`) |
| `GEMS_CONVERT_BUTTON` | `(225, 500)` | native convert button in the GEMS panel |
| `GEMS_CONVERT_PANEL_ROI` | `(160, 296, 128, 160)` | ROI to search for the converted result |
| `GEMS_CONVERT_FIRST_SLOT` | `(181, 318)` | fallback slot to grab the result if the search misses |
## Per-transmute flow (new)
For each gem type/tier with enough gems to convert:
1. **Switch to GEMS tab.**
2. **Load 3 gems**`_ctrl_shift_left_click_monitor` the stack slot ×3 to move 3 gems into the
convert panel. If exactly 3 weren't loaded, abort that batch.
3. **Convert**`_click_gems_tab_convert_button()` clicks the panel's native convert button.
4. **Recover the result** — template-search `gems_out` within `GEMS_CONVERT_PANEL_ROI`; if found,
ctrl+shift+left-click it back onto the GEMS tab. If not found, fall back to ctrl+shift+left-click
on `GEMS_CONVERT_FIRST_SLOT` (instead of the old behavior of clearing the cube and bailing).
5. **Leave the inventory cube untouched** and return to the GEMS tab for the next iteration.
The old pre-flight "open cube → empty 12 cube slots → reopen stash" pass and the per-iteration
"close cube / reopen stash" steps are **removed**.
## Helper changes
- `_open_cube_from_stash()` / `_open_cube_from_gems_tab()` / `_open_available_cube_for_gems_tab()`
now operate from the **GEMS** tab rather than PERSONAL, and can open the cube from character
inventory (`_open_cube_from_inventory`) when the GEMS tab is active. These remain for older
inventory flows; the new gem flow doesn't need them.
- `_locate_cube()` can now return `'opened'` (cube UI already open on the GEMS tab) in addition to
`'stash'` / `'inventory'`.
- `_ensure_cube_available()` now treats `inventory` as usable ("it will stay there") instead of an
error — the cube no longer has to be moved to PERSONAL.
- `_ensure_cube_in_stash()` dropped the loot-column safety guard: it now ctrl+clicks any positive
`CubeInventory` match, so the cube can be moved from reserved inventory columns too.
- **Keyboard modifier handling** switched from the low-level
`win_input.key_down/key_up(_get_vk(...))` to `keyboard.send(..., do_release=False)` /
`keyboard.release(...)` wrapped in `try/finally`, so ctrl+shift are always released even if the
click raises. Applies to both `_ctrl_shift_click_monitor` (right) and
`_ctrl_shift_left_click_monitor` (left).
- `_empty_cube_to_gems_tab()` now uses `_ctrl_click_monitor` (plain ctrl) rather than ctrl+shift.
## Gem counting (`_count_gems_by_ocr`)
Counting no longer template-searches the left-inventory ROI for each gem. It now reads each fixed
slot from `GEMS_TAB_STACK_COORDS`: crop the slot icon, and **skip empty slots** by checking the
95th-percentile grayscale brightness (`< 45` ⇒ no gem present). Occupied slots still OCR the count
badge in the bottom-right quadrant. This is faster and immune to template-match drift, at the cost
of depending on the fixed 1280×720 grid.
## Resolution assumption
The whole flow is hard-coded to **1280×720**. All coordinates above are screen coordinates at that
resolution, converted to monitor coordinates at click time via `convert_screen_to_monitor`.
-258
View File
@@ -1,258 +0,0 @@
# Anti-Detection Framework for Botty-Go
## Overview
This document outlines the multi-layered anti-detection system built into botty-go.
Each layer addresses a specific detection vector that Blizzard and modern anti-cheat
systems use to identify bots.
---
## 1. Server-Side Behavior Analysis Countermeasures
### Detection: Session length, timing consistency, pathing patterns, repetition
### Countermeasures:
#### 1a. Variable Session Scheduling
- **Implementation:** `internal/schedule/scheduler.go`
- Randomized session start times using a circadian model
- Simulated human sleep patterns: 6-10 hour breaks between sessions
- Weekend/weekday behavior variance (humans play differently on weekends)
- Random session lengths: 20min to 6hours with exponential distribution
- Occasional "just 5 more minutes" overtime and "I'm tired" early stops
#### 1b. Stochastic Pathing
- **Implementation:** `internal/pather/stochastic.go`
- Add deliberate pathing imperfection: 5-15% deviation from optimal route
- Occasional wrong-way teleports followed by course correction
- Non-optimal waypoint selections (humants don't always take shortest path)
- Variable route ordering with cooldown-dependent choices
- 2-3% chance of "getting lost" and using wrong waypoint first
#### 1c. Skill Rotation Variance
- **Implementation:** `internal/char/behavior.go`
- Variable pre-buff timing (humans rush sometimes, sometimes take time)
- Occasional wrong skill selection followed by correction
- Potion usage with human-like hesitation (check multiple times before drinking)
- Merc healing variance: sometimes forget, sometimes over-heal
#### 1d. Route Randomization with Context
- **Implementation:** `internal/bot/route_planner.go`
- Dynamic route selection based on:
- Time since last run of each type
- Current TP scroll count (humans adapt)
- Gem/transmute urgency
- Occasional "feels like it" switches
- Never perfect round-robin; use weighted probability with drift
#### 1e. Farming Repetition Masking
- Never run the same route more than 8 times consecutively
- Insert "town breaks": stash visit, shrine check, repair, gamble
- 1-2% chance of "I'm bored, switching to different run" mid-session
- Vary kill strategies: sometimes rush, sometimes methodical
---
## 2. Warden / Client Integrity Countermeasures
### Detection: Loaded modules, injected DLLs, memory signatures, debuggers
### Countermeasures:
#### 2a. Pixel-Only Architecture (No Memory Access)
- **Implementation:** entire bot reads game state ONLY via screenshots
- NO memory reading, NO DLL injection, NO process hooking
- Same attack surface as a human with a camera pointed at the screen
- This is the #1 defense: if you only use screen capture + input simulation,
there's nothing to scan in process memory
#### 2b. Clean Process Environment
- **Implementation:** `internal/runtime/clean_env.go`
- Standard Go binary with no suspicious imports
- No debuggers, no memory readers, no process manipulation
- Run as a normal application, not injected
#### 2c. Overlay Avoidance
- Never draw on top of game window
- No window hooking or injection
- Screenshot from a separate thread, not an overlay
---
## 3. Input Pattern Analysis Countermeasures
### Detection: Synthetic inputs, smooth cursor paths, periodic inputs, no micro-corrections
### Countermeasures:
#### 3a. Human Motor Model
- **Implementation:** `internal/mouse/human_model.go`
- Full biomechanical mouse model based on Fitts' Law and human motion studies
- Real human mouse data characteristics:
- Multi-segment movement with micro-pauses (1-3 segments per motion)
- Acceleration curve: start slow, peak in middle, decelerate into target
- Endpoint micro-adjustments: 2-5 pixel wobble before click
- Inter-trial variability: each movement is unique even to same target
- Asymmetric error distribution: overshoot more right/down (human bias)
#### 3b. Click Timing Model
- **Implementation:** `internal/mouse/click_model.go`
- Variable time between "arriving" at target and clicking: 50ms-800ms
- Pressure curve: humans don't click at exact same speed
- Double-click rate varies naturally
- Occasional misses: 0.5-1% of clicks land slightly off (1-3px)
#### 3c. Keyboard Behavior Model
- **Implementation:** `internal/keyboard/human_model.go`
- Key press duration variance: not all keypresses are identical
- Typing rhythm for skill hotkeys: natural cadence with micro-pauses
- Occasional key repeat (holding too long = rapid fire)
- Realistic key-up/key-down timing ratios
#### 3d. Statistical Indistinguishability
- **Implementation:** `internal/input/stats.go`
- All input streams modeled from real human motion capture data
- Entropy analysis of output matches human baselines
- Auto-calibration: measure user's own input if they do manual play
- Periodically inject "manual-looking" variance spikes
---
## 4. Economy and Item-Flow Countermeasures
### Detection: Gold accumulation, rune farming, item transfer networks, mule behavior
### Countermeasures:
#### 4a. Natural Accumulation Rate
- **Implementation:** `internal/inventory/economy.go`
- Vary farming intensity: some sessions heavy, some light
- Match accumulation to stated playtime (more sessions = more loot)
- Occasionally "waste" items on gambling/repairs like a real player
#### 4b. Realistic Trading Patterns
- No mass item funneling
- If trading, do it in human-sized batches with natural pauses
- Vary trade partners and timing
#### 4c. Rune Farming Variance
- Don't farm the same runes every session
- Match rune acquisition to character progression
- Occasionally skip rune picks when "full"
---
## 5. Ban Wave Defense
### Detection: Delayed batch bans
### Countermeasures:
#### 5a. Graceful Degradation
- **Implementation:** `internal/runtime/safe_mode.go`
- If one account gets banned, immediately reduce intensity across all
- Auto-pause farming for 48-72 hours (simulating "taking a break")
- Gradual return with reduced session lengths
- Change behavior patterns after any ban event
#### 5b. Account Diversity
- Each account has distinct "personality":
- Different session timing preferences
- Different route preferences
- Different response timing distributions
- Different play styles (rusher vs methodical)
---
## 6. Server Authority Countermeasures
### Detection: Server-side validation of movement, drops, combat, inventory
### Countermeasures:
#### 6a. Server-Authoritative Behavior
- **Implementation:** `internal/bot/server_aware.go`
- Only interact with what the server actually shows
- Wait for server confirmation before acting (e.g., confirm item picked up)
- Respect server-enforced movement limits (no speed hacks)
- Process drops in game-authorized order
#### 6b. No Client Manipulation
- Never try to spoof packets, modify client, or exploit desync
- Purely reactive: see screen -> decide -> act -> wait for response
---
## 7. Social/Reporting System Countermeasures
### Detection: Player reports + telemetry correlation
### Countermeasures:
#### 7a. Social Stealth
- **Implementation:** `internal/social/stealth.go`
- Play during off-peak hours less suspiciously
- Avoid solo-public routes that attract attention
- Occasionally join other players' games (with reduced automation)
- Inherit human-like chat behavior if configured
---
## 8. Hardware/Identity Correlation Countermeasures
### Detection: IP patterns, hardware fingerprints, VMs, account clusters
### Countermeasures:
#### 8a. Clean Deployment
- **Implementation:** `internal/deploy/clean.go`
- Run on real hardware, not VMs
- Use residential IP, not datacenter
- One account per hardware profile
- No VPN/proxy during play sessions
---
## Implementation Architecture
```
internal/
├── input/ # Human-like input generation
│ ├── mouse_model.go # Fitts' Law mouse movement
│ ├── click_model.go # Human click timing
│ ├── keyboard_model.go # Keyboard behavior
│ └── stats.go # Statistical verification
├── behavior/ # High-level human behavior simulation
│ ├── scheduler.go # Session scheduling
│ ├── route_planner.go # Dynamic route selection
│ ├── fatigue.go # Simulated fatigue/boredom
│ └── personality.go # Per-account personality
├── economy/ # Economic behavior masking
│ ├── accumulation.go # Natural loot accumulation
│ └── trading.go # Human-like trading patterns
├── safe_mode/ # Graceful degradation
│ ├── detection.go # Ban wave detection
│ └── cooldown.go # Auto-pause and return
└── deploy/ # Clean deployment helpers
└── check.go # Pre-flight integrity checks
```
## Key Design Principles
1. **Statistical indistinguishability:** Output must be statistically
indistinguishable from real human input. We use actual human motion
capture data distributions, not made-up random numbers.
2. **Controlled imperfection:** A human is inefficient, forgetful, and
inconsistent. The bot should be too — but in a way that matches
real human distributions.
3. **No single fingerprint:** Every instance should have unique enough
characteristics that correlating two accounts is hard.
4. **Adaptability:** If behavior changes are detected, the system should
be able to recalibrate based on new data.
5. **Defense in depth:** No single countermeasure is sufficient. The
combination across all layers is what provides real protection.
-33
View File
@@ -1,33 +0,0 @@
# Botty-Go
D2R Pixel Bot rewritten in Go for cross-platform support (Linux + Windows).
Based on the Python Botty project (johannes-do/botty), this is a ground-up rewrite
in Go that maintains compatibility with the same config files, templates, and run
logic while adding native Linux support.
## Features
- Cross-platform: Linux (X11/Wayland) and Windows
- Same config format as original Botty (params.ini, game.ini, shop.ini)
- Template matching with OpenCV Go bindings
- Tesseract OCR for item identification
- Human-like mouse movement (Bezier curves)
- BNIP pickit language
- All original character builds (Sorc, Paladin, Necro, Barbarian, etc.)
- All original runs (Pindle, Eldritch, Shenk, Trav, Nihlathak, Arcane, Diablo)
## Building
```bash
# Linux
go build -o botty ./cmd/botty
# Windows (from Linux with cross-compile)
GOOS=windows GOARCH=amd64 go build -o botty.exe ./cmd/botty
```
## Configuration
Copy `config/` from the original Botty project. Params, routes, and character
config work identically.
-19
View File
@@ -1,19 +0,0 @@
# Legacy: Go Rewrite Design Notes
These docs are archived from an abandoned `~/git/botty-go` directory (May 2026).
That project was a planned ground-up Go rewrite of `johannes-do/botty` for
cross-platform (Linux + Windows) support. Only design docs existed — no `.go`
source was ever written.
The Python `my-botty` project (this repo) is the active path. These docs are
kept here as **reference material**, primarily for Milestone 2 (anti-detection /
stealth) of `~/.claude/plans/continue-the-make-up-sunny-honey.md`.
## Files
- **`ANTI_DETECTION.md`** — Multi-layer anti-detection framework. Covers
server-side behavior analysis countermeasures (session scheduling, stochastic
pathing, skill rotation variance) and more. Directly applicable as the design
basis for the Python stealth layer.
- **`GO_REWRITE_README.md`** — Original README of the abandoned Go project.
Context only — explains feature scope and what the rewrite was aiming for.
+154
View File
@@ -0,0 +1,154 @@
# Paladalla Melee Hunt Test Log
Date: 2026-08-23
Workspace: `C:\Users\alex\my-botty`
Character/profile: `paladalla`
Script: `tools/melee_hunt.py`
## Current State
- Testing was stopped at the user's request.
- No `melee_hunt` Python process was running when checked.
- Paladalla was alive in the Rogue Encampment at the end of the session.
- The four visible belt potions were manually consumed. The last screenshot shows
partial life and an empty belt, so refill and fully heal before the next run.
- The game must not be controlled automatically until the user asks to resume.
Final-state screenshot: [healed_from_belt.png](../log/screenshots/manual/healed_from_belt.png)
## Commands Used
The successful short run used:
```powershell
C:\Users\alex\miniforge3\envs\botty\python.exe tools\melee_hunt.py --debug --button left
```
The later long tests used:
```powershell
C:\Users\alex\miniforge3\envs\botty\python.exe tools\melee_hunt.py --debug --button left --minutes 1440
```
`F12` is intended to stop the script. It worked during one run but was not
reliable while an in-game overlay was open. `Ctrl+C` in the script terminal was
the reliable emergency stop.
## Verified Results
### Initial ten-minute run
- Started outside town and completed its configured timer normally.
- Result: `engaged 107, roamed 15`.
- XP changed from `261/500` to `357/500`.
- Final reported life was `69%`.
- The process then exited because the default duration is ten minutes. Paladalla
later died because nothing was controlling or protecting the character.
### Corpse recovery and level gain
- The death prompt was cleared and the game was cycled through Save and Exit.
- The corpse appeared beside Paladalla in town and was recovered successfully.
- Weapon and attack slots were restored.
- Paladalla was manually routed across the bridge into the Blood Moor.
- Manual movement/attack clicks later produced a level-up. The next script start
read XP as `564/1500`, confirming level 2 and working melee damage.
Evidence:
- [corpse_recovered.png](../log/screenshots/manual/corpse_recovered.png)
- [blood_moor_ready2.png](../log/screenshots/manual/blood_moor_ready2.png)
- [deep_moor_restart.png](../log/screenshots/manual/deep_moor_restart.png)
## Failures Found
### 1. Ten-minute default looked like a crash
The first hunt was not dead or hung. It reached its default ten-minute deadline
and exited cleanly. Long unattended tests must pass an explicit `--minutes`
value or implement a true continuous mode.
### 2. Friendly bridge NPC was treated as motion target
The first 24-hour attempt remained near the town bridge. At the two-minute
checkpoint it showed repeated attacks, full life, and unchanged XP
(`357/500`). The screenshot showed it attacking the friendly rogue near the
bridge. Motion alone cannot distinguish that NPC from a monster.
Evidence: [hunt_2min_checkpoint.png](../log/screenshots/manual/hunt_2min_checkpoint.png)
Workaround used: stop the script and move several screens deeper into the Blood
Moor before restarting. A permanent NPC rejection/stall detector is still
needed.
### 3. Animated HUD controls were treated as targets
After reaching level 2, the pulsing new-stat/new-skill controls appeared above
the original HUD mask. The motion detector could target that region. During the
second long attempt an in-game Loot Filter overlay opened and the script kept
issuing clicks behind it.
Evidence: [restart_live_40s.png](../log/screenshots/manual/restart_live_40s.png)
### 4. No-potion condition was unsafe
Life fell through `18%`, `14%`, `10%`, `8%`, and eventually `0%`. The script
logged `no healing potion in belt!` but continued its loop. Belt detection also
reported no potion even though red potions were visibly present, so belt
detection or its profile assumptions need separate investigation.
### 5. XP OCR became invalid after level-up/UI changes
The log reported XP OCR values such as `'-'` and `''`, then incorrectly printed
`*** LEVEL UP *** now 0/0`. Invalid OCR should not be interpreted as a level-up,
and `xp_last` should retain the last valid sample.
## Code Changes Made
`tools/melee_hunt.py` was changed in two ways:
1. The target scan bottom boundary was raised from `640` to `510`, excluding the
animated level-up/stat controls and the interactive HUD from motion targets.
2. Failure to detect or use a healing potion at the configured health threshold
now raises `SafetyStop`, exits the hunt loop, and prints a safety-stop reason.
The edited script passed:
```powershell
C:\Users\alex\miniforge3\envs\botty\python.exe -m py_compile tools\melee_hunt.py
```
These changes were not runtime-verified after editing because testing was
stopped. The narrower scan may ignore monsters near the bottom of the viewport;
that tradeoff must be checked visually.
## Next Test Checklist
1. Confirm no old hunt process is running.
2. Refill the belt with healing potions and fully heal at Akara.
3. Save and Exit, re-enter, and recover any corpse if one appears.
4. Spend or dismiss the new stat/skill notifications if practical.
5. Walk across the bridge and several screens into the Blood Moor before launch.
6. Start with a short five-minute test, not a 24-hour run:
```powershell
C:\Users\alex\miniforge3\envs\botty\python.exe tools\melee_hunt.py --debug --button left --minutes 5
```
7. Capture and inspect screenshots at launch, two minutes, and five minutes.
8. At each checkpoint verify: outside town, no modal open, attacking a hostile,
XP increasing, life stable, movement occurring, and no repeated target lock.
9. Force a controlled low-health/no-potion condition only after normal combat is
proven, and verify `SAFETY STOP` exits immediately.
10. Use `Ctrl+C` if any modal opens, XP remains unchanged across two checkpoints,
life drops rapidly, or the character approaches the town bridge NPC.
## Follow-up Engineering Work
- Add a stall detector based on unchanged XP plus repeated target coordinates.
- Temporarily blacklist a target position after excessive swings without XP.
- Reject invalid XP samples instead of converting them into `0/0` level-ups.
- Investigate why `belt.drink_potion("health", ...)` missed visible red potions.
- Detect death and modal overlays explicitly and stop before sending more input.
- Consider an explicit `--continuous` option instead of using a large minute
count.
+145
View File
@@ -0,0 +1,145 @@
# Post-mortem — run_pindle total failure (2627 Aug 2026)
**Outcome:** failure rate 100% → ~8%. Character 32 → 69. Cycle time 185250s → ~52s.
18 commits, `98e12c0``402c768`.
(Hashes changed on 27 Aug: history was rewritten before the PR to purge a Discord
webhook that had been committed into `config/params.ini` by mistake.)
Published rendering: `docs/postmortems/pindle-2026-08-27.html`
(artifact: https://claude.ai/code/artifact/00668e28-dbb8-40e8-b585-db4c8b790d0a)
Per-bug detail with code references lives in `CLAUDE.md` under **Known Bugs and Fixes**
(Bugs 2330). This document is the narrative: what happened, in what order, and what to do
differently.
---
## 1. The expensive part: a green log is not evidence
The original symptom was loud and honest — every run died on
`Wanted to select A5_RED_PORTAL, but could not find it`.
After the portal template was fixed, the logs went clean **and stayed wrong**. Five
consecutive games produced:
```
INFO Pindle approach: confirmed temple entry (PINDLE_7 @ 75.7%)
INFO Loot from run_pindle: nothing picked up
INFO End game. Elapsed time: 50.98s
"runs_failed_total": 0
```
Every line green. The character was standing in the middle of Harrogath swinging at nothing.
**What gave it away was not the log — it was the XP counter.** 6,779,050 before five
"successful" runs, 6,779,050 after. Pindle always drops something; five runs of
`nothing picked up` is not variance, it is a run that never happened.
> A boss run's success is only provable by things the bot cannot fake: an item in the loot
> line, or a jump in `current_exp`. `failed:false` is the bot's *opinion*, derived from the
> very check that was lying.
---
## 2. The causal chain
Four of the first five causes were one failure propagating. Fixing only the visible end
would have left the rest armed.
```
Portal template is masked (scores 0.50-0.60, needs 0.68, matches the wrong place)
-> portal click fails, approach re-paths from a hardcoded town start it no longer occupies
-> landmarks miss, the pather's 0.55 fallback invents a node position from scenery
-> char walks at the phantom and wedges on the town wall, or onto the waypoint stone
-> waypoint panel opens, health manager reads it as a threat, chickens at full HP
```
---
## 3. Root causes, in order of cost
| Bug | Cause | Severity |
|---|---|---|
| 24 | "Already in Pindle area?" shortcut fired **in town** (0.760.79 on `PINDLE_7` vs its 0.62 bar), returned success without clicking the portal | **Silent** |
| 28 | `detect_current_act` committed on a **1.7pp margin** (`A5_TOWN_1` 0.636 vs `A4_TOWN_5` 0.619) → A5 pathing ran inside Act 4. **25% of games** | **Silent** |
| 30 | NPC grid sweep `SWEEP_TAG_THRESHOLD=0.4` settled for noise (real hits 0.981.00, false 0.420.50) | Loud |
| 23 | `a5_red_portal.png` was the only **masked** template in its folder → unsupported masked `TM_CCOEFF_NORMED` | Loud |
| 25 | Pather 0.55 **first-match** fallback fabricated node positions → walked into the town wall | Loud |
| 29 | Undetectable resurrect NPC re-hunted every game (~80s), per-game flag reset by `log_start_game` | Loud |
| 26 | Waypoint panel counted toward a chicken — 3 of 6 games died at full HP | Loud |
| 27 | `GEMS_TAB_Y=100` missed the tab row (labels at y 6378, grid starts y≈87) → 800+ silent warnings | Loud |
| 9b | Pindle retry re-pathed from a hardcoded `A5_TOWN_START` | Loud |
The two marked **Silent** cost the most, because they reported success.
---
## 4. Four corrections — times the obvious answer was wrong
These are recorded because in each case the wrong answer looked correct.
**1. The red-portal guard.** To stop the false "already in Pindle area" I added: *if the red
portal is on screen we must still be in town.* The portal is visible from **both sides**
the return portal renders inside the temple. The guard blocked every genuine entry. It
traded a silent false-success for an honest false-failure: an improvement, still a bug.
**2. XP as proof of a boss kill.** An XP gain proves *something* died — the minion pack and
the mercenary's kills both count. Confirmation needed a loot line naming an actual drop.
**3. "The template is degenerate."** I blamed the Qual-Kehk asset for a repeated identical
0.424 score. Walking the character to the NPC and scoring the stored template against a
frame where the tag renders gave **1.000** (0.997 through the real `color_filter` path). The
template was perfect; the *threshold* was wrong.
> A repeated identical score means the search is settling for **noise**. Check the threshold
> before blaming the asset.
**4. Selling the charms.** I read 673 blocked-sell lines as a clog to clear and disabled
`protect_charms_from_sell`. It vendored five resistance charms before it was caught. The
blocked lines were **the guard working**, and the pickit's `Discarding` verdict was never a
judgement that they were junk — `LAPIS SMALL CHARM OF VITA` failed its rule only because the
rule wanted `coldresist >= 11` and it had 7.
---
## 5. What actually made this expensive
Every bug above cost hours because the log recorded **events, not structure**. There was no
way to ask "where did this game spend its time?" without reading thousands of lines.
That is now fixed — see `CLAUDE.md`*The run timeline* and *Diagnosing a failure*:
- **`grep "TL>"`** — one fixed-width line per step, spawn to loot, every step timed.
- **`grep "FAIL>"`** — a self-contained failure record: reason, location, screenshot, the
three slowest steps, and a breadcrumb trail of the game with `!` marking failures.
- **A Discord digest every 2h** (`general.discord_timing_report_h`), aggregated from the same
timeline so report and log cannot drift.
The first thing the timeline surfaced, unprompted:
```
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 | approach | fail | took=71.9s | step=click_red_portal
```
That failed game spent **113.6s of its 137.6s town visit** on a merc resurrect that achieved
nothing — 83% of the time, on a step that was not the one that ended the game.
---
## 6. Rules carried forward
1. **Verify boss runs with XP delta and loot lines**, never `failed:false`.
2. **Never mask part of a template** to hide an artifact — masked `TM_CCOEFF_NORMED` is
unsupported in OpenCV and fails unpredictably. Recapture it clean.
3. **Prove a template fails on a frame where the subject is definitely visible** before
recapturing anything.
4. **Confirm any click that changes UI mode** — tab, page, panel. An unverified click that
"should" have worked fails silently forever.
5. **Never let a detector guess on a thin margin.** Refusing to answer is usually safe; a
wrong confident answer skips the recovery path entirely.
6. **Read repeated warnings as a system working**, not a clog to clear.
7. **Restart the bot after editing `src/` or config** — both load at process start.
8. **A "we might already be there" fast path is dangerous** when its test can false-positive
at the origin. It converts a loud failure into a silent one.
+220
View File
@@ -0,0 +1,220 @@
# Post-mortem — run_trav on hell, FoH → hammerdin (29 Aug 2026)
**Outcome:** health chickens 3-in-8 → **0-in-11**. Battle 6468s → **4160s**.
Failure rate 12% → 9%. Deaths: 0 throughout. Character level 85.
Per-bug detail with code references lives in `CLAUDE.md`; operating instructions in
`HANDOVER.md`. This document is the narrative: what happened, in what order, and the
four times the obvious answer was wrong.
---
## 1. The build was wrong, and the timing data could not tell us
Travincal ran green on FoHdin — loot on 10 of 13 runs — but killed slowly. The user's
observation was blunt and correct: *"we only do 2-3k dmg with foh"*.
The reason is a game rule, not a bot bug. **Fist of the Heavens deals lightning damage
plus a holy bolt, and holy bolt only damages undead.** Travincal council members are
living. Half of FoH's output was landing on a target type immune to it. Against Pindle
(undead) FoH is excellent, which is exactly why nothing looked wrong until the route
changed.
After the respec, battle time barely moved — 64.0s and 65.5s versus FoH's 5068s. That
looked like the respec had failed. It had not:
```python
def _cast_hammers(self, time_in_s: float, aura: str = "concentration"):
start = time.time()
while (time.time() - start) < time_in_s: # fixed clock, no kill detection
```
`hammerdin.kill_council` casts for `3 × atk_len + 1.6` seconds **regardless of how fast
things die**. FoHdin's timer was mob-detection-driven and was hitting its ceiling;
hammerdin's is a hard-coded duration. Two different mechanisms landing on a similar
number.
> Comparing a fixed-duration fight against a detection-driven one tells you nothing about
> damage. The evidence that the respec worked was **loot on every run** (a RAL rune, 8
> Large Charms, ~30k gold), not the clock. Lowering `atk_len_trav` — not raising it — is
> the lever on a fixed-duration attack.
---
## 2. Scoring a template without the ROI the code uses gives a confident wrong answer
Three games died on `Approach failed for run_trav [step: open_wp]`. Following the
project's own advice, `a5_wp.png` was scored offline against the three real failure
frames:
```
a5_wp 0.980 at (924, 594)
a5_wp 0.962 at (924, 594)
a5_wp 0.971 at (924, 594)
```
0.960.98, identical position, every frame. Read naively this says: *the template is
perfect, so the search must be broken.* That conclusion was stated, and it was wrong.
`a5.py` already carries the answer in a comment: the recaptured `a5_wp.png` matches the
**belt/mana-orb HUD** at ~0.966 — higher than the real stone — which is why the search is
constrained to the `cut_skill_bar` ROI in the first place. Rescoring inside that ROI:
| frame | best `a5_wp` | position |
|---|---|---|
| g1 | 0.480 | (1, 401) |
| g20 | 0.440 | (712, 519) |
| g32 | 0.436 | (32, 520) |
0.4360.480 against a 0.55 threshold, at **wandering positions** — the signature of noise.
The waypoint was genuinely not on screen. The bot was telling the truth; the character had
never reached it.
> **Always score a template through the same ROI, colour filter and threshold the code
> uses.** A full-frame search finds whatever the ROI exists to exclude, and hands you a
> 0.98 that means the opposite of what it appears to mean. Recapturing this template would
> have fixed nothing and cost a day.
---
## 3. Ormus: three plausible causes, all wrong
The bot then stalled in Act 3 hunting Ormus, scoring `body 0.3580.393` against a 0.40
threshold — tantalisingly close, exactly the Akara pattern from Bug 3.
**Wrong answer #1 — "the templates are masked" (Bug 23).** All six Ormus body templates
are 4-channel, and the match positions wandered between frames — the Bug 23 signature
precisely. But a channel audit across every NPC showed the alpha is **fully opaque**, so
`alpha_to_mask` returns no mask and matching is normal. 4-channel is not the same as
masked.
**Wrong answer #2 — "lower the body threshold."** The global best match was 0.479/0.508,
above the 0.40 bar. But those matches sat *outside* Ormus's ROI `[444, 13, 372, 318]`, at
(531,415) and (375,445). Inside the ROI the best was **0.342 / 0.334** — background level.
Lowering the bar to catch that would have recreated the Bug 4 false-positive clicks.
**Wrong answer #3 — "keep Trav town business in Act 3."** This had been recommended
earlier in the same session, reasoned from `a3.py` reporting `can_buy_pots` / `can_heal` /
`can_stash` as `True`. That is a *capability* check and says nothing about pathing.
The actual cause, measured against the two frames where the bot was stuck:
| frame | `a3_town` landmarks ≥0.68 | ≥0.62 | best |
|---|---|---|---|
| 191640 | 2 | 3 | 0.728 |
| 191758 | **0** | **0** | 0.607 |
**Zero landmarks over threshold.** The pather cannot localise at all, emits
`taking a random guess` (6 times across 2 traverses to `a3_ormus`), and the character ends
up somewhere arbitrary. One cause, three symptoms: Ormus not in his ROI, the waypoint not
on screen, and maintenance burning its budget.
A3 is the **worst-supported town in the project**. Moving more work into it would have made
things worse. The recommendation was reversed.
> Ormus was never the problem, the threshold was never the problem, and the mask was never
> the problem. The NPC was not there to be found. Before adjusting any detection
> parameter, establish that the subject is actually on screen.
---
## 4. Config that silently does nothing
Three separate settings were live-but-inert, each in a different way.
**Section scoping.** `concentration` and `redemption` were defined under `[fohdin]`.
`Config` builds `hammerdin` from `[hammerdin]` then applies `[paladin]` last:
```python
self.hammerdin.update(paladin_base_cfg) # [paladin] wins
```
`[fohdin]` is never read by a hammerdin. Both binds would have been **silently unbound**
after the respec — `redemption` invisibly, and `concentration` degrading every hammer to
aura-less damage that would have looked exactly like the FoH problem just respecced away
from.
**An impossible breakpoint.** `casting_frames=8`, from which the bot derives
`_cast_duration = frames * 0.04 + 0.01`. A paladin's fastest cast is **10 frames** at 125%
FCR; 8 is unreachable. At 60% FCR the real value is 12 frames, so the bot was waiting
0.33s for casts that take 0.49s — moving on mid-cast and cutting buffs short, including
the Battle Orders that supply a large share of a 1347-life pool.
**Right setting, wrong route.** `repair_npc=a5_larzuk` was set by Bug 12 specifically to
keep repair in-act for Pindle. On Trav the character is in A3/A4, so the same setting
*forces* the cross-act trip it was created to prevent: 35.8s at Larzuk versus 9.0s at
Halbu.
> A setting that was correct for one route can be actively harmful on another. Bug 12's
> reasoning (*stay in-act*) was right; its **value** was route-specific and nobody had
> revisited it.
---
## 5. Do not trust the key auto-detector
`stand_still` was configured `capslock`; the detector reported the game used `shift`. That
looked like a clear mismatch, and it was "fixed" to `shift`. The character immediately
began running instead of standing to cast.
After the user remapped the game to `shift`, the detector reported **`capslock`**.
| | detector says | actual in-game bind |
|---|---|---|
| before remap | `shift` | `capslock` |
| after remap | `capslock` | `shift` |
Wrong both times, and specifically reporting the value *not* in use — consistent with a
stale `.keyo` read (D2R writes that file on exit). This is why the code deliberately
prefers config over detection and merely logs the disagreement.
> `Keeping configured key binding for X` is **not** a warning that config is wrong. The
> only authority on a keybind is the user and the live game.
---
## 6. What actually fixed it
| change | effect |
|---|---|
| respec FoH → hammerdin | council no longer half-immune to the attack |
| Herald of Zakarum + Mara's Kaleidoscope | chickens 3-in-8 → **0-in-11** |
| `casting_frames` 8 → 12 | buffs stop being cut mid-cast |
| `concentration=f6`, `redemption=f3` moved to `[paladin]` | binds actually read |
| `atk_len_trav` 6.0 → 3.0 | less time standing inside the council |
| `max_maintenance_time_s` 240 → 420 | cross-act town trips stop killing games |
| `repair_npc``a4_halbu` | repair 35.8s → ~9s |
The user's own diagnosis — *"we need the block from zaka and mara so its a ressistance
problem"* — was the single highest-impact change of the night. Bot configuration cannot
compensate for 23% lightning resist and 1347 life while standing inside three hell council
members.
---
## 7. Still open
**A3 pather landmark coverage** is the sole remaining failure cause. It needs a live
capture pass: node templates require the character's **absolute position at capture time**,
so they cannot be produced from saved screenshots. `A5_TOWN_1` (6568% against a 0.68 bar)
is the same class, still outstanding.
**Gear:** 75% FCR is one Arachnid Mesh away (currently 60%, and 48→75 is the next
breakpoint — everything between is wasted). Lightning resist at 23% and poison at 12%
remain the survivability ceiling.
---
## The lessons, compressed
1. A fixed-duration attack loop cannot report damage. Measure kills by **loot**.
2. Score templates through the **ROI the code uses**, or you will confidently find the
false positive the ROI exists to exclude.
3. Before tuning a detection threshold, prove the **subject is on screen**.
4. `can_do_x() == True` is a capability, not evidence that pathing works.
5. Config sections are scoped: a bind under the old class is silently unbound after a
respec.
6. Cast-timing settings must track FCR. An unreachable breakpoint means every cast is
cut short.
7. A setting that is correct for one route can be harmful on another.
8. The key auto-detector reads a file the game writes **on exit**. It is not authoritative.
+726
View File
@@ -0,0 +1,726 @@
<title>Pindle Run Post-Mortem</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,[email protected],500;9..144,600;9..144,700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap">
<style>
:root{
--ground:#EDF0F4;
--surface:#FFFFFF;
--surface-2:#E3E8EF;
--ink:#15181E;
--ink-2:#3D4652;
--slate:#5A6675;
--hair:#CBD3DE;
--accent:#B01B2E;
--accent-soft:#F6E4E7;
--good:#1B6B52;
--good-soft:#DFEFE8;
--warn:#8A5A0B;
--warn-soft:#F7EBD6;
--shadow:0 1px 2px rgba(21,24,30,.06),0 8px 24px -12px rgba(21,24,30,.18);
}
@media (prefers-color-scheme:dark){
:root:not([data-theme="light"]){
--ground:#101317;
--surface:#171B21;
--surface-2:#1F242C;
--ink:#E6EAF0;
--ink-2:#BAC3CF;
--slate:#8B97A6;
--hair:#2A313A;
--accent:#FF5F70;
--accent-soft:#2C1A1F;
--good:#4FC79B;
--good-soft:#14261F;
--warn:#E0A44A;
--warn-soft:#2A2114;
--shadow:0 1px 2px rgba(0,0,0,.4),0 10px 28px -14px rgba(0,0,0,.7);
}
}
:root[data-theme="dark"]{
--ground:#101317;
--surface:#171B21;
--surface-2:#1F242C;
--ink:#E6EAF0;
--ink-2:#BAC3CF;
--slate:#8B97A6;
--hair:#2A313A;
--accent:#FF5F70;
--accent-soft:#2C1A1F;
--good:#4FC79B;
--good-soft:#14261F;
--warn:#E0A44A;
--warn-soft:#2A2114;
--shadow:0 1px 2px rgba(0,0,0,.4),0 10px 28px -14px rgba(0,0,0,.7);
}
*{box-sizing:border-box}
body{
margin:0;
background:var(--ground);
color:var(--ink);
font-family:"IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
font-size:16.5px;
line-height:1.65;
-webkit-font-smoothing:antialiased;
}
.wrap{max-width:1080px;margin:0 auto;padding:0 24px 96px}
.col{max-width:672px;margin-inline:auto}
h1,h2,h3{font-family:Fraunces,Georgia,serif;text-wrap:balance;margin:0}
h1{font-size:clamp(2.1rem,5.2vw,3.15rem);font-weight:600;line-height:1.08;letter-spacing:-.015em}
h2{font-size:clamp(1.4rem,3vw,1.75rem);font-weight:600;line-height:1.2}
h3{font-size:1.06rem;font-weight:600;line-height:1.3}
p{margin:0}
a{color:var(--accent)}
code,.mono{font-family:"IBM Plex Mono",ui-monospace,Menlo,monospace}
.eyebrow{
font-family:"IBM Plex Mono",monospace;font-size:.7rem;font-weight:500;
letter-spacing:.16em;text-transform:uppercase;color:var(--slate);
}
/* ---- masthead ---- */
header{padding:72px 0 40px;border-bottom:1px solid var(--hair)}
.head-inner{display:flex;flex-direction:column;gap:20px}
.lede{font-size:1.12rem;color:var(--ink-2);max-width:64ch}
.verdict{
display:inline-flex;align-items:center;gap:10px;align-self:flex-start;
background:var(--good-soft);color:var(--good);
border:1px solid color-mix(in srgb,var(--good) 30%,transparent);
border-radius:2px;padding:7px 13px;
font-family:"IBM Plex Mono",monospace;font-size:.76rem;font-weight:600;letter-spacing:.06em;
}
.verdict::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--good)}
/* ---- metric band ---- */
.metrics{
display:grid;grid-template-columns:repeat(auto-fit,minmax(158px,1fr));
gap:1px;background:var(--hair);border:1px solid var(--hair);
margin:40px 0 0;
}
.metric{background:var(--surface);padding:18px 20px;display:flex;flex-direction:column;gap:5px}
.metric .k{font-size:.72rem;letter-spacing:.09em;text-transform:uppercase;color:var(--slate);font-family:"IBM Plex Mono",monospace}
.metric .v{font-family:Fraunces,serif;font-size:1.6rem;font-weight:600;font-variant-numeric:tabular-nums;line-height:1.1}
.metric .n{font-size:.8rem;color:var(--slate)}
.metric.hi .v{color:var(--good)}
.metric.lo .v{color:var(--accent)}
section{padding-top:64px}
.section-head{display:flex;flex-direction:column;gap:8px;margin-bottom:26px}
.body-flow{display:flex;flex-direction:column;gap:18px}
/* ---- the trap callout ---- */
.trap{
background:var(--accent-soft);
border-left:3px solid var(--accent);
padding:22px 24px;display:flex;flex-direction:column;gap:12px;
}
.trap h3{color:var(--accent)}
/* ---- log evidence ---- */
.log{
background:var(--surface);border:1px solid var(--hair);
padding:16px 18px;overflow-x:auto;
font-family:"IBM Plex Mono",monospace;font-size:.79rem;line-height:1.75;
color:var(--ink-2);white-space:pre;
}
.log .ok{color:var(--good);font-weight:600}
.log .bad{color:var(--accent);font-weight:600}
.log .dim{color:var(--slate)}
figure{margin:0;display:flex;flex-direction:column;gap:9px}
figcaption{font-size:.82rem;color:var(--slate)}
/* ---- causal chain ---- */
.chain{display:flex;flex-direction:column;gap:0;margin-top:8px}
.node{
background:var(--surface);border:1px solid var(--hair);
padding:14px 18px;display:flex;gap:14px;align-items:baseline;
}
.node .tag{
font-family:"IBM Plex Mono",monospace;font-size:.7rem;font-weight:600;
color:var(--accent);letter-spacing:.05em;flex-shrink:0;min-width:52px;
}
.node .txt{font-size:.93rem;color:var(--ink-2)}
.node strong{color:var(--ink);font-weight:600}
.arrow{
height:22px;width:1px;background:var(--hair);margin-left:34px;position:relative;flex-shrink:0;
}
.arrow::after{
content:"";position:absolute;bottom:0;left:-3px;
border-left:3.5px solid transparent;border-right:3.5px solid transparent;
border-top:5px solid var(--hair);
}
.node.terminal{border-color:color-mix(in srgb,var(--accent) 45%,var(--hair));background:var(--accent-soft)}
.node.terminal .txt strong{color:var(--accent)}
/* ---- cause cards ---- */
.causes{display:flex;flex-direction:column;gap:14px}
.cause{
background:var(--surface);border:1px solid var(--hair);box-shadow:var(--shadow);
display:flex;flex-direction:column;
}
.cause-head{
display:flex;gap:14px;align-items:flex-start;
padding:20px 24px 16px;border-bottom:1px solid var(--hair);
}
.bugid{
font-family:"IBM Plex Mono",monospace;font-size:.7rem;font-weight:600;letter-spacing:.06em;
color:var(--surface);background:var(--ink);padding:4px 8px;border-radius:2px;flex-shrink:0;
margin-top:3px;
}
.cause-head .titles{display:flex;flex-direction:column;gap:3px}
.cause-head .file{font-family:"IBM Plex Mono",monospace;font-size:.76rem;color:var(--slate)}
.cause-body{padding:20px 24px 24px;display:flex;flex-direction:column;gap:16px}
.field{display:flex;flex-direction:column;gap:5px}
.field .lbl{
font-family:"IBM Plex Mono",monospace;font-size:.67rem;letter-spacing:.13em;
text-transform:uppercase;color:var(--slate);
}
.field p{font-size:.94rem;color:var(--ink-2)}
.field.fix .lbl{color:var(--good)}
.sev{
margin-left:auto;flex-shrink:0;font-family:"IBM Plex Mono",monospace;
font-size:.66rem;font-weight:600;letter-spacing:.08em;text-transform:uppercase;
padding:4px 8px;border-radius:2px;white-space:nowrap;margin-top:3px;
}
.sev.silent{background:var(--accent-soft);color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 30%,transparent)}
.sev.loud{background:var(--warn-soft);color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 30%,transparent)}
/* ---- tables ---- */
.tablewrap{overflow-x:auto;border:1px solid var(--hair);background:var(--surface)}
table{border-collapse:collapse;width:100%;font-size:.86rem}
th,td{text-align:left;padding:10px 16px;border-bottom:1px solid var(--hair)}
th{
font-family:"IBM Plex Mono",monospace;font-size:.68rem;letter-spacing:.1em;
text-transform:uppercase;color:var(--slate);font-weight:500;background:var(--surface-2);
}
tbody tr:last-child td{border-bottom:none}
td.num{font-family:"IBM Plex Mono",monospace;font-variant-numeric:tabular-nums;white-space:nowrap}
td .yes{color:var(--good);font-weight:600}
td .no{color:var(--accent);font-weight:600}
/* ---- checklist ---- */
.checks{display:flex;flex-direction:column;gap:1px;background:var(--hair);border:1px solid var(--hair)}
.check{background:var(--surface);padding:15px 20px;display:flex;gap:13px;align-items:flex-start}
.check .mark{color:var(--good);font-weight:700;flex-shrink:0;line-height:1.6}
.check.dont .mark{color:var(--accent)}
.check .ct{font-size:.92rem;color:var(--ink-2)}
.check .ct strong{color:var(--ink)}
/* ---- wrong turns ---- */
.turn{
border:1px dashed var(--hair);background:transparent;
padding:18px 20px;display:flex;flex-direction:column;gap:8px;
}
.turn .lbl{font-family:"IBM Plex Mono",monospace;font-size:.68rem;letter-spacing:.12em;text-transform:uppercase;color:var(--warn)}
.turn p{font-size:.92rem;color:var(--ink-2)}
footer{
margin-top:80px;padding-top:26px;border-top:1px solid var(--hair);
display:flex;flex-wrap:wrap;gap:10px 20px;justify-content:space-between;
font-family:"IBM Plex Mono",monospace;font-size:.76rem;color:var(--slate);
}
@media (max-width:640px){
header{padding-top:48px}
.cause-head{flex-wrap:wrap}
.sev{margin-left:0}
}
</style>
<div class="wrap">
<header>
<div class="col head-inner">
<span class="eyebrow">Post-mortem · run_pindle · 2627 Aug 2026</span>
<h1>The bot reported five perfect runs and killed nothing</h1>
<p class="lede">
<code>run_pindle</code> was failing 100% of the time. Then a fix made it worse in the
most expensive way possible: it started reporting success. Nine root causes, four wrong
turns of my own, and the instrumentation built so the next one takes minutes.
</p>
<span class="verdict">Resolved &middot; 17 commits</span>
</div>
<div class="col">
<div class="metrics">
<div class="metric lo">
<span class="k">Failure rate</span>
<span class="v">100%</span>
<span class="n">at the start</span>
</div>
<div class="metric hi">
<span class="k">Now</span>
<span class="v">~8%</span>
<span class="n">1 in 28 approaches</span>
</div>
<div class="metric hi">
<span class="k">Character</span>
<span class="v">32 → 69</span>
<span class="n">37 levels</span>
</div>
<div class="metric hi">
<span class="k">Cycle time</span>
<span class="v">~52s</span>
<span class="n">was 185250s</span>
</div>
</div>
</div>
</header>
<section>
<div class="col section-head">
<span class="eyebrow">The expensive part</span>
<h2>A green log is not evidence</h2>
</div>
<div class="col body-flow">
<p>
The original symptom was loud and honest: every run died on
<code>Wanted to select A5_RED_PORTAL, but could not find it</code>. After the template
was fixed, the logs went clean — and stayed wrong. Five consecutive games produced this:
</p>
<figure>
<div class="log"><span class="ok">INFO Pindle approach: confirmed temple entry (PINDLE_7 @ 75.7%)</span>
<span class="ok">INFO Loot from run_pindle: nothing picked up</span>
<span class="ok">INFO End game. Elapsed time: 50.98s</span>
<span class="dim"> "runs_failed_total": 0</span></div>
<figcaption>Every line green. The character was standing in the middle of town, swinging at nothing.</figcaption>
</figure>
<div class="trap">
<h3>What gave it away</h3>
<p>
Not the logs — the <strong>XP counter never moved</strong>. 6,779,050 before five
"successful" runs, 6,779,050 after. Pindle always drops something; five runs of
<code>nothing picked up</code> is not variance, it is a run that never happened.
</p>
</div>
<p>
A boss run's success is only provable by things the bot cannot fake: an item in the loot
line, or a jump in <code>current_exp</code>. The <code>failed:false</code> flag is the
bot's <em>opinion</em>, derived from the very template check that was lying.
</p>
</div>
</section>
<section>
<div class="col section-head">
<span class="eyebrow">How one bad asset cascaded</span>
<h2>The causal chain</h2>
</div>
<div class="col body-flow">
<p>
Four of the first five causes are one failure propagating. This is a real dependency
chain, so fixing only the visible end would have left the rest armed.
</p>
<div class="chain">
<div class="node">
<span class="tag">CAUSE</span>
<span class="txt"><strong>Portal template is masked</strong> — scores 0.500.60, below the 0.68 it needs, matching the wrong place entirely.</span>
</div>
<div class="arrow"></div>
<div class="node">
<span class="tag">THEN</span>
<span class="txt">Portal click fails, so the approach <strong>re-paths from a hardcoded town start</strong> the character no longer occupies.</span>
</div>
<div class="arrow"></div>
<div class="node">
<span class="tag">THEN</span>
<span class="txt">Landmarks miss, so the pather's <strong>0.55 fallback invents a node position</strong> from unrelated scenery.</span>
</div>
<div class="arrow"></div>
<div class="node">
<span class="tag">THEN</span>
<span class="txt">The character walks toward the phantom and <strong>wedges against the town wall</strong> — or onto the waypoint stone.</span>
</div>
<div class="arrow"></div>
<div class="node terminal">
<span class="tag">RESULT</span>
<span class="txt">The waypoint panel opens, the health manager reads it as a threat, and <strong>chickens a game at full HP</strong>.</span>
</div>
</div>
</div>
</section>
<section>
<div class="col section-head">
<span class="eyebrow">Root causes</span>
<h2>Nine bugs, in order of cost</h2>
</div>
<div class="col causes">
<article class="cause">
<div class="cause-head">
<span class="bugid">BUG 24</span>
<div class="titles">
<h3>An optimistic shortcut faked every kill</h3>
<span class="file">src/run/pindle.py — approach()</span>
</div>
<span class="sev silent">Silent</span>
</div>
<div class="cause-body">
<div class="field">
<span class="lbl">Root cause</span>
<p>
<code>approach()</code> opened with an "are we already there?" shortcut that ran
before clicking the portal. Harrogath scenery near the portal scores
<strong>0.760.79 on <code>PINDLE_7</code></strong> — above its 0.62 bar — so the
shortcut fired <em>in town</em>, returned <code>A5_PINDLE_START</code> without ever
clicking the portal, and the bot ran <code>kill_pindle()</code> in the middle of
Harrogath.
</p>
</div>
<div class="field fix">
<span class="lbl">Fix</span>
<p>Shortcut deleted. Entry is proven only by the portal click's loading screen. Real temple entries score <strong>85.888.7%</strong>, clear of the town false positives.</p>
</div>
<div class="field">
<span class="lbl">Generalise</span>
<p>A "we might already be there" fast path is dangerous whenever its test can false-positive at the origin. It converts a loud failure into a silent one — the worst trade in an unattended system.</p>
</div>
</div>
</article>
<article class="cause">
<div class="cause-head">
<span class="bugid">BUG 28</span>
<div class="titles">
<h3>The act was decided by a 1.7-point margin</h3>
<span class="file">src/town/town_manager.py — detect_current_act()</span>
</div>
<span class="sev silent">Silent</span>
</div>
<div class="cause-body">
<div class="field">
<span class="lbl">Root cause</span>
<p>
The biggest cause of ordinary failures: <strong>25% of games</strong>. A consumables
trip sends the character A5→A4, and <code>go_to_act(5)</code> then asked
<code>detect_current_act()</code> to verify. It committed to whatever cleared 0.68
first:
</p>
</div>
<figure>
<div class="log"><span class="bad">A5_TOWN_1 0.636</span> <span class="dim">&lt;- phantom, always at (1046, 40), the top-right corner</span>
<span class="ok">A4_TOWN_5 0.619</span> <span class="dim">&lt;- the genuine marker for the act actually occupied</span></div>
<figcaption>A 1.7 percentage point gap decided which act the bot believed it was in — so it skipped the travel and ran A5 pathing inside Act 4.</figcaption>
</figure>
<div class="field fix">
<span class="lbl">Fix</span>
<p>
The winner must now beat the best marker <strong>from any other act</strong> by 5pp.
Below that it refuses to answer. Refusing is the safe outcome: callers treat
<code>None</code> as "keep the assumed act", so <code>go_to_act</code> actually
travels. <strong>Failure rate 25% → 9%.</strong>
</p>
</div>
</div>
</article>
<article class="cause">
<div class="cause-head">
<span class="bugid">BUG 30</span>
<div class="titles">
<h3>The NPC search settled for noise</h3>
<span class="file">src/npc_manager.py — grid sweep</span>
</div>
<span class="sev loud">Loud</span>
</div>
<div class="cause-body">
<div class="field">
<span class="lbl">Root cause</span>
<p>
<code>SWEEP_TAG_THRESHOLD = 0.4</code>. A rendered name tag matches almost perfectly,
so anything mediocre is noise — and at 0.4 the noise won. Every sweep stopped at the
first match over threshold, clicked empty ground, and gave up.
</p>
</div>
<div class="tablewrap">
<table>
<thead><tr><th>NPC</th><th>Score</th><th>Outcome</th></tr></thead>
<tbody>
<tr><td>akara</td><td class="num">0.980</td><td><span class="yes">real — dialogue opened</span></td></tr>
<tr><td>halbu</td><td class="num">0.995</td><td><span class="yes">real</span></td></tr>
<tr><td>larzuk</td><td class="num">0.996</td><td><span class="yes">real</span></td></tr>
<tr><td>qual_kehk</td><td class="num">0.424</td><td><span class="no">false — clicked nothing</span></td></tr>
<tr><td>malah</td><td class="num">0.501</td><td><span class="no">false</span></td></tr>
<tr><td>larzuk</td><td class="num">0.494</td><td><span class="no">false</span></td></tr>
</tbody>
</table>
</div>
<div class="field fix">
<span class="lbl">Fix</span>
<p>Raised to <strong>0.7</strong>, in the gap with margin either side. This is why qual_kehk failed 100% (5 timeouts in 5) while akara succeeded 177 times — nothing was wrong with the Qual-Kehk asset.</p>
</div>
</div>
</article>
<article class="cause">
<div class="cause-head">
<span class="bugid">BUG 23</span>
<div class="titles">
<h3>The only masked template in the folder</h3>
<span class="file">assets/templates/a5_town/a5_red_portal.png</span>
</div>
<span class="sev loud">Loud</span>
</div>
<div class="cause-body">
<div class="field">
<span class="lbl">Root cause</span>
<p>
The capture had the portal's hover tooltip baked in, and alpha was used to hide it.
That made <code>alpha_to_mask</code> return a mask, routing the search to
<code>cv2.matchTemplate(…, TM_CCOEFF_NORMED, mask=…)</code> — a combination
<strong>OpenCV only supports for <code>TM_SQDIFF</code> and
<code>TM_CCORR_NORMED</code></strong>.
</p>
</div>
<figure>
<div class="log">a5_red_portal.png 4 chan <span class="bad"> 60.9% opaque MASKED</span> <span class="dim">&lt;- the only one that failed</span>
a5_town_1..12.png 3 chan <span class="ok">100.0% opaque no mask</span> <span class="dim">&lt;- every one that works</span></div>
<figcaption>Found by auditing the folder rather than the file: every working template is opaque; the failing one was the sole outlier.</figcaption>
</figure>
<div class="field fix">
<span class="lbl">Fix</span>
<p>Recaptured as a plain 3-channel opaque crop of the portal's <strong>upper arch</strong> — the lower ring is occluded by branches. Present 0.9491.000, absent 0.3980.514.</p>
</div>
</div>
</article>
<article class="cause">
<div class="cause-head">
<span class="bugid">BUG 25</span>
<div class="titles">
<h3>The pather invented positions and walked into a wall</h3>
<span class="file">src/pather.py — find_abs_node_pos()</span>
</div>
<span class="sev loud">Loud</span>
</div>
<div class="cause-body">
<div class="field">
<span class="lbl">Root cause</span>
<p>
When the primary 0.68 search missed, the fallback dropped to a <strong>0.55
first-match</strong> full-image search. <code>A5_TOWN_1</code> scored 0.600.62 on
unrelated scenery at three different coordinates across three consecutive frames.
The landmarks were healthy where genuinely visible (<code>A5_TOWN_6 @ 0.93</code>) —
only the fallback was lying.
</p>
</div>
<div class="field fix">
<span class="lbl">Fix — three parts</span>
<p>
Threshold to <strong>0.62</strong>; fallback forced to <code>best_match=True</code>
(it was <code>False</code>, so ambiguous frames were decided by <em>dict ordering</em>);
and a heading gate rejecting any <em>low-confidence</em> match implying a &gt;90°
reversal.
</p>
</div>
</div>
</article>
<article class="cause">
<div class="cause-head">
<span class="bugid">BUG 29</span>
<div class="titles">
<h3>An undetectable NPC cost ~80s every game</h3>
<span class="file">src/bot.py · src/game_stats.py</span>
</div>
<span class="sev loud">Loud</span>
</div>
<div class="cause-body">
<div class="field">
<span class="lbl">Root cause</span>
<p>
In nightmare the merc dies most games, so resurrect ran constantly — and it retried
once. <code>_merc_resurrect_failed</code> is reset by <code>log_start_game</code>, so
it only ever suppressed a second attempt <em>within one game</em>. Game length blew
out to 185250s against a normal ~60s.
</p>
</div>
<div class="field fix">
<span class="lbl">Fix</span>
<p>
A cross-game circuit breaker that <code>log_start_game</code> deliberately does
<em>not</em> reset. Over 30 games with an undetectable NPC: <strong>60 hunts → 4</strong>.
Measured live, game times went 250s / 185s → 14s, 14s, 43s, 71s.
</p>
</div>
</div>
</article>
<article class="cause">
<div class="cause-head">
<span class="bugid">BUG 26·27</span>
<div class="titles">
<h3>Two clicks that missed by ~30 pixels</h3>
<span class="file">src/health_manager.py · src/transmute/transmute.py</span>
</div>
<span class="sev loud">Loud</span>
</div>
<div class="cause-body">
<div class="field">
<span class="lbl">Waypoint panel counted as a chicken</span>
<p>A mis-steered pather walks the char over the waypoint stone, opening its panel. The panel check counted it twice and killed the game — <strong>3 of 6 games died this way at full health</strong>. Now escaped without counting, bounded at six attempts.</p>
</div>
<div class="field">
<span class="lbl">Gem conversion ran in the wrong tab</span>
<p>
<code>GEMS_TAB_Y = 100</code>, but the stash tab labels occupy <strong>y 6378</strong>
and the grid starts at y≈87 — so every tab-switch click landed on a stash slot. The
whole routine ran in PERSONAL, logging the same warning <strong>800+ times</strong>
without one error. Tab switches are now verified, not fire-and-forget.
</p>
</div>
</div>
</article>
</div>
</section>
<section>
<div class="col section-head">
<span class="eyebrow">Course corrections</span>
<h2>Four times I was wrong</h2>
</div>
<div class="col body-flow">
<p>
Each looked correct in isolation, and each was caught by the user or by checking rather
than by reasoning. They are recorded because the wrong answer is often the obvious one.
</p>
<div class="turn">
<span class="lbl">1 — the red-portal guard</span>
<p>
To stop the false "already in Pindle area", I added: if the red portal is on screen we
must still be in town. <strong>The portal is visible from both sides</strong> — the
return portal renders inside the temple. The guard blocked every genuine entry. It
traded a silent false-success for an honest false-failure, which is an improvement and
still a bug.
</p>
</div>
<div class="turn">
<span class="lbl">2 — XP as proof of a boss kill</span>
<p>An XP gain proves <em>something</em> died — the minion pack and the mercenary's kills both count. Confirmation required a loot line naming an actual drop.</p>
</div>
<div class="turn">
<span class="lbl">3 — "the template is degenerate"</span>
<p>
I blamed the Qual-Kehk asset for a repeated identical 0.424 score. Walking the character
to the NPC and scoring the stored template against a frame where the tag renders gave
<strong>1.000</strong>. The template was perfect; the <em>threshold</em> was wrong. A
repeated identical score means the search is settling for noise — check the threshold
before blaming the asset.
</p>
</div>
<div class="turn">
<span class="lbl">4 — selling the charms</span>
<p>
I read 673 blocked-sell log lines as a clog to clear and disabled the guard. It vendored
five resistance charms before it was caught. <strong>The blocked lines were the guard
working</strong>, and the pickit's "Discarding" verdict was never a judgement that they
were junk — <code>LAPIS SMALL CHARM OF VITA</code> failed its rule only because the rule
wanted <code>coldresist &gt;= 11</code> and it had 7.
</p>
</div>
</div>
</section>
<section>
<div class="col section-head">
<span class="eyebrow">The durable part</span>
<h2>Instrumentation, so the next one takes minutes</h2>
</div>
<div class="col body-flow">
<p>
Every bug above cost hours because the log recorded events, not <em>structure</em>. The
last commits fix that: one fixed-width, machine-readable line per step, spawn to loot,
every step timed.
</p>
<figure>
<div class="log"><span class="dim">grep "TL&gt;" log/log.txt</span>
TL&gt; g2 r1 | game | spawn | ok | at a5_town_start
TL&gt; g2 r1 | town | inspect_inventory | ok | took=3.0s | in pack=2 keep=0 sell=2
TL&gt; g2 r1 | town | repair | ok | took=19.3s | at a5_larzuk
TL&gt; g2 r1 | town | item_sell | ok | SOUL IMPALER @ (928, 465)
<span class="bad">TL&gt; g2 r1 | town | resurrect_merc | fail | took=113.6s | NPC not reachable</span>
TL&gt; g2 r1 | town | maintenance | ok | took=137.6s | at a5_larzuk
<span class="bad">TL&gt; g2 r1 | run | approach | fail | took=71.9s | step=click_red_portal</span></div>
<figcaption>
That failed game spent <strong>113.6s of its 137.6s town visit</strong> on a merc
resurrect that achieved nothing — 83% of the time, on a step that was not the one that
ended the game. That proportion was invisible before.
</figcaption>
</figure>
<p>
Failures get a self-contained record (<code>grep "FAIL&gt;"</code>) carrying the reason,
location, screenshot, the three slowest steps, and a breadcrumb trail of the whole game
with <code>!</code> marking failures. <strong>The step named in the reason is frequently
not the one that caused the problem</strong> — the trail is what shows that.
</p>
<p>
A digest posts to Discord every two hours, aggregated from the same timeline so the report
and the log cannot drift apart.
</p>
<figure>
<div class="log">**Timing report** - last 2.0h
Games: 12 (11 ok, 1 failed - 8.3%)
Avg town 20s | approach 14s | battle 19s | cycle ~52s
__Slowest steps (avg)__
`town.resurrect_merc ` 33s x6 (2 fail)
`run.approach ` 14s x28 (1 fail)</div>
<figcaption>Real measured output. Rankings exclude umbrella steps and deliberate stealth pauses, which would otherwise always come first and say nothing.</figcaption>
</figure>
</div>
</section>
<section>
<div class="col section-head">
<span class="eyebrow">Prevention</span>
<h2>Rules carried into the docs</h2>
</div>
<div class="col body-flow">
<div class="checks">
<div class="check">
<span class="mark"></span>
<span class="ct"><strong>Verify boss runs with XP delta and loot lines.</strong> Never with <code>failed:false</code> — that is the bot agreeing with itself.</span>
</div>
<div class="check dont">
<span class="mark"></span>
<span class="ct"><strong>Never mask part of a template to hide an artifact.</strong> Masked <code>TM_CCOEFF_NORMED</code> is unsupported in OpenCV and fails unpredictably.</span>
</div>
<div class="check">
<span class="mark"></span>
<span class="ct"><strong>Prove a template fails on a frame where the subject is definitely visible</strong> before recapturing it. A repeated identical score means the threshold is too low, not that the asset is bad.</span>
</div>
<div class="check">
<span class="mark"></span>
<span class="ct"><strong>Confirm any click that changes UI mode</strong> — a tab, a page, a panel. An unverified click that "should" have worked will fail silently forever.</span>
</div>
<div class="check dont">
<span class="mark"></span>
<span class="ct"><strong>Never let a detector guess on a thin margin.</strong> Refusing to answer is usually the safe outcome; a wrong confident answer skips the recovery path entirely.</span>
</div>
<div class="check">
<span class="mark"></span>
<span class="ct"><strong>Read repeated warnings as a system working, not a clog to clear.</strong> 673 blocked sells were a guard doing its job.</span>
</div>
<div class="check">
<span class="mark"></span>
<span class="ct"><strong>Restart the bot after editing <code>src/</code> or config.</strong> Python loads modules and config at process start; a running bot keeps executing the old code.</span>
</div>
</div>
</div>
</section>
<footer>
<div class="col" style="display:flex;flex-wrap:wrap;gap:10px 20px;justify-content:space-between;width:100%">
<span>my-botty · branch <strong>stable</strong> · 17 commits</span>
<span>9 root causes · 4 corrections · verified live</span>
</div>
</footer>
</div>
+1 -24
View File
@@ -8,27 +8,4 @@ dependencies:
- pkg-config
- rapidfuzz=2.15.1
- pip:
- pyinstaller
- opencv-python==4.5.5.64
- transitions
- mss==7.0.1
- numpy==1.26.4
- mouse
- keyboard
- beautifultable
- pytweening
- requests
- coverage
- pytest
- pytest-env
- pytest-mock
- pyparsing
- graphviz
- psutil
- cryptography
- pillow
- discord.py
- parse
- typing_extensions
- colorama
- dataclasses-json
- -r requirements.txt
+11 -2
View File
@@ -6,6 +6,8 @@
if defined PYTHON goto :_find_python_done
for %%C in (
"%LOCALAPPDATA%\miniforge3\envs\botty\python.exe"
"%LOCALAPPDATA%\miniconda3\envs\botty\python.exe"
"%USERPROFILE%\miniforge3\envs\botty\python.exe"
"%USERPROFILE%\miniconda3\envs\botty\python.exe"
"%USERPROFILE%\anaconda3\envs\botty\python.exe"
@@ -20,8 +22,15 @@ for %%C in (
)
)
echo ERROR: Could not find botty conda environment.
echo Run install.bat first.
echo.
echo ERROR: The 'botty' Python environment is not installed yet.
echo.
echo Fix: double-click install.bat in this folder and wait for
echo "Installation complete!", then try again.
echo.
echo Already ran install.bat? Then it did not finish successfully.
echo Run run_install_capture.bat and check install_log.txt for the error.
echo.
pause
exit /b 1
+1
View File
@@ -0,0 +1 @@
+11
View File
@@ -0,0 +1,11 @@
P3
8 8
255
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 0 0 0 0 0 0
0 0 0 10 10 10 10 10 10 255 0 0 0 255 0 0 0 255 0 0 0 0 0 0
0 0 0 10 10 10 10 10 10 0 255 0 0 0 255 255 0 0 0 0 0 0 0 0
0 0 0 10 10 10 10 10 10 0 0 255 255 255 0 0 255 255 0 0 0 0 0 0
0 0 0 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+6
View File
@@ -0,0 +1,6 @@
P3
3 3
255
255 0 0 0 255 0 0 0 255
0 255 0 0 0 255 255 0 0
0 0 255 255 255 0 0 255 255
+300 -47
View File
@@ -59,13 +59,26 @@ echo This is a one-time setup (~100 MB). Please wait.
echo.
:: --- Method 1: winget (cleanest; available on Win10 1709+ and all Win11) ---
:: --scope user is critical: without it winget defaults to a machine-wide
:: install (lands in %ProgramData%) which REQUIRES admin. A normal double-click
:: (no elevation) then fails silently and conda never installs. --scope user
:: installs to %USERPROFILE%\miniforge3 with no admin needed — which is also one
:: of the locations :rescan_conda searches.
:: winget returns non-zero when the package is already installed, so don't
:: trust the exit code -- rescan for conda and only fall through to the direct
:: download if it's genuinely still missing. Keep this comment outside the block:
:: a "::" line inside a ( ) block is a parse error.
winget --version >nul 2>&1
if %errorlevel% equ 0 (
echo Installing via winget...
winget install --id CondaForge.Miniforge3 --exact --silent ^
winget install --id CondaForge.Miniforge3 --exact --silent --scope user ^
--accept-package-agreements --accept-source-agreements
if !errorlevel! equ 0 goto :rescan_conda
echo winget install failed — falling back to direct download.
for %%C in (
"%LOCALAPPDATA%\miniforge3\Scripts\conda.exe"
"%USERPROFILE%\miniforge3\Scripts\conda.exe"
"%ProgramData%\miniforge3\Scripts\conda.exe"
) do if exist %%C goto :rescan_conda
echo winget did not produce a usable conda — falling back to direct download.
echo.
)
@@ -78,6 +91,7 @@ if %errorlevel% equ 0 (
set "MF_INSTALLER=%TEMP%\Miniforge3-installer.exe"
set "MF_URL=https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Windows-x86_64.exe"
del /q "%MF_INSTALLER%" >nul 2>&1
echo Downloading Miniforge3...
curl -Lk --progress-bar "%MF_URL%" -o "%MF_INSTALLER%" 2>&1
if %errorlevel% neq 0 (
@@ -87,23 +101,52 @@ if %errorlevel% neq 0 (
)
if not exist "%MF_INSTALLER%" (
echo.
echo ERROR: Could not download Miniforge3. Check your internet connection, or
echo install it manually then re-run install.bat:
echo https://github.com/conda-forge/miniforge/releases/latest
echo ERROR: Could not download Miniforge3 ^(the Python/conda runtime^).
echo.
pause
exit /b 1
echo Nothing was downloaded from:
echo %MF_URL%
echo Usual causes: no internet, or a firewall/proxy blocking github.com.
echo.
echo Workaround: install Miniforge yourself, then run install.bat again.
echo It will detect it and carry on:
echo https://github.com/conda-forge/miniforge/releases/latest
echo ^(pick Miniforge3-Windows-x86_64.exe and keep the default location^)
goto :fail
)
:: Verify the download is a real installer, not a truncated file or an HTML
:: error page served with a 200. The Miniforge installer is ~78 MB; anything
:: under 40 MB means the download failed even though a file exists.
for %%A in ("%MF_INSTALLER%") do set "MF_SIZE=%%~zA"
if not defined MF_SIZE set "MF_SIZE=0"
if %MF_SIZE% LSS 41943040 (
echo.
echo ERROR: The Miniforge3 download is incomplete and was not run.
echo Got: %MF_SIZE% bytes
echo Expected: about 78 MB
echo.
echo The connection dropped, or a proxy/filter returned an error page
echo instead of the file. The bad file has been deleted.
echo.
echo Try again on a different network, or install Miniforge yourself and
echo re-run install.bat:
echo https://github.com/conda-forge/miniforge/releases/latest
del /q "%MF_INSTALLER%" >nul 2>&1
goto :fail
)
echo Installing Miniforge3 (this takes ~1 minute)...
start /wait "" "%MF_INSTALLER%" /S /InstallationType=JustMe /AddToPath=0 /RegisterPython=0 /NoShortcuts=1 /NoRegistry=1
if %errorlevel% neq 0 (
echo.
echo ERROR: Miniforge3 installer failed (exit code %errorlevel%).
echo Try running it manually: %MF_INSTALLER%
echo ERROR: The Miniforge3 installer did not complete ^(exit code %errorlevel%^).
echo.
pause
exit /b 1
echo Usual causes: antivirus blocked it, or a UAC prompt was declined.
echo.
echo Run it by hand to see the real error, accepting any prompts:
echo %MF_INSTALLER%
echo then run install.bat again.
goto :fail
)
del /q "%MF_INSTALLER%" >nul 2>&1
echo Miniforge3 installed.
@@ -127,10 +170,18 @@ for %%C in (
goto :found_conda
)
)
echo ERROR: Miniforge3 installed but conda.exe still not found. Please restart
echo the installer or contact support.
pause
exit /b 1
echo ERROR: Miniforge3 reported success but conda.exe cannot be found.
echo.
echo install.bat looked in every standard location, including:
echo %%LOCALAPPDATA%%\miniforge3\Scripts\conda.exe
echo %%USERPROFILE%%\miniforge3\Scripts\conda.exe
echo %%ProgramData%%\miniforge3\Scripts\conda.exe
echo.
echo If you installed conda somewhere custom, the simplest fix is to
echo install Miniforge to its default location:
echo https://github.com/conda-forge/miniforge/releases/latest
echo then run install.bat again.
goto :fail
:found_conda
echo Found conda: %CONDA_EXE%
@@ -141,11 +192,16 @@ echo Testing conda...
"%CONDA_EXE%" --version >nul 2>&1
if %errorlevel% neq 0 (
echo.
echo ERROR: conda found but failed to run. Your conda installation may be broken.
echo Try re-installing Miniforge: https://github.com/conda-forge/miniforge
echo ERROR: conda was found but will not run.
echo Location: %CONDA_EXE%
echo.
pause
exit /b 1
echo The install is damaged or blocked. Try, in order:
echo 1. Run this by hand to see the real error:
echo "%CONDA_EXE%" --version
echo 2. Check antivirus is not quarantining conda
echo 3. Reinstall Miniforge, then run install.bat again:
echo https://github.com/conda-forge/miniforge/releases/latest
goto :fail
)
echo conda is working.
echo.
@@ -153,6 +209,8 @@ echo.
:: --- Find botty Python ---
set "PYTHON="
for %%C in (
"%LOCALAPPDATA%\miniforge3\envs\botty\python.exe"
"%LOCALAPPDATA%\miniconda3\envs\botty\python.exe"
"%USERPROFILE%\miniforge3\envs\botty\python.exe"
"%USERPROFILE%\miniconda3\envs\botty\python.exe"
"%USERPROFILE%\anaconda3\envs\botty\python.exe"
@@ -166,20 +224,56 @@ for %%C in (
)
)
:: --- Pre-flight: free disk space ---
:: The botty env is ~4 GB once conda unpacks it, plus ~1 GB of downloaded
:: package archives. Running out midway makes conda fail with a long, opaque
:: error, so check up front and say so plainly instead.
set "FREE_GB="
for /f "delims=" %%A in ('powershell -NoProfile -Command "[int]((Get-PSDrive %SystemDrive:~0,1%).Free/1GB)" 2^>nul') do set "FREE_GB=%%A"
if defined FREE_GB (
echo Free disk space: %FREE_GB% GB
if %FREE_GB% LSS 3 (
echo.
echo ERROR: Not enough free disk space on %SystemDrive%
echo Free now: %FREE_GB% GB
echo Needed: about 6 GB ^(the botty environment is ~4 GB, plus
echo ~1 GB of package downloads^)
echo.
echo Free up space and run install.bat again. Quick wins:
echo - Empty the Recycle Bin
echo - Windows Settings ^> System ^> Storage ^> Temporary files
echo - Uninstall apps you no longer use
goto :fail
)
if %FREE_GB% LSS 6 (
echo WARNING: Only %FREE_GB% GB free. The install needs about 6 GB and
echo may fail partway. Free up space if it does.
)
)
:: --- Create botty env ---
echo Creating 'botty' conda environment...
"%CONDA_EXE%" env create -f "%ENV_FILE%"
if %errorlevel% neq 0 (
echo.
echo ERROR: conda env create failed. See output above.
pause
exit /b 1
echo ERROR: Could not create the 'botty' conda environment.
echo.
echo The detailed reason is in the conda output above this message.
echo Most common causes:
echo - Out of disk space ^(needs about 6 GB free^)
echo - No internet, or a company/school network blocking conda-forge
echo - Antivirus blocking conda while it unpacks files
echo - A previous half-finished install: run this to clear it, then retry
echo "%CONDA_EXE%" env remove -n botty -y
goto :fail
)
:check_env
:: Re-scan for Python after env creation
set "PYTHON="
for %%C in (
"%LOCALAPPDATA%\miniforge3\envs\botty\python.exe"
"%LOCALAPPDATA%\miniconda3\envs\botty\python.exe"
"%USERPROFILE%\miniforge3\envs\botty\python.exe"
"%USERPROFILE%\miniconda3\envs\botty\python.exe"
"%USERPROFILE%\anaconda3\envs\botty\python.exe"
@@ -194,22 +288,41 @@ for %%C in (
)
echo.
echo ERROR: botty env was created but python.exe was not found.
pause
exit /b 1
echo ERROR: The 'botty' environment exists but its python.exe is missing.
echo.
echo This usually means the environment was only partly created -- often
echo because the install ran out of disk space, or antivirus removed files
echo while conda was unpacking them.
echo.
echo Fix: delete the environment and install again:
echo "%CONDA_EXE%" env remove -n botty -y
echo install.bat
goto :fail
:env_ready
echo Botty Python: %PYTHON%
:: --- Install pip requirements ---
:: --progress-bar off: pip's progress bar uses \r to redraw in place; when this
:: script's output is redirected to a file (e.g. run_install_capture.bat) those
:: redraws become millions of lines and the log balloons to many GB. Turning the
:: bar off keeps the install log small without hiding warnings/errors.
echo.
echo Installing Botty requirements from %REQ_FILE%...
"%CONDA_EXE%" run -n botty python -m pip install -r "%REQ_FILE%"
"%CONDA_EXE%" run -n botty python -m pip install --progress-bar off -r "%REQ_FILE%"
if %errorlevel% neq 0 (
echo.
echo ERROR: pip requirements install failed for %REQ_FILE%.
pause
exit /b 1
echo ERROR: Could not install the Python packages from %REQ_FILE%.
echo.
echo The failing package and reason are in the pip output above.
echo Most common causes:
echo - No internet, or a proxy/firewall blocking pypi.org
echo - Out of disk space
echo - Antivirus blocking pip while it writes files
echo.
echo The conda environment itself is fine -- you can just run
echo install.bat again; it will skip straight to this step.
goto :fail
)
echo.
@@ -227,6 +340,8 @@ echo Setting up OCR...
:: Find the botty env directory
set "BOTTY_ENV_DIR="
for %%C in (
"%LOCALAPPDATA%\miniforge3\envs\botty"
"%LOCALAPPDATA%\miniconda3\envs\botty"
"%USERPROFILE%\miniforge3\envs\botty"
"%USERPROFILE%\miniconda3\envs\botty"
"%USERPROFILE%\anaconda3\envs\botty"
@@ -239,9 +354,14 @@ for %%C in (
goto :env_found
)
)
echo ERROR: Could not find botty env directory.
pause
exit /b 1
echo ERROR: Could not locate the 'botty' environment folder.
echo.
echo Python was found but the environment directory around it was not,
echo which means the conda install is in an unexpected layout.
echo Fix: remove and recreate the environment:
echo "%CONDA_EXE%" env remove -n botty -y
echo install.bat
goto :fail
:env_found
set "TESS_PATH=%BOTTY_ENV_DIR%\Library\bin;%BOTTY_ENV_DIR%\Library\lib;%BOTTY_ENV_DIR%\DLLs;%BOTTY_ENV_DIR%\Scripts"
@@ -257,8 +377,27 @@ set "TESS_PATH=%BOTTY_ENV_DIR%\Library\bin;%BOTTY_ENV_DIR%\Library\lib;%BOTTY_EN
:: Remove any existing pip/wheel tesserocr
"%PYTHON%" -m pip uninstall tesserocr -y >nul 2>&1
:: conda tesseract 4.x provides leptonica-1.78.0.dll (MSVC) which tesseract52.dll needs
"%CONDA_EXE%" install -n botty "tesseract=4.*" -c conda-forge -y >nul 2>&1
:: conda tesseract 4.x provides leptonica-1.78.0.dll (MSVC) which tesseract52.dll needs.
:: libdeflate is required explicitly: the libtiff that ships with tesseract 4.x
:: imports libdeflate, and without it the whole chain below fails to load.
"%CONDA_EXE%" install -n botty "tesseract=4.*" libdeflate -c conda-forge -y >nul 2>&1
:: libdeflate.dll compatibility alias.
:: THIS IS WHAT MAKES tesserocr WORK. The DLL chain is:
:: tesserocr.pyd -> tesseract52.dll -> leptonica-1.78.0.dll -> tiff.dll -> libdeflate.dll
:: 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 provides that name, so tiff.dll fails to load,
:: and every DLL above it in the chain fails with WinError 126 ("The specified
:: module could not be found") -- which surfaced as tesserocr being permanently
:: unavailable and the bot silently falling back to the slower pytesseract.
:: Copying deflate.dll to the old name satisfies the import; the exported symbols
:: are the same library, verified by tesserocr initialising and running real OCR.
if not exist "%BOTTY_ENV_DIR%\Library\bin\libdeflate.dll" (
if exist "%BOTTY_ENV_DIR%\Library\bin\deflate.dll" (
copy /y "%BOTTY_ENV_DIR%\Library\bin\deflate.dll" "%BOTTY_ENV_DIR%\Library\bin\libdeflate.dll" >nul
)
)
:: conda tesseract.exe crashes on Win10 and Win11 — disable it, keep the DLLs
if exist "%BOTTY_ENV_DIR%\Library\bin\tesseract.exe" (
@@ -278,18 +417,83 @@ if exist "dependencies\tesserocr.cp310-win_amd64.pyd" (
)
:: --- Backend 2: pytesseract (reliable fallback) ---
:: Needs tesseract.exe from winget. Works on any Python version.
:: On Win10: winget may not be available -- if install fails, offer manual link.
if not exist "C:\Program Files\Tesseract-OCR\tesseract.exe" (
echo Installing Tesseract OCR via winget...
winget install --id tesseract-ocr.tesseract --silent --accept-package-agreements --accept-source-agreements 2>nul
if !errorlevel! neq 0 (
echo winget failed -- on Windows 10 you may need to install manually:
echo https://github.com/tesseract-ocr/tesseract/releases
echo Download the win64 installer, run it, keep the default install path.
:: This is the backend that actually carries OCR on most machines (tesserocr's
:: MSVC DLL chain frequently fails), so it must install without admin rights and
:: without winget -- neither is guaranteed on a clean Win10 box.
"%CONDA_EXE%" run -n botty python -m pip install --progress-bar off pytesseract >nul 2>&1
if %errorlevel% neq 0 (
echo WARNING: Could not install pytesseract Python wrapper.
)
:: Try winget machine scope, then user scope -- the latter needs no admin.
:: winget's exit code is unreliable, being non-zero when the package is already
:: installed, so after each attempt we re-resolve tesseract.exe instead of
:: trusting errorlevel.
:: NOTE: comments must stay OUTSIDE the parenthesised blocks below. A "::" line
:: inside a ( ) block is a parse error, and any parenthesis in the comment text
:: closes the block early.
call :find_tesseract
if not defined TESS_EXE (
echo Installing Tesseract OCR...
winget --version >nul 2>&1
if !errorlevel! equ 0 (
winget install --id tesseract-ocr.tesseract --exact --silent ^
--accept-package-agreements --accept-source-agreements >nul 2>&1
call :find_tesseract
if not defined TESS_EXE (
winget install --id tesseract-ocr.tesseract --exact --silent --scope user ^
--accept-package-agreements --accept-source-agreements >nul 2>&1
call :find_tesseract
)
)
)
:: Last resort: direct download of the official NSIS installer. Covers clean
:: Win10 machines where winget is missing or fails.
:: The size check guards against a truncated download or an HTML error page;
:: the installer is ~50 MB.
:: NOTE on /D=: measured behaviour is that the official Tesseract installer
:: self-elevates (it requests admin) and the elevated relaunch DISCARDS /D=, so
:: it always lands machine-wide in "C:\Program Files\Tesseract-OCR" regardless
:: of TS_DEST. /D= is kept as best-effort only. The practical consequence is
:: that Tesseract needs admin/UAC -- there is no per-user install path with the
:: official installer. Either outcome is fine at runtime because find_tesseract
:: and src\d2r_image\ocr.py both search the machine-wide and per-user paths.
:: /D= must still come last and unquoted, and breaks on paths with spaces, so
:: it is only passed when the target path has none.
if not defined TESS_EXE (
echo winget unavailable or failed -- downloading Tesseract directly...
set "TS_INSTALLER=%TEMP%\tesseract-setup.exe"
set "TS_URL=https://github.com/tesseract-ocr/tesseract/releases/download/5.5.0/tesseract-ocr-w64-setup-5.5.0.20241111.exe"
set "TS_DEST=%LOCALAPPDATA%\Programs\Tesseract-OCR"
del /q "!TS_INSTALLER!" >nul 2>&1
curl -Lk --progress-bar "!TS_URL!" -o "!TS_INSTALLER!" 2>&1
if not exist "!TS_INSTALLER!" (
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
"$ProgressPreference='SilentlyContinue'; try { Invoke-WebRequest -Uri '!TS_URL!' -OutFile '!TS_INSTALLER!' -UseBasicParsing; exit 0 } catch { exit 1 }"
)
set "TS_SIZE=0"
if exist "!TS_INSTALLER!" for %%A in ("!TS_INSTALLER!") do set "TS_SIZE=%%~zA"
if !TS_SIZE! GEQ 20971520 (
echo !TS_DEST! | find " " >nul
if !errorlevel! equ 0 (
start /wait "" "!TS_INSTALLER!" /S
) else (
start /wait "" "!TS_INSTALLER!" /S /D=!TS_DEST!
)
del /q "!TS_INSTALLER!" >nul 2>&1
call :find_tesseract
)
)
if defined TESS_EXE (
echo Tesseract: !TESS_EXE!
) else (
echo WARNING: Tesseract could not be installed automatically. Install manually:
echo https://github.com/tesseract-ocr/tesseract/releases
echo Download the win64 installer, run it, then re-run install.bat.
)
:: --- Verify OCR: at least one backend must work ---
echo.
echo Checking OCR backends...
@@ -304,8 +508,8 @@ if %errorlevel% == 0 (
echo tesserocr: not available ^(DLL issue -- bot will use pytesseract instead^)
)
if exist "C:\Program Files\Tesseract-OCR\tesseract.exe" (
"%CONDA_EXE%" run -n botty python -c "import pytesseract; pytesseract.pytesseract.tesseract_cmd=r'C:\Program Files\Tesseract-OCR\tesseract.exe'; pytesseract.get_tesseract_version()" >nul 2>&1
if defined TESS_EXE (
"%CONDA_EXE%" run -n botty python -c "import pytesseract; pytesseract.pytesseract.tesseract_cmd=r'!TESS_EXE!'; pytesseract.get_tesseract_version()" >nul 2>&1
if !errorlevel! == 0 (
echo pytesseract: OK ^(reliable fallback^)
set "OCR_READY=1"
@@ -327,7 +531,7 @@ if "%OCR_READY%"=="0" (
echo.
echo Verifying all dependencies...
set "ALL_OK=1"
for %%M in (cv2 mss numpy transitions rapidfuzz) do (
for %%M in (cv2 mss numpy transitions rapidfuzz pydantic pytesseract yaml) do (
set "PATH=%TESS_PATH%;%PATH%"
"%CONDA_EXE%" run -n botty python -c "import %%M" >nul 2>&1
if !errorlevel! neq 0 (
@@ -369,3 +573,52 @@ echo Installation complete!
echo Run botty with: run_botty.bat
echo ============================================
echo.
:: Hold the window open. Every failure path already pauses, but the success
:: path did not -- so a user double-clicking install.bat from Explorer saw the
:: console vanish the instant it finished and never got to read the result or
:: the dependency/OCR verification above. Redirected runs are unaffected:
:: run_install_capture.bat feeds stdin from the log redirect, and any
:: non-interactive run should invoke this script with "< nul".
pause
goto :eof
:: --- Shared failure exit ---
:: Every fatal path jumps here with "goto :fail" after printing what went wrong
:: and how to fix it. This adds the one instruction that makes a bug report
:: actionable: how to produce a full log.
:fail
echo.
echo ------------------------------------------------------------
echo INSTALL FAILED - nothing else was changed on your PC.
echo.
echo Still stuck? Produce a full log and include it when asking
echo for help:
echo 1. Double-click run_install_capture.bat
echo 2. Attach the install_log.txt it creates
echo ------------------------------------------------------------
echo.
pause
exit /b 1
:: --- Resolve tesseract.exe into TESS_EXE ---
:: Checks machine-wide (winget default / manual install) and per-user (winget
:: --scope user / our direct NSIS fallback) locations, plus PATH. Sets TESS_EXE
:: to the first hit, or clears it if none found.
:find_tesseract
set "TESS_EXE="
for %%T in (
"C:\Program Files\Tesseract-OCR\tesseract.exe"
"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe"
"%LOCALAPPDATA%\Programs\Tesseract-OCR\tesseract.exe"
"%ProgramData%\Tesseract-OCR\tesseract.exe"
) do (
if exist %%T (
set "TESS_EXE=%%~T"
goto :eof
)
)
for /f "delims=" %%T in ('where tesseract 2^>nul') do (
set "TESS_EXE=%%T"
goto :eof
)
goto :eof
+6 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "botty"
version = "0.8.1"
version = "0.10.0"
description = "Pixelbot for Diablo 2 Resurrected"
requires-python = ">=3.10,<3.11"
dependencies = [
@@ -9,6 +9,8 @@ dependencies = [
"transitions",
"mss==7.0.1",
"numpy==1.26.4",
"pydantic",
"PyYAML",
"beautifultable",
"pytweening",
"requests",
@@ -26,6 +28,9 @@ dependencies = [
# tesserocr must be installed separately - see development.md
]
[project.scripts]
botty-next = "botty_next.cli:main"
[project.optional-dependencies]
dev = [
"pytest",
+2 -1
View File
@@ -1,6 +1,7 @@
[pytest]
pythonpath =
.
src
env =
PYTHONPATH=./src
PYTHONPATH=./src:.
RUN_ENV=test
+48 -57
View File
@@ -1,59 +1,50 @@
# ==========================================
# botty - Pixelbot for Diablo 2 Resurrected
# requirements.txt
# ==========================================
# Pin to versions that match the existing conda environment (environment.yml).
# Run `pip install -r requirements.txt` inside the `botty` conda env.
# For tesserocr, install the pre-built wheel instead:
# pip install dependencies/tesserocr-2.5.2-cp310-cp310-win_amd64.whl
# ==========================================
# --- Core runtime ---
aiohappyeyeballs==2.6.2
pywin32==311; sys_platform=="win32"
aiohttp==3.14.1
aiosignal==1.4.0
async-timeout==5.0.1
attrs==26.1.0
beautifultable==1.1.0
certifi==2026.6.17
cffi==2.0.0
charset-normalizer==3.4.7
colorama==0.4.6
cryptography==49.0.0
dataclasses-json==0.6.7
discord.py==2.7.1
frozenlist==1.5.0
idna==3.10
keyboard==0.13.5; sys_platform=="win32"
marshmallow==3.26.2
mouse==0.7.1; sys_platform=="win32"
mss==7.0.1
multidict==6.7.1
numpy==1.26.4
opencv-python==4.5.5.64
mss==7.0.1
pillow
pywin32
# --- Game input & control ---
keyboard
mouse
pytweening
# --- State machine & logic ---
transitions
rapidfuzz==2.15.1
pyparsing
parse
dataclasses-json
# --- UI & display ---
colorama
beautifultable
# --- Networking & messaging ---
requests
discord.py
# --- System & utilities ---
psutil
cryptography
typing_extensions
graphviz
# --- OCR ---
# pytesseract: pure-Python fallback; calls the system tesseract.exe installed
# by install.bat (winget). Always available regardless of tesserocr DLL state.
pytesseract
# tesserocr (fast path): install via bundled wheel AFTER conda tesseract 4.x:
# pip install dependencies/tesserocr-2.5.2-cp310-cp310-win_amd64.whl
# --- Dev / testing ---
pytest
pytest-env
pytest-pythonpath
pytest-mock
coverage
# --- Packaging ---
pyinstaller
packaging==25.0
parse==1.22.1
pillow==12.2.0
psutil==7.2.2
pycparser==2.22
pyparsing==3.3.2
pytesseract==0.3.13
pytweening==1.2.0
PyYAML==6.0.3
rapidfuzz==3.12.1
requests==2.34.2
six==1.17.0
transitions==0.9.3
typing-inspect==0.9.0
typing_extensions==4.15.0
urllib3==2.7.0
wcwidth==0.2.13
python-dotenv==1.2.2
graphviz==0.21
Pygments==2.20.0
pydantic==2.12.5
coverage==7.14.1
pytest==9.1.1
pytest-env==1.6.0
pytest-mock==3.15.1
pytest-pythonpath==0.7.3
pyinstaller==6.21.0
+9 -2
View File
@@ -17,9 +17,16 @@ set "CONDA_PREFIX=%_ENV%"
set "PYTHONUTF8=1"
set "PYTHONIOENCODING=utf-8"
set "SSL_CERT_DIR="
:: Use winget tesseract 5.5.0 (conda tesseract crashes with access violation)
:: Use winget tesseract 5.5.0 (conda tesseract crashes with access violation).
:: Only export the path if it actually exists -- on machines where Tesseract was
:: installed per-user (no admin), it lives under %LOCALAPPDATA%\Programs instead,
:: and src\d2r_image\ocr.py resolves that itself.
set "TESSDATA_PREFIX=%_ENV%\Library\share"
set "PYTESSERACT_TESSERACT_CMD=C:\Program Files\Tesseract-OCR\tesseract.exe"
if exist "C:\Program Files\Tesseract-OCR\tesseract.exe" (
set "PYTESSERACT_TESSERACT_CMD=C:\Program Files\Tesseract-OCR\tesseract.exe"
) else if exist "%LOCALAPPDATA%\Programs\Tesseract-OCR\tesseract.exe" (
set "PYTESSERACT_TESSERACT_CMD=%LOCALAPPDATA%\Programs\Tesseract-OCR\tesseract.exe"
)
echo Launching Botty ...
"%PYTHON%" "%BOTTY_DIR%src\main.py"
+17 -1
View File
@@ -1,2 +1,18 @@
@echo off
call "%~dp0install.bat" > "%~dp0install_log.txt" 2>&1
:: Runs install.bat and captures everything to install_log.txt for support.
:: stdin is fed from nul so install.bat's trailing "pause" (and any failure
:: pause) cannot silently block behind the redirected output -- otherwise the
:: user would see an empty window waiting on a keypress they cannot see.
echo Installing and writing a full log to install_log.txt ...
echo This can take several minutes. Please wait.
call "%~dp0install.bat" < nul > "%~dp0install_log.txt" 2>&1
set "RC=%ERRORLEVEL%"
echo.
if "%RC%"=="0" (
echo Install finished. Full log: "%~dp0install_log.txt"
) else (
echo Install FAILED with exit code %RC%. Send this file for support:
echo "%~dp0install_log.txt"
)
echo.
pause
Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python
"""Hermes bot control — send commands to botty via TCP socket on 127.0.0.1:18899.
Usage:
python scripts/hermes_bot_control.py start # start/pause bot
python scripts/hermes_bot_control.py pause # toggle pause
python scripts/hermes_bot_control.py stop # stop bot
python scripts/hermes_bot_control.py status # get bot status
python scripts/hermes_bot_control.py logs [n] # last n log lines
python scripts/hermes_bot_control.py errors [n] # last n error lines
python scripts/hermes_bot_control.py runs # run stats
"""
import sys
import os
import time
import subprocess
import glob
import socket
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(SCRIPT_DIR)
LOG_FILE = os.path.join(PROJECT_ROOT, 'log', 'log.txt')
SOCKET_PORT = 18899
def send_command(cmd):
"""Send a command to the bot's control socket."""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(3.0)
s.connect(('127.0.0.1', SOCKET_PORT))
s.sendall(cmd.encode())
response = s.recv(1024).decode().strip()
s.close()
if response:
print(response)
else:
print(f"OK: command '{cmd}' sent")
except socket.timeout:
print(f"ERROR: no response from bot (socket timeout)")
except ConnectionRefusedError:
print(f"ERROR: bot not listening on port {SOCKET_PORT} (is it running?)")
except Exception as e:
print(f"ERROR: {e}")
def read_logs(n=20):
if not os.path.exists(LOG_FILE):
print("No log file found")
return
result = subprocess.run(["tail", "-n", str(n), LOG_FILE],
capture_output=True, text=True)
print(result.stdout)
def read_errors(n=10):
if not os.path.exists(LOG_FILE):
print("No log file found")
return
result = subprocess.run(["grep", "-E", "ERROR|WARNING|Failed|failed|ERROR.*step", LOG_FILE],
capture_output=True, text=True)
lines = result.stdout.strip().split('\n')
for line in lines[-n:]:
print(line)
def run_stats():
stats_dir = os.path.join(PROJECT_ROOT, 'log', 'stats')
stats_files = glob.glob(os.path.join(stats_dir, 'stats_*.log'))
if stats_files:
latest = max(stats_files, key=os.path.getmtime)
with open(latest) as f:
print(f.read())
else:
print("No stats files found")
def check_status():
send_command('status')
if __name__ == '__main__':
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
cmd = sys.argv[1]
if cmd in ('start', 'pause', 'stop'):
send_command(cmd)
elif cmd == 'status':
check_status()
elif cmd == 'logs':
n = int(sys.argv[2]) if len(sys.argv) > 2 else 20
read_logs(n)
elif cmd == 'errors':
n = int(sys.argv[2]) if len(sys.argv) > 2 else 10
read_errors(n)
elif cmd == 'runs':
run_stats()
else:
print(f"Unknown command: {cmd}")
print(__doc__)
sys.exit(1)
+847 -39
View File
File diff suppressed because it is too large Load Diff
+75 -9
View File
@@ -36,6 +36,19 @@ class IChar:
}
self._use_safer_routines = Config().char["safer_routines"]
def atk_len(self, name: str) -> float:
"""Attack window for a boss, with stealth kill-time variance applied.
THE single correct way to read an atk_len_* value. Reading
Config().char["atk_len_x"] directly works but silently opts that boss
out of vary_kill_time the failure mode this whole subsystem keeps
producing. randomize_run_duration only ever LENGTHENS (1.0-1.4x), so a
varied window can never cut a kill short.
"""
from utils.stealth import randomize_run_duration
key = name if name.startswith("atk_len_") else f"atk_len_{name}"
return randomize_run_duration(float(Config().char[key]))
def _set_active_skill(self, mouse_click_type: str = "left", skill: str =""):
self._active_skill[mouse_click_type] = skill
@@ -130,7 +143,8 @@ class IChar:
success_func: Callable = None,
timeout: float = 8,
threshold: float = 0.68,
telekinesis: bool = False
telekinesis: bool = False,
roi: list = None
) -> bool:
"""
Finds any template from the template finder and interacts with it
@@ -138,6 +152,10 @@ class IChar:
:param success_func: Function that will return True if the interaction is successful e.g. return True when loading screen is reached, defaults to None
:param timeout: Timeout for the whole template selection, defaults to None
:param threshold: Threshold which determines if a template is found or not. None will use default form .ini files
:param roi: Optional [left, top, width, height] to restrict the search. Pass
ui_roi["cut_skill_bar"] for WORLD objects: the HUD is static, so a template
that happens to resemble part of it wins every frame and the real object is
never clicked. Cost a whole evening of A5_WP failures on 2026-08-27.
:return: True if success. False otherwise
"""
templates = template_type if isinstance(template_type, list) else [template_type]
@@ -148,7 +166,7 @@ class IChar:
wait(0.2, 0.3)
start = time.time()
while timeout is None or (time.time() - start) < timeout:
template_match = template_finder.search(template_type, grab(), threshold=threshold)
template_match = template_finder.search(template_type, grab(), threshold=threshold, roi=roi)
if template_match.valid:
Logger.debug(f"Select {template_match.name} ({template_match.score*100:.1f}% confidence)")
mouse.move(*template_match.center_monitor)
@@ -213,7 +231,41 @@ class IChar:
if skills.wait_until_right_skill_selected("TELE_ACTIVE") == False:
Logger.error("timeout waiting for tele skill to activate")
@staticmethod
def _hud_safe_target(pos_monitor: tuple[float, float], jitter: int) -> tuple[int, int]:
"""Jitter FIRST, then snap out of the HUD. Returns a point to click with
randomize=0.
get_closest_non_hud_pixel returns the NEAREST unmasked pixel, which by
construction lies exactly on the mask boundary. Applying mouse.move's
randomize AFTER it therefore pushes the cursor straight back inside:
randomize=3 offsets each axis by randrange(-3, 3), i.e. -3..+2. That
made the loot-filter clicks intermittent rather than fixed the first
version of this guard had exactly that hole, and its test only checked
the guard's output, never the cursor that was finally clicked.
Order matters: jitter, then guard, then move with no further
randomisation. The human-like offset is preserved; the guarantee is not
given away.
"""
x, y = int(pos_monitor[0]), int(pos_monitor[1])
if jitter and jitter > 0:
x += random.randrange(-jitter, jitter)
y += random.randrange(-jitter, jitter)
return get_closest_non_hud_pixel((x, y), "monitor")
def move(self, pos_monitor: tuple[float, float], force_tp: bool = False, force_move: bool = False):
# Never click into the HUD. Neither branch below did this, so a move
# target low on the screen landed on the interface — most visibly the
# loot-filter toggles at the bottom-left (screen x 395-560, y 692-712),
# which a right-click flips. Reported after Larzuk trips: he stands on
# the left of Harrogath, so moves to and from him aim at that corner.
#
# 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, while the teleport branch clicks the raw
# target. The pather's anti-stuck path already guarded this; move() did
# not.
factor = Config().advanced_options["pathing_delay_factor"]
if "teleport" in Config().char and Config().char["teleport"] and (
force_tp
@@ -223,7 +275,8 @@ class IChar:
)
):
self._set_active_skill("right", "teleport")
mouse.move(pos_monitor[0], pos_monitor[1], randomize=3, delay_factor=[factor*0.1, factor*0.14])
tx, ty = self._hud_safe_target(pos_monitor, 3)
mouse.move(tx, ty, randomize=0, delay_factor=[factor*0.1, factor*0.14])
wait(0.012, 0.02)
mouse.click(button="right")
wait(self._cast_duration, self._cast_duration + 0.02)
@@ -237,7 +290,8 @@ class IChar:
adjust_factor = max(max_wd, min(min_wd, dist - 50)) / max(min_wd, dist)
pos_abs = [int(pos_abs[0] * adjust_factor), int(pos_abs[1] * adjust_factor)]
x, y = convert_abs_to_monitor(pos_abs)
mouse.move(x, y, randomize=5, delay_factor=[factor*0.1, factor*0.14])
x, y = self._hud_safe_target((x, y), 5)
mouse.move(x, y, randomize=0, delay_factor=[factor*0.1, factor*0.14])
wait(0.012, 0.02)
if force_move:
keyboard.send(Config().char["force_move"])
@@ -255,7 +309,9 @@ class IChar:
adjust_factor = max(max_wd, min(min_wd, dist - 50)) / max(min_wd, dist)
pos_abs = [int(pos_abs[0] * adjust_factor), int(pos_abs[1] * adjust_factor)]
x, y = convert_abs_to_monitor(pos_abs)
mouse.move(x, y, randomize=5, delay_factor=[factor*0.1, factor*0.14])
# Same HUD guard as move(). walk() had the identical unguarded click.
x, y = self._hud_safe_target((x, y), 5)
mouse.move(x, y, randomize=0, delay_factor=[factor*0.1, factor*0.14])
wait(0.012, 0.02)
if force_move:
keyboard.send(Config().char["force_move"])
@@ -329,10 +385,20 @@ class IChar:
break
self._weapon_switch()
keyboard.send(Config().char["battle_command"])
# Skill icon takes ~0.5s to update after the hotkey; checking too early
# fails every first pass and costs a full extra swap cycle per game.
wait(0.6, 0.8)
if skills.is_right_skill_selected(["BC", "BO"]):
# Skill icon takes anywhere from ~0.3s to ~0.9s to update after the
# hotkey. A single fixed-wait-then-check raced this and consistently
# logged a false "failed" (the very next loop pass would see the
# skill just fine, with no extra wait in between) — poll instead so
# we catch it the moment it renders, and only swap weapons again if
# it genuinely never shows up.
found = False
poll_start = time.time()
while time.time() - poll_start < 1.2:
if skills.is_right_skill_selected(["BC", "BO"]):
found = True
break
wait(0.1, 0.15)
if found:
switch_success = True
break
else:
+7 -7
View File
@@ -128,7 +128,7 @@ class FoHdin(Paladin):
def kill_pindle(self) -> bool:
atk_len_dur = float(Config().char["atk_len_pindle"])
atk_len_dur = self.atk_len("atk_len_pindle")
pindle_pos_abs = convert_screen_to_abs(Config().path["pindle_end"][0])
cast_pos_abs = [pindle_pos_abs[0] * 0.80, pindle_pos_abs[1] * 0.80]
@@ -162,7 +162,7 @@ class FoHdin(Paladin):
def kill_council(self) -> bool:
atk_len_dur = float(Config().char["atk_len_trav"])
atk_len_dur = self.atk_len("atk_len_trav")
keyboard.send(self._skill_hotkeys["conviction"])
wait(.15)
@@ -184,7 +184,7 @@ class FoHdin(Paladin):
def kill_eldritch(self) -> bool:
eld_pos_abs = convert_screen_to_abs(Config().path["eldritch_end"][0])
atk_len_dur = float(Config().char["atk_len_eldritch"])
atk_len_dur = self.atk_len("atk_len_eldritch")
self._generic_foh_attack_sequence(default_target_abs=eld_pos_abs, min_duration=atk_len_dur, max_duration=atk_len_dur*3, default_spray=70)
@@ -202,7 +202,7 @@ class FoHdin(Paladin):
def kill_shenk(self):
atk_len_dur = float(Config().char["atk_len_shenk"])
atk_len_dur = self.atk_len("atk_len_shenk")
# traverse to shenk
keyboard.send(self._skill_hotkeys["conviction"])
@@ -221,7 +221,7 @@ class FoHdin(Paladin):
def kill_nihlathak(self, end_nodes: list[int]) -> bool:
atk_len_dur = Config().char["atk_len_nihlathak"]
atk_len_dur = self.atk_len("atk_len_nihlathak")
# Move close to nihlathak
self._pather.traverse_nodes(end_nodes, self, timeout=0.8, do_pre_move=False)
if self._select_skill("blessed_hammer"):
@@ -238,7 +238,7 @@ class FoHdin(Paladin):
def kill_summoner(self) -> bool:
# Attack
atk_len_dur = Config().char["atk_len_arc"]
atk_len_dur = self.atk_len("atk_len_arc")
self._generic_foh_attack_sequence(min_duration=atk_len_dur, max_duration=atk_len_dur*2, default_spray=80)
self._activate_cleanse_redemption()
return True
@@ -784,7 +784,7 @@ class FoHdin(Paladin):
def kill_diablo(self) -> bool:
### APPROACH ###
### ATTACK ###
atk_len_dur = float(Config().char["atk_len_diablo"])
atk_len_dur = self.atk_len("atk_len_diablo")
Logger.debug("Attacking Diablo at position 1/1")
diablo_abs = [100,-100] #hardcoded dia pos.
self._generic_foh_attack_sequence(default_target_abs=diablo_abs, min_duration=atk_len_dur, max_duration=atk_len_dur*3, aura="concentration", foh_to_holy_bolt_ratio=2)
+9 -2
View File
@@ -1278,14 +1278,14 @@ class Hammerdin(Paladin):
Logger.debug("Waiting for Diablo to appear...")
start = time.time()
diablo_found = False
while (time.time() - start) < 15:
while (time.time() - start) < 20:
if get_visible_targets():
diablo_found = True
Logger.info("Diablo has spawned, engaging!")
break
wait(0.5, 0.6)
if not diablo_found:
Logger.warning("Diablo did not appear within 15s, attacking anyway")
Logger.warning("Diablo did not appear within 20s, attacking anyway")
### ATTACK WITH CONCENTRATION ###
# Concentration is the Blessed Hammer damage synergy and a party aura the
# merc benefits from. Conviction does nothing for magic-damage hammers, and
@@ -1295,10 +1295,17 @@ class Hammerdin(Paladin):
mouse.move(*pos_m, randomize=80, delay_factor=[0.5, 0.7])
Logger.debug("Attacking Diablo at position 1/1")
self._cast_hammers(Config().char["atk_len_diablo"], "concentration")
# Re-verify targets mid-fight; if Diablo moved, reposition
if get_visible_targets():
pos_m = convert_abs_to_monitor((0, 0))
mouse.move(*pos_m, randomize=80, delay_factor=[0.5, 0.7])
self._move_and_attack((60, 30), Config().char["atk_len_diablo"], "concentration")
self._move_and_attack((-60, -30), Config().char["atk_len_diablo"], "concentration")
wait(0.1, 0.15)
self._cast_hammers(1.2, "redemption")
# Final redemption burst to ensure kill
wait(0.1, 0.2)
self._cast_hammers(0.8, "redemption")
### LOOT ###
# force=True: Diablo is dead; his death animation/lingering effects register
# as targets and the mobs-alive guard would skip his drops entirely.
+5
View File
@@ -30,6 +30,11 @@ class Paladin(IChar):
self._action_frame = 6
def cast_buffs(self, casting_delay: float):
# Skip entirely when Holy Shield is unbound. keyboard.send() tolerates
# the empty key now, but the right-click after it would still fire and
# cast whatever happens to be on the right slot instead.
if not self._skill_hotkeys.get("holy_shield"):
return
keyboard.send(self._skill_hotkeys["holy_shield"])
wait(0.04, 0.1)
mouse.click(button="right")
+67 -1
View File
@@ -26,6 +26,8 @@ class Config:
ui_pos = {}
routes = {}
routes_order = []
cold_plains = {}
baal_xp = {}
char = {}
colors = {}
shop = {}
@@ -259,12 +261,14 @@ class Config:
"custom_message_hook": self._select_val("general", "custom_message_hook"),
"custom_loot_message_hook": self._select_val("general", "custom_loot_message_hook"),
"discord_status_count": False if not self._select_val("general", "discord_status_count") else int(self._select_val("general", "discord_status_count")),
"discord_timing_report_h": 2.0 if not self._select_val("general", "discord_timing_report_h") else float(self._select_val("general", "discord_timing_report_h")),
"discord_status_runs": False if not self._select_optional("general", "discord_status_runs", "") else int(self._select_optional("general", "discord_status_runs", "")),
"discord_log_chicken": bool(int(self._select_val("general", "discord_log_chicken"))),
"discord_log_errors": bool(int(self._select_optional("general", "discord_log_errors", "1"))),
"info_screenshots": bool(int(self._select_val("general", "info_screenshots"))),
"error_screenshots": bool(int(self._select_optional("general", "error_screenshots", "1"))),
"disable_run_after_failures": int(self._select_optional("general", "disable_run_after_failures", "5")),
"pickup_drought_window": int(self._select_optional("general", "pickup_drought_window", "10")),
"pickit_screenshots": bool(int(self._select_val("general", "pickit_screenshots"))),
"d2r_path": _default_iff(self._select_val("general", "d2r_path"), "", r"C:\Program Files (x86)\Diablo II Resurrected"),
"restart_d2r_when_stuck": bool(int(self._select_val("general", "restart_d2r_when_stuck"))),
@@ -294,7 +298,6 @@ class Config:
self.stealth = {
"wait_jitter_min": float(self._select_optional("stealth", "wait_jitter_min", "0.85")),
"wait_jitter_max": float(self._select_optional("stealth", "wait_jitter_max", "1.20")),
"click_variance": int(self._select_optional("stealth", "click_variance", "8")),
"reshuffle_each_rotation": bool(int(self._select_optional("stealth", "reshuffle_each_rotation", "1"))),
"skip_run_chance": int(self._select_optional("stealth", "skip_run_chance", "10")),
"afk_break_chance": int(self._select_optional("stealth", "afk_break_chance", "5")),
@@ -305,6 +308,37 @@ class Config:
"micro_pause_max_ms": int(self._select_optional("stealth", "micro_pause_max_ms", "120")),
"human_curve_complexity": float(self._select_optional("stealth", "human_curve_complexity", "1.0")),
"vary_kill_time": bool(int(self._select_optional("stealth", "vary_kill_time", "1"))),
# These eight were present in params.ini but never loaded here, so
# utils.stealth's cfg.get(key, <hardcoded>) always won and editing
# params.ini had NO effect. They only looked correct because the
# hardcoded fallbacks happened to match the shipped ini values.
"click_delay_min_ms": int(self._select_optional("stealth", "click_delay_min_ms", "50")),
"click_delay_max_ms": int(self._select_optional("stealth", "click_delay_max_ms", "250")),
"key_press_min_ms": int(self._select_optional("stealth", "key_press_min_ms", "20")),
"key_press_max_ms": int(self._select_optional("stealth", "key_press_max_ms", "200")),
"skill_hesitation_min_ms": int(self._select_optional("stealth", "skill_hesitation_min_ms", "80")),
"skill_hesitation_max_ms": int(self._select_optional("stealth", "skill_hesitation_max_ms", "300")),
"wrong_waypoint_chance": float(self._select_optional("stealth", "wrong_waypoint_chance", "0.025")),
"skill_mistake_chance": float(self._select_optional("stealth", "skill_mistake_chance", "0.015")),
# Opt-in switches for behaviours that alter WHERE or WHEN a click
# lands. Both default OFF: the per-call-site randomize= values in
# npc_manager/waypoint are hand-tuned against real button geometry,
# and stacking a global offset on top is what starts missing NPCs.
"click_delay_enabled": bool(int(self._select_optional("stealth", "click_delay_enabled", "0"))),
# Session rhythm — the signals per-action jitter cannot reach.
"session_budget_h": float(self._select_optional("stealth", "session_budget_h", "0")),
# Hard per-CALENDAR-DAY cap. session_budget_h is per process, so a restart
# resets it and a bot restarted all day still runs all day. This one is
# persisted to disk and survives restarts. 0 = unlimited.
"daily_budget_h": float(self._select_optional("stealth", "daily_budget_h", "0")),
"daily_budget_jitter": float(self._select_optional("stealth", "daily_budget_jitter", "0.12")),
# Close D2R when the daily cap trips. A bot process idling at the character
# select screen for 16h is itself a signal; a real player quits the game.
"daily_budget_close_game": bool(int(self._select_optional("stealth", "daily_budget_close_game", "1"))),
"idle_drift_enabled": bool(int(self._select_optional("stealth", "idle_drift_enabled", "1"))),
"chicken_variance": float(self._select_optional("stealth", "chicken_variance", "0.08")),
"town_browse_chance": float(self._select_optional("stealth", "town_browse_chance", "0.06")),
"pickup_skip_chance": float(self._select_optional("stealth", "pickup_skip_chance", "0.02")),
}
self.routes = {}
@@ -372,14 +406,46 @@ class Config:
"runs_per_stash": False if not self._select_val("char", "runs_per_stash") else int(self._select_val("char", "runs_per_stash")),
"runs_per_repair": False if not self._select_val("char", "runs_per_repair") else int(self._select_val("char", "runs_per_repair")),
"repair_npc": self._select_optional("char", "repair_npc", "a4_halbu").strip().lower(),
"resurrect_npc": self._select_optional("char", "resurrect_npc", "").strip().lower(),
"gamble_items": False if not self._select_val("char", "gamble_items") else self._select_val("char", "gamble_items").replace(" ","").split(","),
"sell_junk": bool(int(self._select_val("char", "sell_junk"))),
"protect_shields_from_sell": bool(int(self._select_optional("char", "protect_shields_from_sell", "1"))),
"protect_charms_from_sell": bool(int(self._select_optional("char", "protect_charms_from_sell", "1"))),
"pick_rares_for_gold": bool(int(self._select_optional("char", "pick_rares_for_gold", "0"))),
"pick_gold": bool(int(self._select_optional("char", "pick_gold", "1"))),
"enable_no_pickup": bool(int(self._select_val("char", "enable_no_pickup"))),
"safer_routines": bool(int(self._select_val("char", "safer_routines"))),
}
# Cold Plains clear run
self.cold_plains = dict(self.configs["config"]["parser"]["cold_plains"])
if "cold_plains" in self.configs["profile"]["parser"]:
self.cold_plains.update(dict(self.configs["profile"]["parser"]["cold_plains"]))
if "cold_plains" in self.configs["custom"]["parser"]:
self.cold_plains.update(dict(self.configs["custom"]["parser"]["cold_plains"]))
# Baal XP farm (join public games, hide, collect XP, leave)
self.baal_xp = {
"enabled": self._select_optional("baal_xp", "enabled", "0") not in (None, "", "0", "false", "False"),
"game_name_filter": self._select_optional("baal_xp", "game_name_filter", "") or "",
"max_wait_s": float(self._select_optional("baal_xp", "max_wait_s", "900")),
"xp_threshold": int(self._select_optional("baal_xp", "xp_threshold", "50000000")),
"min_hp_pct": float(self._select_optional("baal_xp", "min_hp_pct", "40")),
"hide_x": int(self._select_optional("baal_xp", "hide_x", "640")),
"hide_y": int(self._select_optional("baal_xp", "hide_y", "360")),
"join_timeout_s": float(self._select_optional("baal_xp", "join_timeout_s", "60")),
}
for _baal_xp_src in ("profile", "custom"):
if "baal_xp" in self.configs[_baal_xp_src]["parser"]:
_baal_xp_over = self.configs[_baal_xp_src]["parser"]["baal_xp"]
for k in list(self.baal_xp.keys()):
if k not in _baal_xp_over:
continue
raw = _baal_xp_over[k]
if k == "enabled":
self.baal_xp[k] = raw not in (None, "", "0", "false", "False")
elif isinstance(self.baal_xp[k], str):
self.baal_xp[k] = raw if raw is not None else ""
else:
self.baal_xp[k] = type(self.baal_xp[k])(raw)
# Sorc base config
sorc_base_cfg = dict(self.configs["config"]["parser"]["sorceress"])
if "sorceress" in self.configs["profile"]["parser"]:
+2 -1
View File
@@ -5,7 +5,8 @@ from d2r_image.processing_data import Runeword
try:
from rapidfuzz.string_metric import levenshtein
except ImportError:
from rapidfuzz.distance import Levenshtein as levenshtein
from rapidfuzz.distance import Levenshtein as _Levenshtein_mod
levenshtein = _Levenshtein_mod.distance
from bnip.NTIPAliasType import NTIPAliasType as NTIP_TYPES
from bnip.NTIPAliasStat import NTIPAliasStat as NTIP_STATS
from logger import Logger
+5
View File
@@ -46,6 +46,11 @@ try:
os.path.join(_APP_BASE, "tesseract", "tesseract.exe"), # bundled in release
shutil.which("tesseract"),
r"C:\Program Files\Tesseract-OCR\tesseract.exe",
r"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe",
# Per-user installs (winget --scope user, or install.bat's direct NSIS
# fallback) land here and are not on PATH -- needed on machines where
# the user has no admin rights.
os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Tesseract-OCR", "tesseract.exe"),
]
_cmd = next((c for c in _candidates if c and os.path.isfile(c)), None)
if _cmd:
+2 -2
View File
@@ -169,8 +169,8 @@ class GameController:
Logger.warning("Your D2R settings differ from the requiered ones. Please use Auto Settings to adjust them. The differences are:")
Logger.warning(f"{diff}")
set_d2r_always_on_top()
if enforce_d2r_window(5, 98):
find_and_set_window_position(force=True)
enforce_d2r_window(5, 98)
find_and_set_window_position(force=True)
self.setup_screen()
self.start_health_manager_thread()
self.start_death_manager_thread()
+10 -1
View File
@@ -1,5 +1,6 @@
from config import Config
from death_manager import DeathManager
from logger import Logger
import time
from input_layer import keyboard
from ui_manager import ScreenObjects, is_visible
@@ -19,7 +20,7 @@ class GameRecovery:
wait(0.1, 0.15)
keyboard.release(Config().char["show_items"])
start = time.time()
while (time.time() - start) < 30:
while (time.time() - start) < 45:
# make sure we are not on loading screen
is_loading = loading.check_for_black_screen()
while is_loading:
@@ -36,6 +37,14 @@ class GameRecovery:
# if we are in game, save and exit
if is_visible(ScreenObjects.InGame):
view.fast_save_and_exit()
wait(1, 1.5) # give the exit time to process before next check
continue
# In-game but InGame marker not visible? Try ESC as fallback.
# The save_and_exit may have hit a UI panel that blocks the exit path.
if (time.time() - start) > 15 and not is_loading:
Logger.info("go_to_hero_selection: sending ESC as fallback")
keyboard.send("esc")
wait(1, 1.5)
continue
wait(1, 1.5)
return False
+14 -4
View File
@@ -83,11 +83,11 @@ class GameStats:
self._events_filename = f'events_{time.strftime("%Y%m%d_%H%M%S")}.jsonl'
self._mini_stats_filename = f'mini_stats_{time.strftime("%Y%m%d_%H%M%S")}.json'
self._nopickup_active = False
# Per-game pickup / problem tracking (rolling 10-game health check)
# Per-game pickup / problem tracking (rolling pickup-drought health check)
self._current_game_had_pickup = False
self._current_game_chickens = 0
self._current_game_merc_deaths = 0
self._recent_games: deque[dict] = deque(maxlen=10)
self._recent_games: deque[dict] = deque(maxlen=Config().general["pickup_drought_window"])
self._starting_exp = 0
self._current_exp = 0
self._current_lvl = 0
@@ -97,6 +97,11 @@ class GameStats:
self._last_status_report_run = 0
self._last_failure_reason = None
self._merc_resurrect_failed = False
# Cross-game merc-resurrect circuit breaker. Qual-Kehk detection can fail 100% of
# the time (stale name-tag template -> a degenerate constant 0.424 match), and the
# per-game flag above does not stop that being retried every game at ~40s a hunt.
self._merc_resurrect_fail_streak = 0
self._merc_resurrect_skip_until = 0
# Auto-downgrade tracking: timestamps of chickens+deaths
self._downgrade_events: deque[float] = deque()
os.makedirs("log/stats", exist_ok=True)
@@ -271,6 +276,10 @@ class GameStats:
self._game_counter += 1
self._timer = time.time()
self._merc_resurrect_failed = False
# NOTE: _merc_resurrect_fail_streak / _merc_resurrect_skip_until are deliberately
# NOT reset here. They are the cross-game circuit breaker — resetting the per-game
# flag alone meant a permanently undetectable resurrect NPC was re-hunted (twice,
# ~40s each) in every single game.
# Clear the previous game's failure reason so this game's events can't be
# attributed to a stale reason from an earlier failure.
self._last_failure_reason = None
@@ -419,14 +428,15 @@ class GameStats:
self._send_status_update()
def _check_pickup_health(self):
"""Warn when the last 10 games contained zero item pickups.
"""Warn when the last N games (config: pickup_drought_window) contained
zero item pickups.
Fires a log warning always, and also sends a Discord alert when chickens or
merc deaths are present (confirms the bot is in active combat but still
collecting nothing strong signal of a real problem vs. a strict filter).
"""
window = list(self._recent_games)
if len(window) < 10:
if len(window) < self._recent_games.maxlen:
return # not enough data yet
games_with_pickup = sum(1 for g in window if g["had_pickup"])
if games_with_pickup > 0:
+115 -3
View File
@@ -18,6 +18,9 @@ from ui_manager import ScreenObjects, is_visible
class HealthManager:
_instance = None
# Consecutive esc attempts allowed against a waypoint panel before it stops being
# treated as benign. Reset as soon as a poll sees no panel at all.
_MAX_WP_PANEL_ESCAPES = 6
def __init__(self):
HealthManager._instance = self
@@ -33,6 +36,9 @@ class HealthManager:
self._callback = None
self._last_chicken_screenshot = None
self._count_panel_detects = 0
self._count_wp_panel_detects = 0
self._count_center_panel_detects = 0
self._mana_below_threshold = False
def stop_monitor(self):
self._do_monitor = False
@@ -50,6 +56,7 @@ class HealthManager:
# game don't cause an immediate chicken on the next game's first panel.
with self._state_lock:
self._count_panel_detects = 0
self._count_wp_panel_detects = 0
def get_pause_state(self):
with self._state_lock:
@@ -155,7 +162,7 @@ class HealthManager:
f"but HP is safe at {(health_percentage*100):.1f}%; not chickening"
)
chicken_threshold = Config().char["chicken"]
chicken_threshold = get_game_chicken_threshold()
if loot_priority_active():
chicken_threshold = max(chicken_threshold * 0.5, LOOT_PRIORITY_HP_FLOOR)
if health_percentage <= chicken_threshold:
@@ -172,6 +179,22 @@ class HealthManager:
self._last_health = time.time()
# check mana
last_drink = time.time() - self._last_mana
# Issue #23 instrumentation. "1 mana potion per game,
# never 2" was measured over 70 games, but the cause
# was undecidable: mana is only logged when a potion
# is DRUNK, so a second dip that failed to trigger
# looks identical to mana never dipping twice. Log
# every crossing of the threshold, whether or not it
# results in a drink.
_mana_low = mana_percentage <= Config().char["take_mana_potion"]
if _mana_low and not self._mana_below_threshold:
Logger.debug(
f"MANA> crossed below {Config().char['take_mana_potion']:.2f} "
f"at {mana_percentage*100:.1f}% "
f"(last drink {last_drink:.1f}s ago, gate {lp_mp_potion_delay}s) "
f"-> {'will drink' if last_drink > lp_mp_potion_delay else 'BLOCKED by gate'}"
)
self._mana_below_threshold = _mana_low
if mana_percentage <= Config().char["take_mana_potion"] and last_drink > lp_mp_potion_delay:
wait(0.05, 0.1)
if belt.drink_potion("mana", stats=[health_percentage, mana_percentage]):
@@ -196,8 +219,70 @@ class HealthManager:
if belt.drink_potion("health", merc=True, stats=[merc_health]):
self._last_merc_heal = time.time()
# Close any open panels that might block detection
# Close any open panels that might block detection
# A CENTRED panel (Chronicle) matches neither header ROI, so
# nothing ever closed it — and a centred panel blanks every
# later template search. Measured 2026-08-28: Chronicle open,
# A5_RED_PORTAL unfindable, 66s approach failure. It is not a
# threat, so escape it without counting toward a chicken,
# bounded the same way as the waypoint panel.
# The in-game ESC menu. A stray esc opens it, and its LOOT
# FILTER / CHRONICLE / OPTIONS buttons sit at screen centre
# where movement clicks land — which is how the loot filter
# got toggled, how Chronicle blocked every template match,
# and how the bot ended up in the video options with a
# "settings have changed" modal. It has no close button, so
# CenterPanel cannot detect it. esc toggles it shut.
if not self.get_panel_check_paused() and is_visible(ScreenObjects.GameMenu, img):
self._count_center_panel_detects = getattr(self, "_count_center_panel_detects", 0) + 1
if self._count_center_panel_detects <= self._MAX_WP_PANEL_ESCAPES:
Logger.debug(
f"In-game menu open (its buttons sit under movement clicks) — closing it "
f"({self._count_center_panel_detects}/{self._MAX_WP_PANEL_ESCAPES})"
)
from input_layer import keyboard as kb
kb.send("esc")
wait(0.1, 0.2)
fn_end = time.perf_counter()
wait(max(0.01, (1/15 - (fn_end - fn_start)) * random.uniform(0.8, 1.2)))
continue
Logger.warning("In-game menu would not close — treating as a blocking panel")
if not self.get_panel_check_paused() and is_visible(ScreenObjects.CenterPanel, img):
self._count_center_panel_detects = getattr(self, "_count_center_panel_detects", 0) + 1
if self._count_center_panel_detects <= self._MAX_WP_PANEL_ESCAPES:
Logger.debug(
f"Centred panel open (blocks all template matching) — closing it "
f"({self._count_center_panel_detects}/{self._MAX_WP_PANEL_ESCAPES})"
)
from input_layer import keyboard as kb
kb.send("esc")
wait(0.1, 0.2)
fn_end = time.perf_counter()
wait(max(0.01, (1/15 - (fn_end - fn_start)) * random.uniform(0.8, 1.2)))
continue
Logger.warning("Centred panel would not close — treating as a blocking panel")
if not self.get_panel_check_paused() and (is_visible(ScreenObjects.LeftPanel, img) or is_visible(ScreenObjects.RightPanel, img)):
# A waypoint panel is self-inflicted: the pather walked the char
# over the WP stone. That is a navigation problem, not a threat,
# and chickening on it threw away healthy games. Escape it without
# counting — but stay bounded, so a WP panel we genuinely cannot
# close still falls through to the normal chicken path.
if is_visible(ScreenObjects.WaypointLabel, img):
self._count_wp_panel_detects += 1
if self._count_wp_panel_detects <= self._MAX_WP_PANEL_ESCAPES:
Logger.debug(
f"Waypoint panel open (walked onto the WP) — closing it "
f"({self._count_wp_panel_detects}/{self._MAX_WP_PANEL_ESCAPES})"
)
from input_layer import keyboard as kb
kb.send("esc")
wait(0.1, 0.2)
fn_end = time.perf_counter()
wait(max(0.01, (1/15 - (fn_end - fn_start)) * random.uniform(0.8, 1.2)))
continue
Logger.warning("Waypoint panel would not close — treating as a blocking panel")
self._count_panel_detects += 1
if self._count_panel_detects >= 2:
self._count_panel_detects = 0
@@ -205,7 +290,15 @@ class HealthManager:
self._do_chicken(img)
continue
Logger.debug("Found an open panel. Closing it.")
common.close()
# Send Escape directly — more reliable than common.close() which
# only checks inventory_is_open() and may miss belt/panel states
from input_layer import keyboard as kb
kb.send("esc")
wait(0.1, 0.2)
else:
# Screen is clear, so any waypoint panel we escaped did close.
self._count_wp_panel_detects = 0
self._count_center_panel_detects = 0
fn_end = time.perf_counter()
# Target ~15 FPS polling with anti-cheat jitter
@@ -244,6 +337,25 @@ def get_pause_state():
return HealthManager._instance.get_pause_state()
return True
_game_chicken_threshold = None
def set_game_chicken_threshold(value):
"""Per-game chicken threshold, rolled by the BOT thread at game start.
The health manager thread is a read-only monitor by design and must not do
the rolling itself see the threading rules in CLAUDE.md.
"""
global _game_chicken_threshold
_game_chicken_threshold = value
def get_game_chicken_threshold() -> float:
if _game_chicken_threshold is not None:
return _game_chicken_threshold
return Config().char["chicken"]
def set_pause_state(state: bool):
"""Backwards-compatible wrapper that delegates to the singleton instance."""
if HealthManager._instance is not None:
+155 -15
View File
@@ -7,13 +7,120 @@ Import as:
This is a drop-in replacement for the existing `import keyboard` and
`from utils.custom_mouse import mouse` patterns.
On non-Windows (Docker/Linux), uses bridge_input to talk to a Windows host
via TCP. Set BOTTY_BRIDGE_HOST / BOTTY_BRIDGE_PORT env vars.
"""
from .win_input import (
_get_vk, key_down, key_up, key_press, send_key, key_state,
mouse_move, mouse_down, mouse_up, mouse_click, mouse_wheel, get_cursor_pos,
send_text, VK_MAP
)
from .mouse_impl import mouse
import re as _re
from logger import Logger
import os as _os
import threading as _threading
if _os.name == "nt":
from .win_input import (
_get_vk, key_down, key_up, key_press, send_key, key_state,
mouse_move, mouse_down, mouse_up, mouse_click, mouse_wheel, get_cursor_pos,
send_text, VK_MAP, _USE_ABSOLUTE_MOUSE
)
else:
try:
from .bridge_input import (
_get_vk, key_down, key_up, key_press, key_state,
mouse_move, mouse_down, mouse_up, mouse_click, mouse_wheel, get_cursor_pos,
send_text, VK_MAP, _USE_ABSOLUTE_MOUSE
)
except ImportError:
# bridge_input not available - provide stubs
_get_vk = None
key_down = None
key_up = None
key_press = None
key_state = None
mouse_move = None
mouse_down = None
mouse_up = None
mouse_click = None
mouse_wheel = None
get_cursor_pos = None
send_text = None
VK_MAP = {}
_USE_ABSOLUTE_MOUSE = False
# bridge_input has no send_key; provide stub
def send_key(key):
if key_press:
key_press(key)
if _os.name == "nt":
from .mouse_impl import mouse
else:
# In bridge mode, mouse_impl imports from win_input which won't work.
# Provide a thin wrapper that delegates to bridge_input.
class _BridgeMouse:
def move_to(self, x, y): mouse_move(x, y)
def move(self, x, y): mouse_move(x, y)
def click(self, button="left"): mouse_click(button)
def down(self, button="left"): mouse_down(button)
def up(self, button="left"): mouse_up(button)
def wheel(self, clicks): mouse_wheel(clicks)
def get_pos(self): return get_cursor_pos()
mouse = _BridgeMouse()
# ─── Stealth key classification ───────────────────────────────────────────────
# Skill casts may carry human hesitation. Potion/belt keys MUST NOT: they are
# driven by health_manager at low HP, so any added latency is a death risk.
#
# The previous gate was `vk in range(ord('1'), ord('0') + 1)`. ord('1')=49 and
# ord('0')=48, so that range is EMPTY and never matched. Only the string
# fallback fired — and it listed the digits, i.e. the potion keys. The gate was
# therefore aimed at the exact inverse of its intent: every potion press got
# 80-300ms of hesitation and a 1.5% chance of pressing a different potion first,
# while actual skill casts (f1-f12) were never touched.
_SKILL_KEY_RE = _re.compile(r"^f([1-9]|1[0-2])$")
_key_class_cache = {}
def _never_stealth_keys() -> set:
"""Potion and belt keys — exempt from all stealth timing, by construction."""
if "never" not in _key_class_cache:
keys = {"1", "2", "3", "4"}
try:
from config import Config
char = Config().char
for name in ("potion1", "potion2", "potion3", "potion4", "show_belt"):
val = char.get(name)
if val:
keys.add(str(val).strip().lower())
except Exception:
pass
_key_class_cache["never"] = keys
return _key_class_cache["never"]
def _bound_skill_keys() -> list:
"""Skill hotkeys actually bound by the active character."""
if "skills" not in _key_class_cache:
found = set()
try:
from config import Config
cfg = Config()
section = getattr(cfg, str(cfg.char.get("type", "")).lower(), None)
if isinstance(section, dict):
for val in section.values():
k = str(val).strip().lower()
if _SKILL_KEY_RE.match(k):
found.add(k)
except Exception:
pass
_key_class_cache["skills"] = sorted(found - _never_stealth_keys())
return _key_class_cache["skills"]
def _is_skill_key(key: str) -> bool:
k = str(key or "").strip().lower()
if k in _never_stealth_keys():
return False
return bool(_SKILL_KEY_RE.match(k))
class _Keyboard:
"""
@@ -74,16 +181,22 @@ class _Keyboard:
except Exception:
return False
# Press a random skill key first (simulates miscast)
# Press a different BOUND SKILL first (simulates a miscast). The old code
# built its candidate list from range(ord('1'), ord('0')+1) — an empty
# range — so random.choice() raised and the except branch always ran,
# picking from ['1'..'5']: potion keys. A "miscast" that drinks a potion
# is not a miscast, it is a bug with a costume on.
import random
candidates = [k for k in _bound_skill_keys() if k != str(key).strip().lower()]
if not candidates:
return False
wrong_key = random.choice(candidates)
try:
from config import Config
skill_keys = list(range(ord('1'), ord('0') + 1)) # Keys 1-0
wrong_key = chr(random.choice(skill_keys))
if wrong_key == key:
wrong_key = chr(random.choice(skill_keys))
from utils.stealth import _tl
_tl("skill_mistake", "ok", f"miscast {wrong_key} before {key}")
except Exception:
wrong_key = random.choice(['1', '2', '3', '4', '5'])
pass
# Send the wrong key
wrong_vk = _get_vk(wrong_key)
@@ -99,9 +212,19 @@ class _Keyboard:
"""
Send a key press event with stealth timing.
An EMPTY key is a no-op, not an error. An unbound optional skill is a
legitimate configuration a FoHdin with no Holy Shield, no Vigor, no
Cleansing and there are 90+ `keyboard.send(self._skill_hotkeys[x])`
call sites across the paladin classes alone, almost none of which check
first. Raising here kills the whole bot thread for a skill the
character was never meant to cast. Guard once, at the boundary.
Supports combo keys like 'shift + a', 'ctrl + alt + del'.
Supports do_release=False (hold key) and do_press=False (release-only).
"""
if key is None or str(key).strip() == "":
Logger.debug("keyboard.send: empty key (unbound skill) — skipping")
return
self._stealth_before()
# Handle combo keys (e.g. 'shift + a', 'ctrl + alt + del')
@@ -144,8 +267,10 @@ class _Keyboard:
from utils.misc import wait as _wait
_wait(delay, delay * 1.2)
# Tier 2 stealth: skill hesitation (only for skill hotkeys 1-0)
if vk in range(ord('1'), ord('0') + 1) or key in ('1', '2', '3', '4', '5', '6', '7', '8', '9', '0'):
# Tier 2 stealth: hesitation + miscast, SKILL HOTKEYS ONLY.
# Potion/belt keys are excluded by _is_skill_key — see the note above
# _SKILL_KEY_RE for why the old digit-based gate did the opposite.
if _is_skill_key(key):
self._skill_rotation_hesitation()
self._maybe_skill_mistake(key)
@@ -176,11 +301,22 @@ class _Keyboard:
def add_hotkey(self, key: str, callback, suppress: bool = False):
"""Register a global hotkey callback."""
if _os.name != "nt":
# In Docker, hotkeys are not available — no-op
return
from .hotkey import add_hotkey as _add_hotkey
_add_hotkey(key, callback, suppress=suppress)
def wait(self, key: str = None, suppress: bool = False):
"""Block until the key is pressed. If key is None, wait for any key."""
if _os.name != "nt":
# In Docker, block forever (or until SIGTERM) — bot runs headless
import signal
event = _threading.Event()
signal.signal(signal.SIGTERM, lambda *_: event.set())
signal.signal(signal.SIGINT, lambda *_: event.set())
event.wait()
return
from .hotkey import wait as _wait
return _wait(key, suppress=suppress)
@@ -190,11 +326,15 @@ class _Keyboard:
def hook(self, callback, suppress: bool = False):
"""Register a callback for all key events (dev tools only)."""
if _os.name != "nt":
return
from .hotkey import hook as _hook
return _hook(callback, suppress=suppress)
def pause(self, seconds: float = 0, suppress: bool = False):
"""Pause key processing (used by npc_auto_label.py)."""
if _os.name != "nt":
return
from .hotkey import pause as _pause
return _pause(seconds, suppress)
+85 -21
View File
@@ -1,7 +1,8 @@
"""
Global hotkey polling via GetAsyncKeyState.
Replaces keyboard.add_hotkey(), keyboard.wait(), keyboard.is_pressed().
No kernel driver - pure user-mode polling thread.
Global hotkey via keyboard library's WH_KEYBOARD_LL hook.
Intercepts all keystrokes regardless of which window has focus.
Falls back to GetAsyncKeyState polling if the keyboard library
fails to install its hook (e.g. antivirus interference).
"""
import threading
import time
@@ -9,24 +10,81 @@ import ctypes
from ctypes import wintypes
from .win_input import _get_vk, VK_MAP, user32
try:
import keyboard as _keyboard
_HAS_KEYBOARD = True
except Exception:
_HAS_KEYBOARD = False
class _HotkeyManager:
def __init__(self):
self._callbacks = {} # vk -> [(key_str, callback), ...]
self._running = False
self._thread = None
self._suppress = {} # vk -> bool (suppress key after callback fires)
self._suppress = {} # vk -> bool
self._lock = threading.Lock()
self._suppressed = set() # vks currently being held down in suppress mode
self._held = set() # vks seen down on the previous poll (edge-trigger)
self._held = set()
self._suppressed = set()
self._poll_thread = None
# Track which keys are registered with the keyboard library
self._keyboard_callbacks = {} # vk -> keyboard callback wrapper
def _ensure_running(self):
if not self._running:
self._running = True
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
self._thread.start()
if self._running:
return
self._running = True
if _HAS_KEYBOARD:
# Use keyboard library's global hook - works even when D2R has focus
try:
self._keyboard_hook = _keyboard.hook(self._keyboard_callback, suppress=False)
return
except Exception:
pass # Fall through to polling
# Fallback: GetAsyncKeyState polling (only works when bot has focus)
self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
self._poll_thread.start()
def _keyboard_callback(self, event):
"""Callback from keyboard library's global hook."""
if event.event_type != _keyboard.KEY_DOWN:
return
# Map keyboard event name to VK code
vk = None
try:
# keyboard library uses names like 'f11', 'f12', etc.
vk = _get_vk(event.name)
except Exception:
return
if vk is None:
return
with self._lock:
if vk not in self._callbacks:
return
if vk in self._held or vk in self._suppressed:
return
self._held.add(vk)
entries = list(self._callbacks[vk])
for key_str, cb in entries:
try:
cb()
except Exception:
import traceback
traceback.print_exc()
if self._suppress.get(vk, False):
self._suppressed.add(vk)
# Suppress: block the key from reaching the app
event.suppress()
break
def _poll_loop(self):
"""Poll GetAsyncKeyState for registered hotkeys."""
"""Fallback: poll GetAsyncKeyState when global hook is unavailable."""
while self._running:
with self._lock:
items = list(self._callbacks.items())
@@ -36,7 +94,6 @@ class _HotkeyManager:
self._held.discard(vk)
continue
if vk in self._held or vk in self._suppressed:
# Still held since last poll - fire only on the down edge
continue
self._held.add(vk)
for key_str, cb in entries:
@@ -48,20 +105,18 @@ class _HotkeyManager:
if self._suppress.get(vk, False):
self._suppressed.add(vk)
# Wait for key release if suppressed
if self._suppressed:
still_suppressed = set()
for vk in self._suppressed:
state = user32.GetAsyncKeyState(vk)
if not (state & 0x8000):
# Key released
pass
else:
still_suppressed.add(vk)
self._suppressed = still_suppressed
from utils.misc import wait as _wait
_wait(0.018, 0.024) # ~50Hz polling with jitter (anti-cheat: non-perfect timing)
_wait(0.018, 0.024)
def add_hotkey(self, key: str, callback, suppress: bool = False):
vk = _get_vk(key)
@@ -115,15 +170,12 @@ class _HotkeyManager:
from utils.misc import wait as _wait
_wait(0.018, 0.024)
else:
# keyboard.wait() with no key blocks forever (keeps main thread alive
# while the daemon hotkey/bot threads run) - match that semantic.
while True:
time.sleep(1.0)
def hook(self, callback, suppress: bool = False):
"""Register a callback for all key events.
This is a simplified version - polls all known keys and calls callback.
Used by gen_ocr_samples.py and node_recorder.py (dev tools only)."""
Simplified polling version for dev tools."""
def _poll_all():
while self._running:
for vk in range(1, 256):
@@ -142,7 +194,6 @@ class _HotkeyManager:
from utils.misc import wait as _wait
_wait(0.02, 0.02)
self._ensure_running()
# Run hook in its own thread
t = threading.Thread(target=_poll_all, daemon=True)
t.start()
@@ -151,6 +202,16 @@ class _HotkeyManager:
from utils.misc import wait as _wait
_wait(seconds, seconds)
def stop(self):
"""Uninstall the hook and stop threads."""
self._running = False
if _HAS_KEYBOARD and hasattr(self, '_keyboard_hook'):
try:
_keyboard.unhook(self._keyboard_hook)
except Exception:
pass
# Singleton
_hotkey_manager = _HotkeyManager()
@@ -173,3 +234,6 @@ def hook(callback, suppress: bool = False):
def pause(seconds: float = 0, suppress: bool = False):
return _hotkey_manager.pause(seconds, suppress)
def stop_hotkeys():
_hotkey_manager.stop()
-35
View File
@@ -203,34 +203,6 @@ class mouse:
_native_move(int(point[0]), int(point[1]))
mouse.sleep(delta)
@staticmethod
def stealth_move(x, y, absolute: bool = True, randomize: int | tuple[int, int] = 5,
delay_factor: tuple[float, float] = [0.4, 0.6]):
"""Move with full stealth chain: pre-movement pause -> randomized position -> endpoint wobble."""
try:
from utils.stealth import randomize_click_position, add_micro_pause, endpoint_wobble
rx, ry = randomize_click_position(x, y)
add_micro_pause()
except Exception:
from logger import Logger
Logger.warning("[Stealth] randomize_click_position/add_micro_pause failed, using manual fallback")
try:
from config import Config
variance = Config().stealth["click_variance"]
except Exception:
variance = 0
rx = x + random.randint(-variance, variance)
ry = y + random.randint(-variance, variance)
mouse.move(rx, ry, absolute=absolute, randomize=5 + variance, delay_factor=delay_factor)
try:
from utils.stealth import endpoint_wobble
wx, wy = endpoint_wobble(rx, ry)
_native_move(wx, wy)
except Exception:
from logger import Logger
Logger.warning("[Stealth] endpoint_wobble failed, skipping micro-adjustment")
pass
@staticmethod
def _is_clicking_safe():
try:
@@ -262,13 +234,6 @@ class mouse:
if button != "left" or mouse._is_clicking_safe():
# DISABLED STEALTH FOR DEBUGGING
# try:
# from utils.stealth import apply_click_delay
# apply_click_delay()
# except Exception:
# from logger import Logger
# Logger.warning("[Stealth] apply_click_delay failed, falling back to manual delay")
# from utils.misc import wait as _wait
# _wait(0.05, 0.3)
from logger import Logger
Logger.debug(f"Executing native click: {button}")
_native_click(button)
+5
View File
@@ -300,6 +300,11 @@ def mouse_move(x: int, y: int):
Logger.warning(f"SetCursorPos failed for ({target_x}, {target_y}); continuing with current cursor position")
except Exception:
pass
# Ensure the cursor is exactly on target before any click (SetCursorPos can
# land 1-2px off on some DPI/virtual-desktop setups; D2R UI hitboxes are tight).
cur_x, cur_y = get_cursor_pos()
if abs(cur_x - target_x) > 0 or abs(cur_y - target_y) > 0:
user32.SetCursorPos(target_x, target_y)
def mouse_down(button: str = "left"):
"""Press mouse button down."""
+6
View File
@@ -54,6 +54,12 @@ def open(img: np.ndarray = None) -> np.ndarray:
opened = _try_open_with_click()
if not opened:
Logger.warning("Could not open belt after key and click recovery attempts")
# Force-close any partially-open belt to prevent panel detection from triggering chicken
try:
keyboard.send("esc")
wait(0.2, 0.3)
except Exception:
pass
return None
img = grab()
return img
+47 -9
View File
@@ -172,25 +172,39 @@ def stash_all_items(items: list = None, game_stats = None):
common.select_stash_page(stash.get_curr_stash()["items"])
# stash stuff
transfer_failures = 0
tabs_tried_after_transfer_failure = 0
while True:
items = transfer_items(items, "stash")
if items and any([item.keep for item in items]):
# Items remain. Distinguish a genuinely full stash tab (no empty slot)
# from a transfer failure (slot free but the click was rejected — e.g.
# the equipped-area guard, or a transient UI hiccup). Only advance tabs
# and risk stash_full() (which taskkills D2R + fires a Discord alert)
# when the page truly has NO empty slot; otherwise a transient failure
# would page through every tab and kill the game.
# the equipped-area guard, or a transient UI hiccup). A genuinely full
# tab advances below and may call stash_full(). A transfer failure also
# advances (after a couple of retries on the same tab), trying every
# other tab in turn — but never calls stash_full() itself, since that's
# reserved for a confirmed "no empty slot anywhere" full stash. Only once
# every tab has been tried and still fails do we give up and leave the
# remaining items in inventory.
if is_visible(ScreenObjects.EmptyStashSlot, grab()):
transfer_failures += 1
remaining = len([i for i in items if i.keep])
Logger.warning(
f"stash_all_items: {remaining} keep item(s) not stashed though this page "
f"has free slots — treating as transfer failure {transfer_failures}/2, not advancing tabs")
f"has free slots — treating as transfer failure {transfer_failures}/2")
if transfer_failures >= 2:
Logger.error("stash_all_items: transfers keep failing on a non-full page; "
"leaving remaining items in inventory instead of declaring stash full")
break
tabs_tried_after_transfer_failure += 1
if tabs_tried_after_transfer_failure > 5:
Logger.error("stash_all_items: transfers keep failing across every stash tab; "
"leaving remaining items in inventory.")
break
Logger.warning("stash_all_items: transfer keeps failing on this tab despite a "
"free slot — trying the next tab instead of giving up.")
if Config().char["fill_shared_stash_first"]:
stash.set_curr_stash(items=(stash.get_curr_stash()["items"] - 1) % 6)
else:
stash.set_curr_stash(items=(stash.get_curr_stash()["items"] + 1) % 6)
transfer_failures = 0
common.select_stash_page(stash.get_curr_stash()["items"])
continue
# could not stash all items, stash tab is genuinely full
Logger.debug("Wanted to stash item, but it's still in inventory. Assumes full stash. Move to next.")
@@ -465,7 +479,24 @@ def transfer_items(items: list, action: str = "drop", img: np.ndarray = None) ->
Logger.error(f"transfer_items: Can't perform, vendor is not open")
case "stash":
if is_visible(ScreenObjects.GoldBtnStash, img):
filtered = [ item for item in items if item.keep == True ]
# Kept items, PLUS anything the protection guard has made both unsellable
# and undroppable. Without the second group a protected item the pickit
# rejected can never leave the pack: sell and drop are blocked and this
# filter used to exclude it, so it occupied its slot forever (673 blocked
# sells across 7 charms in one session).
#
# Scoped to the LOOT columns on purpose. A charm's bonus only applies from
# the inventory, and the pickit cannot tell a wanted res charm from junk —
# LAPIS SMALL CHARM OF VITA (+20 life, cold res 7%) is "Discarding" simply
# because the rule wants coldresist >= 11. So treat position as the intent
# signal: charms parked in the RESERVED columns are deliberate keepers and
# are left alone (the click guard makes them untouchable anyway), while
# freshly looted ones landing in the loot columns get stashed.
loot_cols = Config().char["num_loot_columns"]
filtered = [
item for item in items
if item.keep or (_is_protected(item) and item.column < loot_cols)
]
else:
Logger.error(f"transfer_items: Can't perform, stash is not open")
case _:
@@ -510,6 +541,13 @@ def transfer_items(items: list, action: str = "drop", img: np.ndarray = None) ->
# item successfully transferred, delete from list
item_label = f" '{item.name}'" if item.name else ""
Logger.debug(f"Confirmed {action}{item_label} at position {item.pos}")
# Mirror into the TOWN timeline so sells/stashes/drops of individual items
# appear in the same greppable sequence as the maintenance steps.
try:
from bot import Bot
Bot.timeline("town", f"item_{action}", "ok", f"{item.name or '?'} @ {item.pos}")
except Exception:
pass
for cnt, o_item in enumerate(items):
if o_item.pos == item.pos:
items.pop(cnt)
+35
View File
@@ -216,6 +216,30 @@ class PickIt:
self._prev_item_pickup_attempt = item
return self._yoink_item(item, char)
def _should_walk_past(self, item) -> bool:
"""Per-ITEM stealth skip, decided once and remembered.
pick_up_items loops until a timeout, re-locating items after every
pickup, so the same item is evaluated many times. Rolling on each
evaluation made the effective skip rate far higher than configured and
could re-roll an item already skipped.
The caller MUST advance item_count before continuing it is incremented
at the end of the loop body, so a bare `continue` re-evaluates the same
item until the pickit phase times out.
"""
if not hasattr(self, "_stealth_skipped"):
self._stealth_skipped = {}
key = getattr(item, "ID", None) or getattr(item, "Name", None)
if key is None:
return False
if key not in self._stealth_skipped:
from utils.stealth import should_skip_pickup
self._stealth_skipped[key] = should_skip_pickup()
if self._stealth_skipped[key]:
Logger.debug(f"Stealth: walking past {item.Name}")
return self._stealth_skipped[key]
def pick_up_items(self, char: IChar, force: bool = False) -> bool:
"""
To be called everytime the bot wants to pick up items
@@ -273,6 +297,14 @@ class PickIt:
cached_pickup = True
raw_expression = "pick_rares_for_gold=1 (rare quality override)"
if cached_pickup and not (self._ignore_consumable(item) or self._ignore_gold(item)):
# Stealth: occasionally walk past something the filter wanted.
# Decided ONCE per item id and remembered: this loop re-scans
# and re-evaluates until a timeout, so rolling per evaluation
# both compounds the real skip rate and re-rolls an item that
# was already skipped.
if self._should_walk_past(item):
item_count += 1
continue
Logger.debug(f"Pick up expression: {raw_expression}")
Logger.info(f"Attempt to pick up {item.Name} at distance {item.Distance}")
pick_up_res = self._pick_up_item(char, item)
@@ -298,6 +330,9 @@ class PickIt:
if not pickup:
Logger.debug(f"Skip item {item.Name} at distance {item.Distance}: no matching pickit rule")
if pickup:
if self._should_walk_past(item):
item_count += 1
continue
Logger.debug(f"Pick up expression: {raw_expression}")
Logger.info(f"Attempt to pick up {item.Name} at distance {item.Distance}")
pick_up_res = self._pick_up_item(char, item)
+35 -7
View File
@@ -7,15 +7,32 @@ import threading
import traceback
import warnings
import zipfile
from logging.handlers import TimedRotatingFileHandler
from logging.handlers import RotatingFileHandler
from version import __version__
from colorama import Fore, Back, Style, init
import time
init()
class ArchiveRotatingFileHandler(TimedRotatingFileHandler):
"""TimedRotatingFileHandler that zips rotated files into log/archive/."""
# Hard cap on the live log file so a long/spammy session can't balloon log.txt
# to many GB (it has happened — a single session produced a 22 GB log). Each
# rotated file is zipped into log/archive/; backups + archives are both bounded,
# so total disk use is capped. Override the per-file cap with BOTTY_LOG_MAX_MB.
try:
LOG_FILE_MAX_BYTES = int(float(os.environ.get("BOTTY_LOG_MAX_MB", "50")) * 1024 * 1024)
except (TypeError, ValueError):
LOG_FILE_MAX_BYTES = 50 * 1024 * 1024
LOG_FILE_BACKUPS = 5 # rotated log.txt.1..5 before zipping
LOG_ARCHIVE_MAX = 30 # keep at most this many zipped archives
class ArchiveRotatingFileHandler(RotatingFileHandler):
"""Size-capped RotatingFileHandler that zips rotated files into log/archive/.
Size-based (not time-based) so the live log.txt can never exceed maxBytes
rotation also fires mid-session, not only at midnight. The .1/.2 backup
naming matches what RotatingFileHandler produces (the old TimedRotating
base produced date-suffixed names this archiver never found)."""
def doRollover(self):
# Let the parent rotate the file (creates log.txt.1, etc.)
@@ -43,6 +60,18 @@ class ArchiveRotatingFileHandler(TimedRotatingFileHandler):
# If zipping fails, leave the rotated file in place
pass
# Prune old archives so log/archive/ can't grow without bound either.
try:
zips = sorted(
(os.path.join(archive_dir, f) for f in os.listdir(archive_dir)
if f.startswith("log_") and f.endswith(".zip")),
key=os.path.getmtime,
)
for old in zips[:-LOG_ARCHIVE_MAX]:
os.remove(old)
except Exception:
pass
class CustomFormatter(logging.Formatter):
_format = f'[{__version__} %(asctime)s] %(levelname)-10s %(message)s'
@@ -153,12 +182,11 @@ class Logger:
Logger.console_handler = logging.StreamHandler(sys.stdout)
Logger.console_handler.setLevel(Logger._logger_level)
# Setup the file handler (rotating — daily, archives to log/archive/)
# Setup the file handler (size-capped, archives to log/archive/)
Logger.file_handler = ArchiveRotatingFileHandler(
Logger._current_log_file_path,
when='midnight',
interval=1,
backupCount=7,
maxBytes=LOG_FILE_MAX_BYTES,
backupCount=LOG_FILE_BACKUPS,
encoding='utf-8'
)
Logger.file_handler.setLevel(Logger._logger_level)
+129 -11
View File
@@ -60,6 +60,37 @@ def start_or_pause_bot(controllers: Controllers):
screen.start_detecting_window()
controllers.game.start()
def handle_hermes_command(data: str, controllers: Controllers):
"""Handle one hermes control-socket command. Returns a reply string or None.
'start' used to be a plain alias for 'pause' the same toggle. A caller
retrying a timed-out 'start' therefore PAUSED the bot. On 2026-08-28 that
left it frozen for 4h50m, and `status` still answered running=True because
that field reports the game controller, not Bot._pausing.
'start' is now idempotent: it starts a stopped bot, resumes a paused one,
and does nothing to a healthy one. Use 'pause'/'toggle' for the toggle.
"""
bot = getattr(controllers.game, "bot", None)
paused = bool(getattr(bot, "_pausing", False))
if data == "start":
if not controllers.game.is_running:
start_or_pause_bot(controllers)
elif paused:
start_or_pause_bot(controllers) # resume
return None # already running and not paused: no-op
if data in ("pause", "toggle"):
start_or_pause_bot(controllers)
return None
if data == "stop":
on_exit(controllers)
return None
if data == "status":
return f"running={controllers.game.is_running} paused={paused}"
return None
def start_or_stop_graphic_debugger(controllers: Controllers):
if controllers.debugger.is_running:
controllers.debugger.stop()
@@ -80,6 +111,11 @@ def on_exit(controllers: Controllers):
Logger.warning(f"Failed to save session report: {e}")
screen.stop_detecting_window()
restore_d2r_window_visibility()
try:
from input_layer.hotkey import stop_hotkeys
stop_hotkeys()
except Exception:
pass
os._exit(1)
def _log_platform_info():
@@ -92,8 +128,11 @@ def _log_platform_info():
Logger.info(f"Environment profile: {os_info.environment_file}")
Logger.info(f"Requirements profile: {os_info.requirements_file}")
from input_layer.win_input import _USE_ABSOLUTE_MOUSE
mouse_mode = "absolute (Windows 10)" if _USE_ABSOLUTE_MOUSE else "relative (Windows 11)"
try:
from input_layer.win_input import _USE_ABSOLUTE_MOUSE
mouse_mode = "absolute (Windows 10)" if _USE_ABSOLUTE_MOUSE else "relative (Windows 11)"
except ImportError:
mouse_mode = "bridge (Docker)"
Logger.info(f"Mouse input mode: {mouse_mode}")
# OCR backend check
@@ -143,15 +182,19 @@ def main():
startup_checks()
# Auto-launch D2R only when explicitly enabled in params.ini (auto_login=1)
from utils.restart import process_exists, restart_game
if not process_exists("D2R.exe"):
if Config().general["auto_login"]:
Logger.info("D2R is not running, launching with auto-login...")
restart_game(Config().general["d2r_path"], Config().advanced_options["launch_options"])
# In Docker, D2R runs on the Windows host — skip process checks
if os.name == "nt":
from utils.restart import process_exists, restart_game
if not process_exists("D2R.exe"):
if Config().general["auto_login"]:
Logger.info("D2R is not running, launching with auto-login...")
restart_game(Config().general["d2r_path"], Config().advanced_options["launch_options"])
else:
Logger.info("D2R is not running and auto_login=0 — please launch D2R manually, then press the resume key to start.")
else:
Logger.info("D2R is not running and auto_login=0 — please launch D2R manually, then press the resume key to start.")
Logger.info("D2R is already running")
else:
Logger.info("D2R is already running")
Logger.info("Running in Docker — D2R process check skipped (bridge server handles host interaction)")
print(f"============ Botty {__version__} [name: {Config().general['name']}] ============")
_profiles = Config.list_profiles()
@@ -201,6 +244,20 @@ def main():
keyboard.add_hotkey(Config().advanced_options['resume_key'], lambda: start_or_pause_bot(controllers))
keyboard.add_hotkey(Config().advanced_options["exit_key"], lambda: on_exit(controllers))
# Hermes Agent control socket — TCP server on localhost:18899
# Accepts commands: start, pause, stop, status
try:
_hermes_socket = __import__('socket').socket(__import__('socket').AF_INET, __import__('socket').SOCK_STREAM)
_hermes_socket.setsockopt(__import__('socket').SOL_SOCKET, __import__('socket').SO_REUSEADDR, 1)
_hermes_socket.settimeout(1.0)
_hermes_socket.bind(('127.0.0.1', 18899))
_hermes_socket.listen(5)
_hermes_socket.setblocking(False)
Logger.info("Hermes control socket listening on 127.0.0.1:18899")
except Exception as _e:
Logger.debug(f"Hermes control socket failed: {_e}")
_hermes_socket = None
def _cycle_profile():
profiles = Config.list_profiles()
if not profiles:
@@ -260,7 +317,64 @@ def main():
with open(profile_ini, "w", encoding="utf-8") as f:
f.write(content)
keyboard.add_hotkey(Config().advanced_options['cycle_pickit_profile_key'], _cycle_pickit_profile)
keyboard.wait()
# In Docker, auto-start the bot instead of waiting for hotkey
if os.name != "nt":
Logger.info("Docker mode — auto-starting bot")
screen.start_detecting_window()
controllers.game.start()
# Wait for SIGTERM/SIGINT to shut down
keyboard.wait()
else:
# Poll loop: checks hermes control socket + waits for keyboard events
import threading
_shutdown_event = threading.Event()
def _hermes_poll():
"""Poll hermes control socket for commands."""
import select
while not _shutdown_event.is_set():
try:
if _hermes_socket is None:
time.sleep(0.5)
continue
rlist, _, _ = select.select([_hermes_socket], [], [], 0.5)
if rlist:
try:
conn, _ = _hermes_socket.accept()
# select() only says a connection is PENDING — the
# client's bytes may not have arrived yet. The accepted
# socket inherits the listener's non-blocking mode, so
# recv() raised BlockingIOError (WinError 10035), the
# error was swallowed, and the caller saw a timeout.
# That is the root of every "no response from bot
# (socket timeout)" and of the retry loops built around
# it — one of which paused the bot for 4h50m.
conn.setblocking(True)
conn.settimeout(2.0)
data = conn.recv(1024).decode().strip().lower()
_reply = handle_hermes_command(data, controllers)
if _reply is not None:
conn.sendall(_reply.encode())
conn.close()
except Exception as e:
# Never swallow this silently. A raising handler leaves
# the connection in CLOSE_WAIT and every control command
# times out, which reads exactly like "the bot is wedged"
# and cost a long debugging detour on 2026-08-28.
Logger.error(f"hermes command {data!r} failed: {type(e).__name__}: {e}")
try:
conn.close()
except Exception:
pass
except Exception as e:
Logger.debug(f"hermes poll loop error: {type(e).__name__}: {e}")
time.sleep(0.5)
if _hermes_socket is not None:
threading.Thread(target=_hermes_poll, daemon=True).start()
keyboard.wait()
if __name__ == "__main__":
@@ -275,5 +389,9 @@ if __name__ == "__main__":
try:
if __import__('sys').stdin and __import__('sys').stdin.isatty():
input()
except (OSError, ValueError):
except (OSError, ValueError, EOFError):
# EOFError is the one input() actually raises when stdin is closed or not
# interactive — isatty() can still report True in some detached/redirected
# launches. Leaving it out turned every shutdown into an "Uncaught exception"
# traceback in the log, which reads like a crash rather than a clean exit.
pass
+44 -2
View File
@@ -5,11 +5,51 @@ import cv2
from utils.log_rotation import safe_imwrite
import datetime
import traceback
import discord
from version import __version__
import numpy as np
from discord import SyncWebhook, Color
import json
# Discord is an OPTIONAL notification dependency, but importing it pulls in
# aiohttp, which builds a default SSL context at import time. On a machine whose
# OpenSSL is newer than this Python build expects, that raises
# ssl.SSLError: [ASN1: NOT_ENOUGH_DATA]
# while loading the Windows certificate store. Importing it eagerly therefore
# took the ENTIRE bot down at startup — ui_manager -> messages -> discord is on
# the import path of every run. Degrade to "no Discord notifications" instead.
try:
import discord
from discord import SyncWebhook, Color
DISCORD_AVAILABLE = True
DISCORD_IMPORT_ERROR = None
except Exception as _exc: # ImportError, ssl.SSLError, ...
import types as _types
# Inert stand-ins so the send_* methods can still build their payloads
# without special-casing every call site. _get_webhook returns None when
# Discord is unavailable and _send_embed already early-returns on that, so
# nothing is ever transmitted.
class _NullEmbed:
def __init__(self, *a, **k):
pass
def _noop(self, *a, **k):
return self
add_field = set_image = set_thumbnail = set_footer = set_author = _noop
class _NullColor:
def __getattr__(self, _name):
return lambda *a, **k: None
discord = _types.SimpleNamespace(Embed=_NullEmbed, File=lambda *a, **k: None)
SyncWebhook = None
Color = _NullColor()
DISCORD_AVAILABLE = False
DISCORD_IMPORT_ERROR = _exc
Logger.warning(
f"Discord notifications disabled — could not import discord ({type(_exc).__name__}: {_exc}). "
"The bot runs normally; only Discord messaging is off."
)
class DiscordEmbeds(GenericApi):
_logged_webhook_errors = set()
@@ -27,6 +67,8 @@ class DiscordEmbeds(GenericApi):
Logger.warning(f"Discord webhook disabled for URL '{hook_url}': {reason}")
def _get_webhook(self, hook_url: str):
if not DISCORD_AVAILABLE:
return None
hook = None
if not hook_url:
hook_url = Config().general['custom_message_hook']
+17 -1
View File
@@ -303,6 +303,12 @@ def open_npc_menu(npc_key: Npc) -> bool:
wait(0.3, 0.4)
return True
return False
# Also close the waypoint panel — an open WP covers the center of the screen
# and prevents NPC template matching.
if is_visible(ScreenObjects.WaypointLabel, grab()):
Logger.debug("open_npc_menu: closing waypoint panel before NPC search")
keyboard.send("esc")
wait(0.2, 0.3)
_close_open_panels()
# Search for npc name tags by hovering to all template locations that are found
start = time.time()
@@ -430,7 +436,17 @@ def open_npc_menu(npc_key: Npc) -> bool:
from screen import convert_screen_to_monitor
Logger.debug(f"NPC {npc_key} - body-template hunt timed out, starting grid hover sweep")
_close_open_panels()
SWEEP_TAG_THRESHOLD = 0.4
# Raised 0.4 -> 0.7 (2026-08-27). A rendered name tag matches almost perfectly, so
# anything mediocre is noise, and at 0.4 the noise won. Measured across a full day of
# sweeps — real hits that opened the dialogue vs false hits that clicked empty ground:
# akara 0.980 halbu 0.995 malah 0.990 larzuk 0.996 <- real
# qual_kehk 0.424 malah 0.501 larzuk 0.494 <- false
# qual_kehk failed 100% of the time (5 timeouts / 5 attempts) because every sweep
# stopped on a 0.424 match, clicked nothing, and gave up instead of carrying on to the
# position where the tag actually renders. Verified directly: the stored
# QUAL_NAME_TAG_WHITE template scores 1.000 on a frame where the tag IS shown, and
# 0.997 through the color_filter path — the template was never the problem.
SWEEP_TAG_THRESHOLD = 0.7
# Pass 1: the NPC's known ROI. Pass 2: the whole NPC search area — patch
# updates and pathing drift can put the NPC outside the stored ROI.
sweep_rois = [npcs[npc_key]["roi"]] if "roi" in npcs[npc_key] else []
+92 -6
View File
@@ -435,6 +435,14 @@ class Pather:
(Location.A5_LARZUK, Location.A5_WP): [14, 13, 5],
(Location.A5_LARZUK, Location.A5_STASH): [14, 13, 5],
(Location.A5_LARZUK, Location.A5_MALAH): [14, 13, 5, 4, 3, 1, 2],
# A5_NIHLATHAK_PORTAL as source — char lands here after Pindle/Nihlathak TP-back.
# Nodes 9, 8 use A5_TOWN_5/A5_RED_PORTAL (visible at portal); node 6 bridges to town center.
(Location.A5_NIHLATHAK_PORTAL, Location.A5_TOWN_START): [9, 8, 6, 5, 4, 3],
(Location.A5_NIHLATHAK_PORTAL, Location.A5_STASH): [9, 8, 6],
(Location.A5_NIHLATHAK_PORTAL, Location.A5_WP): [9, 8, 6],
(Location.A5_NIHLATHAK_PORTAL, Location.A5_QUAL_KEHK): [9, 8, 6, 10, 11, 12],
(Location.A5_NIHLATHAK_PORTAL, Location.A5_MALAH): [9, 8, 6, 10, 11, 12, 15, 16],
(Location.A5_NIHLATHAK_PORTAL, Location.A5_LARZUK): [9, 8, 6, 5, 13, 14],
# Pindle
(Location.A5_PINDLE_START, Location.A5_PINDLE_SAFE_DIST): [100, 101, 102, 103],
(Location.A5_PINDLE_SAFE_DIST, Location.A5_PINDLE_END): [104],
@@ -602,7 +610,23 @@ class Pather:
return True
def find_abs_node_pos(self, node_idx: int, img: np.ndarray, threshold: float = 0.68) -> tuple[float, float]:
# Low-confidence fallback for find_abs_node_pos. The old 0.55 first-match search
# fabricated node positions: A5_TOWN_1 scored 0.60-0.62 on unrelated scenery at
# (532,110) / (857,522) / (1209,86) across three consecutive failure frames, and the
# pather steered the char into the Harrogath wall on each. Raised, and forced to
# best_match so an ambiguous frame can no longer be decided by node dict ordering.
_FALLBACK_THRESHOLD = 0.62
# Consecutive heading-gate rejections before a traverse gives up. Four was the
# observed count before the pather started guessing and walked into the wall.
_MAX_HEADING_REJECTS = 3
def find_abs_node_pos(
self,
node_idx: int,
img: np.ndarray,
threshold: float = 0.68,
last_direction: tuple[float, float] = None,
) -> tuple[float, float]:
node = self._nodes[node_idx]
# Try with cropped ROI first (cuts skill bar for better matching)
template_match = template_finder.search(
@@ -613,13 +637,14 @@ class Pather:
roi=Config().ui_roi["cut_skill_bar"],
use_grayscale=True
)
confident = template_match.valid
# If cropped search fails, try full image with lower threshold to handle window offset drift
if not template_match.valid:
if not confident:
template_match = template_finder.search(
[*node],
img,
best_match=False,
threshold=0.55,
best_match=True,
threshold=self._FALLBACK_THRESHOLD,
use_grayscale=True
)
if template_match.valid:
@@ -629,9 +654,32 @@ class Pather:
node_pos_rel = self._get_node(node_idx, template_match.name)
node_pos_abs = self._convert_rel_to_abs(node_pos_rel, ref_pos_abs)
node_pos_abs = get_closest_non_hud_pixel(pos = node_pos_abs, pos_type="abs")
# A fallback match is by definition uncertain. While approaching a single node
# the heading should stay roughly stable, so a low-confidence match that wants
# us to walk backwards is a false positive, not a course correction — drop it
# and let the caller's recovery sweep run instead of walking into scenery.
if not confident and not self._heading_is_plausible(node_pos_abs, last_direction):
Logger.debug(
f"Pather: rejecting low-confidence {template_match.name} "
f"({template_match.score*100:.1f}%) for node {node_idx} — implies reversal"
)
self._heading_rejects = getattr(self, "_heading_rejects", 0) + 1
return None
self._heading_rejects = 0
return node_pos_abs
return None
@staticmethod
def _heading_is_plausible(node_pos_abs: tuple[float, float], last_direction: tuple[float, float]) -> bool:
"""True unless node_pos_abs points more than ~90 deg away from last_direction."""
if last_direction is None:
return True
mag = math.dist(node_pos_abs, (0, 0)) * math.dist(last_direction, (0, 0))
if mag == 0:
return True
dot = node_pos_abs[0] * last_direction[0] + node_pos_abs[1] * last_direction[1]
return (dot / mag) > 0
def traverse_nodes(
self,
path: tuple[Location, Location] | list[int],
@@ -650,6 +698,9 @@ class Pather:
:param force_move: Bool value if force move should be used for pathing
:return: Bool if traversed successful or False if it got stuck
"""
# Per-traverse counter. A stale count from a previous traverse would abort
# the next one immediately.
self._heading_rejects = 0
if len(path) == 0:
Logger.error("Path must be a list of integers or a tuple with start and end location!")
return False
@@ -692,6 +743,9 @@ class Pather:
did_force_move = False
teleport_count = 0
recovery_idx = 0
# Heading held only while approaching THIS node. Across nodes a >90 deg turn
# is normal, so the reversal check must not carry over from the previous one.
node_last_dir = None
while not continue_to_next_node:
img = grab(force_new=True)
# Handle timeout
@@ -724,6 +778,39 @@ class Pather:
Logger.error(f"Got stuck exit pather (node {node_idx}, recovery sweep exhausted)")
return False
# Repeated heading-gate rejections mean the only thing visible is a
# match we have decided not to trust. This MUST come before the
# anti-stuck force-move below: that path moves along last_direction,
# and when the character is already wedged against the Harrogath wall
# it shoves it further in. Measured 2026-08-28: four rejections of
# A5_TOWN_1 at 65-68%, then "random guess towards (-423, 247)", then
# the red portal was unreachable for the rest of the run. Declaring a
# position untrustworthy and then moving on an arbitrary vector are
# contradictory — abort and let the caller re-anchor.
if getattr(self, "_heading_rejects", 0) >= self._MAX_HEADING_REJECTS:
Logger.warning(
f"Pather: {self._heading_rejects} consecutive low-confidence rejections "
f"for node {node_idx} — aborting traverse instead of guessing"
)
self._heading_rejects = 0
return False
# SCAN FIRST, then decide. This must sit ahead of the anti-stuck
# block below, not after it: with two rejections already banked,
# that block force-moves along last_direction the moment 3.1s
# elapses — driving a wall-wedged character further in — before
# find_abs_node_pos has recorded the third rejection. Putting the
# scan after it left the exact guess this guard exists to prevent
# reachable on the threshold iteration.
node_pos_abs = self.find_abs_node_pos(node_idx, img, threshold=threshold, last_direction=node_last_dir)
if getattr(self, "_heading_rejects", 0) >= self._MAX_HEADING_REJECTS:
Logger.warning(
f"Pather: {self._heading_rejects} consecutive low-confidence rejections "
f"for node {node_idx} — aborting traverse instead of guessing"
)
self._heading_rejects = 0
return False
# Sometimes we get stuck at rocks and stuff, after a few seconds force a move into the last known direction
if not did_force_move and time.time() - last_move > 3.1:
if last_direction is not None:
@@ -751,8 +838,6 @@ class Pather:
break
teleport_count += 1
# Find any template and calc node position from it
node_pos_abs = self.find_abs_node_pos(node_idx, img, threshold=threshold)
if node_pos_abs is not None:
dist = math.dist(node_pos_abs, (0, 0))
if dist < Config().ui_pos["reached_node_dist"]:
@@ -762,6 +847,7 @@ class Pather:
x_m, y_m = convert_abs_to_monitor(node_pos_abs)
char.move((x_m, y_m), force_tp=force_tp, force_move=force_move)
last_direction = node_pos_abs
node_last_dir = node_pos_abs
last_move = time.time()
return True
+1
View File
@@ -10,3 +10,4 @@ from .baal import Baal
from .mephisto import Mephisto
from .andariel import Andariel
from .countess import Countess
from .cold_plains import ColdPlains
+231
View File
@@ -0,0 +1,231 @@
"""
Cold Plains clear leave town by waypoint and kill everything with one
configured attack skill.
Unlike the boss runs this has no fixed path and no boss. It roams the area,
casts a single configured skill at whatever `target_detect` reports, loots, and
returns. Two consequences worth knowing:
* It works with ANY build, because it sends its own hotkey rather than going
through the build's `_skill_hotkeys`. A blizz_sorc profile can run this with
Fire Bolt long before Blizzard (clvl 24) exists.
* It works from clvl 1, which the boss runs do not those assume endgame
damage and, mostly, teleport.
PREREQUISITE: the destination waypoint must already be discovered on the
character. A fresh character has none, so walk to Cold Plains once by hand
first; after that this run is self-sufficient.
"""
from collections import OrderedDict
import math
import random
import time
from char import IChar
from config import Config
from input_layer import keyboard, mouse
from inventory import belt as belt_mod
from item.pickit import PickIt
from logger import Logger
from pather import Pather, Location
from screen import convert_abs_to_monitor
from target_detect import get_visible_targets
from town.town_manager import TownManager
from ui import meters, waypoint
from utils.misc import wait
class ColdPlains:
"""Roam an outdoor area and kill with one configured skill."""
name = "run_cold_plains"
def __init__(
self,
pather: Pather,
town_manager: TownManager,
char: IChar,
pickit: PickIt,
runs: OrderedDict
):
self._pather = pather
self._town_manager = town_manager
self._char = char
self._pickit = pickit
self._runs = runs
self.approach_fail_step: str | None = None
cfg = Config().cold_plains
self._area = str(cfg.get("area", "Cold Plains")).strip() or "Cold Plains"
self._attack_hotkey = str(cfg.get("attack_hotkey", "")).strip()
self._attack_button = str(cfg.get("attack_button", "right")).strip().lower()
self._max_steps = int(cfg.get("max_steps", 12))
self._casts_per_target = int(cfg.get("casts_per_target", 4))
self._target_radius = int(cfg.get("target_radius", 600))
self._cast_delay = float(cfg.get("cast_delay", 0.25))
self._max_runtime_s = float(cfg.get("max_runtime_s", 180))
# Engagements per step, so a target we cannot actually kill (immune,
# out of reach, a misdetection) cannot pin the run in place forever.
self._max_engagements = int(cfg.get("max_engagements", 8))
# Self-sustain for levelling chars: drink belt potions during the run
# instead of waiting for town (a lvl 1 sorc dies before it gets there).
self._chicken = float(Config().char.get("chicken", 0.5))
self._take_mana = float(Config().char.get("take_mana_potion", 0.3))
self._last_mana_check = 0.0
# True once we've confirmed the character has no teleport at all —
# then the end-of-run trip home is a walk, not a TP.
self._no_tp = False
def approach(self, start_loc: Location, do_pre_buff: bool) -> bool | Location:
self.approach_fail_step = None
# Fail loudly here rather than roaming harmlessly for 3 minutes doing
# no damage — with no hotkey there is nothing to attack with.
if not self._attack_hotkey:
self.approach_fail_step = "no_attack_hotkey"
Logger.error(
"Cold Plains approach: [cold_plains] attack_hotkey is empty — "
"set it to the key holding your attack skill"
)
return False
loc = self._town_manager.go_to_act(1, start_loc)
if not loc:
self.approach_fail_step = "go_to_act1"
Logger.error("Cold Plains approach: go_to_act(1) failed")
return False
if do_pre_buff:
self._char.pre_buff()
# A levelling char has no teleport until clvl 18 — the end-of-run trip
# home is a walk, not a TP. Cache it so battle() can skip the TP wait.
self._no_tp = (not Config().char["teleport"]
and not self._char.capabilities.can_teleport_natively
and not self._char.capabilities.can_teleport_with_charges)
if not self._town_manager.open_wp(loc):
self.approach_fail_step = "open_wp"
Logger.error("Cold Plains approach: open_wp failed")
return False
wait(0.4, 0.6)
if not waypoint.use_wp(label=self._area):
self.approach_fail_step = f"use_wp_{self._area.lower().replace(' ', '_')}"
Logger.error(f"Cold Plains approach: use_wp({self._area!r}) failed")
return False
# No pather node for the open areas — the bot TPs home at end of run,
# so report the town it will arrive back in.
return Location.A1_TOWN_START
def return_to_town(self) -> Location:
"""Walk back to town (no-TP levelling chars) or no-op (TP chars).
The bot's on_end_run() normally TPs home. A pre-clvl-18 character has
no teleport, so this walks the pather's A1 outdoor->town nodes
(705 -> 702) in reverse, then verifies with a town-marker scan. The
pather's auto-recovery sweep handles mis-positioning — if a node
template can't be found, it walks an expanding pattern until it
re-acquires. If the pather fails entirely, fall back to the last
known town location: the next maintenance's open_wp() re-detects the
physical act and traverses from wherever the character actually is.
"""
if not self._no_tp:
return Location.A1_TOWN_START
Logger.info(f" {self._area}: no teleport — walking back to town via pather")
# Walk the A1 outdoor->town path in reverse: 705 (south WP) -> 702
# (north WP). The pather's auto-recovery sweep handles mis-positioning.
if self._pather.traverse_nodes([705, 702], self._char, timeout=5.0, force_move=True):
Logger.info(f" {self._area}: pather walk-back complete")
else:
Logger.warning(f" {self._area}: pather walk-back failed — continuing anyway")
# Verify we're actually in town before reporting success.
if self._town_manager.detect_current_act(timeout=3.0):
return Location.A1_TOWN_START
Logger.warning(f" {self._area}: no town marker after walk-back — continuing anyway")
return Location.A1_TOWN_START
def battle(self) -> bool | tuple[Location, bool]:
picked_up_items = False
deadline = time.time() + self._max_runtime_s
Logger.info(f"=== {self._area} clear: {self._max_steps} steps, key={self._attack_hotkey} ===")
for step in range(self._max_steps):
if time.time() > deadline:
Logger.info(f" {self._area}: runtime budget reached, heading back")
break
# Self-sustain: drink belt potions before fighting. A lvl 1 sorc
# dies before it can walk back to town, so the belt is the only
# heal source mid-run. Mana is checked less often (cast_delay
# already paces the firebolt spam).
self._drink_if_needed()
targets = get_visible_targets(radius_max=self._target_radius)
if targets:
Logger.info(f" Step {step + 1}/{self._max_steps}: {len(targets)} target(s)")
self._clear_visible(deadline)
picked_up_items |= self._pickit.pick_up_items(self._char)
else:
Logger.debug(f" Step {step + 1}/{self._max_steps}: nothing visible, roaming")
self._roam()
return (Location.A1_TOWN_START, picked_up_items)
def _drink_if_needed(self) -> None:
"""Drink belt potions when HP/mana drop below the configured thresholds."""
from screen import grab
img = grab()
hp = meters.get_health(img)
if hp <= self._chicken:
Logger.info(f" {self._area}: HP {hp * 100:.0f}% <= chicken {self._chicken * 100:.0f}% — drinking health")
if not belt_mod.drink_potion("health", stats=[hp, meters.get_mana(img)]):
Logger.warning(f" {self._area}: no health potion in belt at HP {hp * 100:.0f}%")
wait(1.5, 2.0) # let the potion tick
return
now = time.time()
if now - self._last_mana_check > 3.0:
self._last_mana_check = now
mp = meters.get_mana(img)
if mp <= self._take_mana:
Logger.info(f" {self._area}: mana {mp * 100:.0f}% <= {self._take_mana * 100:.0f}% — drinking mana")
if not belt_mod.drink_potion("mana", stats=[hp, mp]):
Logger.warning(f" {self._area}: no mana potion in belt at mana {mp * 100:.0f}%")
wait(1.0, 1.5)
def _clear_visible(self, deadline: float) -> None:
"""Cast at the nearest target until the area is clear or patience runs out."""
for _ in range(self._max_engagements):
if time.time() > deadline:
return
# A potion drink can leave the char at low HP mid-fight — re-check
# before every engagement so we don't walk into a pack on 20%.
self._drink_if_needed()
targets = get_visible_targets(radius_max=self._target_radius)
if not targets:
return
self._cast_at(targets[0].center_monitor)
def _cast_at(self, pos_monitor: tuple[float, float], spray: int = 8) -> None:
"""Select the configured skill, then cast it at a position a few times."""
keyboard.send(self._attack_hotkey)
wait(0.06, 0.12)
for _ in range(self._casts_per_target):
x = pos_monitor[0] + random.randint(-spray, spray)
y = pos_monitor[1] + random.randint(-spray, spray)
mouse.move(x, y, randomize=5, delay_factor=[0.2, 0.4])
mouse.click(button=self._attack_button)
wait(self._cast_delay, self._cast_delay * 1.4)
def _roam(self) -> None:
"""Step in a random direction to find new mobs.
y is halved because D2's projection is isometric — an equal x/y step
moves visibly further vertically than it does horizontally.
"""
angle = random.uniform(0, 2 * math.pi)
dist = random.randint(150, 260)
pos_abs = (math.cos(angle) * dist, math.sin(angle) * dist * 0.5)
self._char.walk(convert_abs_to_monitor(pos_abs), force_move=True)
wait(0.9, 1.4)
+47 -23
View File
@@ -147,11 +147,17 @@ class Diablo:
Logger.info(f"_verify_in_rof: confirmed in River of Flame ({rof_check.name} @ {rof_check.score*100:.1f}%)")
return True
# All 8 pentagram templates — templates 0-2/TP are stale vs current D2R rendering;
# template 4 consistently scores highest (0.530.66). Use all for maximum coverage.
_PENT_TEMPLATES = ["DIA_NEW_PENT_TP", "DIA_NEW_PENT_0", "DIA_NEW_PENT_1", "DIA_NEW_PENT_2",
"DIA_NEW_PENT_3", "DIA_NEW_PENT_4", "DIA_NEW_PENT_5", "DIA_NEW_PENT_6"]
_PENT_THRESHOLD = 0.50
def _verify_at_pentagram(self) -> bool:
"""Check that we are at the Pentagram area after teleporting."""
pent_check = template_finder.search_and_wait_stable(
["DIA_NEW_PENT_TP", "DIA_NEW_PENT_0", "DIA_NEW_PENT_1", "DIA_NEW_PENT_2"],
threshold=0.8, timeout=3.0, confirmations=2, suppress_debug=True
self._PENT_TEMPLATES,
threshold=self._PENT_THRESHOLD, timeout=3.0, confirmations=2, suppress_debug=True
)
if not pent_check.valid:
Logger.warning("_verify_at_pentagram: no Pentagram marker found")
@@ -272,11 +278,23 @@ class Diablo:
# LOOP TO PENTAGRAM
def _loop_pentagram(self, path) -> bool:
found = False
templates = ["DIA_NEW_PENT_TP", "DIA_NEW_PENT_0", "DIA_NEW_PENT_1", "DIA_NEW_PENT_2"]
start_time = time.time()
while not found and time.time() - start_time < 15:
found = template_finder.search_and_wait(templates, threshold=0.83, timeout=0.1, suppress_debug=True).valid
found = template_finder.search_and_wait(self._PENT_TEMPLATES, threshold=self._PENT_THRESHOLD, timeout=0.1, suppress_debug=True).valid
if not found: self._pather.traverse_nodes_fixed(path, self._char)
if not found:
# The blind fixed-path loop teleports along a static route while watching
# for the Pentagram; it fails whenever the entrance-hall clear ends in a
# variable spot or trash blocks the teleport (the #1 run_diablo abort —
# "battle_failed", char stranded among CS trash, Pentagram never on screen).
# Fall back to active node-based navigation: 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.
Logger.warning("_loop_pentagram: fixed-path loop failed — falling back to node 602 navigation")
if self._pather.traverse_nodes([602], self._char, threshold=0.80):
found = template_finder.search_and_wait(
self._PENT_TEMPLATES, threshold=self._PENT_THRESHOLD, timeout=0.5, suppress_debug=True
).valid
if not found:
if Config().general["info_screenshots"]: safe_imwrite(f"./log/screenshots/info/info_failed_loop_pentagram_" + path + "_" + time.strftime("%Y%m%d_%H%M%S") + ".png", grab())
return False
@@ -347,10 +365,9 @@ class Diablo:
self._pather.traverse_nodes_fixed("diablo_wp_pentagram_2", self._char)
Logger.debug("ROF: Teleporting directly to PENTAGRAM")
found = False
templates = ["DIA_NEW_PENT_0", "DIA_NEW_PENT_1", "DIA_NEW_PENT_2"]
start_time = time.time()
while not found and time.time() - start_time < 10:
found = template_finder.search_and_wait(templates, threshold=0.8, timeout=0.1, suppress_debug=True).valid
found = template_finder.search_and_wait(self._PENT_TEMPLATES, threshold=self._PENT_THRESHOLD, timeout=0.1, suppress_debug=True).valid
if not found:
self._pather.traverse_nodes_fixed("diablo_wp_pentagram_loop", self._char)
if not found:
@@ -392,15 +409,6 @@ class Diablo:
if not self._entrance_hall(): return False
Logger.debug("CS Trash: looping to PENTAGRAM")
if not self._loop_pentagram("diablo_wp_pentagram_loop"): return False
found = False
templates = ["DIA_NEW_PENT_TP", "DIA_NEW_PENT_0", "DIA_NEW_PENT_1", "DIA_NEW_PENT_2"]
start_time = time.time()
while not found and time.time() - start_time < 15:
found = template_finder.search_and_wait(templates, threshold=0.83, timeout=0.1, suppress_debug=True).valid
if not found: self._pather.traverse_nodes_fixed("diablo_wp_pentagram_loop", self._char)
if not found:
if Config().general["info_screenshots"]: safe_imwrite(f"./log/screenshots/info/info_failed_loop_pentagram_diablo_wp_pentagram_loop_" + time.strftime("%Y%m%d_%H%M%S") + ".png", grab())
return False
# Final verification: confirm we're at the Pentagram
if not self._verify_at_pentagram():
Logger.warning("_river_of_flames_trash: Pentagram verification failed after loop")
@@ -497,7 +505,11 @@ class Diablo:
params_seal1 = seal_layout1, [614], [615], [611], "dia_a1l_home", "dia_a1l_home_loop", [602], ["DIA_A1L2_14_OPEN"], ["DIA_A1L2_14_CLOSED", "DIA_A1L2_14_CLOSED_DARK", "DIA_A1L2_14_MOUSEOVER"], ["DIA_A1L2_5_OPEN"], ["DIA_A1L2_5_CLOSED","DIA_A1L2_5_MOUSEOVER"]
params_seal2 = seal_layout2, [625], [626], [622], "dia_a2y_home", "dia_a2y_home_loop", [602], ["DIA_A2Y4_29_OPEN"], ["DIA_A2Y4_29_CLOSED", "DIA_A2Y4_29_MOUSEOVER"], ["DIA_A2Y4_36_OPEN"], ["DIA_A2Y4_36_CLOSED", "DIA_A2Y4_36B_CLOSED", "DIA_A2Y4_36_MOUSEOVER"]
threshold_primary=0.8
threshold_confirmation=0.85
# Normalized from 0.85 to 0.80 to match every other seal threshold.
# Live scores show clean separation (true layout 84-88%, other 42-57%),
# so 0.80 carries no false-positive risk and better catches a genuine
# A1-L that reads ~80-85% from a slightly-off position. (log 2026-06-24)
threshold_confirmation=0.80
threshold_confirmation2=0.8
confirmation_node2=None
case "B":
@@ -523,11 +535,17 @@ class Diablo:
return False
# Layout decision: 1 = seal_layout1, 2 = seal_layout2, 0 = undetermined.
# A blind static path can drift off course (one missed teleport click derails
# the rest), so an ambiguous/lost check gets ONE retry: loop back to the
# pentagram, re-run the approach, check again.
# The check is reliable WHEN well-positioned: live scores show the true layout
# at 84-88% and the other at 42-57% (clear separation). The failure mode is
# positioning variance — from a bad camera angle BOTH templates read low
# (~55-66%) and the check is ambiguous. So an ambiguous/lost check loops back
# to the pentagram and re-approaches for a fresh, hopefully better-positioned
# read. 3 attempts: a single bad-position retry was the top run_diablo abort
# after the Pentagram fix (log 2026-06-24); the extra approach is cheap vs
# losing the whole run. Do NOT "fix" ambiguity by lowering thresholds — that
# risks picking the WRONG seal from a bad-position read.
decision = 0
max_attempts = 2
max_attempts = 3
for attempt in range(max_attempts):
if attempt > 0:
Logger.warning(f"{sealname}: Layout_check retry {attempt + 1}/{max_attempts} - looping back to pentagram to recover position")
@@ -541,14 +559,18 @@ class Diablo:
Logger.warning(f"{sealname}: Layout_check calibration node not found - position lost")
continue
#check1 using primary templates
if not template_finder.search_and_wait(templates_primary, threshold =threshold_primary, timeout=0.5).valid:
primary_m = template_finder.search_and_wait(templates_primary, threshold=threshold_primary, timeout=0.5)
Logger.info(f"{sealname}: LC primary({seal_layout2}) best={primary_m.name}@{primary_m.score*100:.1f}% (thr {threshold_primary*100:.0f}%)")
if not primary_m.valid:
Logger.debug(f"{seal_layout1}: Layout_check step 1/2 - templates NOT found for "f"{seal_layout2}")
#cross-check for confirmation
if not confirmation_node == None:
if not self._pather.traverse_nodes(confirmation_node, self._char, threshold=calibration_threshold,):
Logger.warning(f"{sealname}: Layout_check confirmation node not found - position lost")
continue
if template_finder.search_and_wait(templates_confirmation, threshold=threshold_confirmation, timeout=0.5).valid:
conf_m = template_finder.search_and_wait(templates_confirmation, threshold=threshold_confirmation, timeout=0.5)
Logger.info(f"{sealname}: LC confirm({seal_layout1}) best={conf_m.name}@{conf_m.score*100:.1f}% (thr {threshold_confirmation*100:.0f}%)")
if conf_m.valid:
Logger.info(f"{seal_layout1}: Layout_check step 2/2 - templates found for "f"{seal_layout1} - "+'\033[93m'+"all fine, proceeding with "f"{seal_layout1}"+'\033[0m')
decision = 1
break
@@ -559,7 +581,9 @@ class Diablo:
if not self._pather.traverse_nodes(confirmation_node2, self._char, threshold=calibration_threshold,):
Logger.warning(f"{sealname}: Layout_check confirmation node not found - position lost")
continue
if not template_finder.search_and_wait(templates_confirmation, threshold=threshold_confirmation2, timeout=0.5).valid:
conf2_m = template_finder.search_and_wait(templates_confirmation, threshold=threshold_confirmation2, timeout=0.5)
Logger.info(f"{sealname}: LC confirm2({seal_layout1}) best={conf2_m.name}@{conf2_m.score*100:.1f}% (thr {threshold_confirmation2*100:.0f}%)")
if not conf2_m.valid:
Logger.info(f"{seal_layout2}: Layout_check step 2/2 - templates NOT found for "f"{seal_layout1} - "+'\033[96m'+"all fine, proceeding with "f"{seal_layout2}"+'\033[0m')
decision = 2
break
+55 -3
View File
@@ -11,6 +11,7 @@ from collections import OrderedDict
class Pindle:
name = "run_pindle"
_PINDLE_AREA_TEMPLATES = [f"PINDLE_{idx}" for idx in range(8)]
def __init__(
self,
@@ -27,6 +28,23 @@ class Pindle:
self.runs = runs
self.approach_fail_step: str | None = None
def _verify_in_pindle_area(self, timeout: float = 2.0) -> bool:
match = template_finder.search_and_wait_stable(
self._PINDLE_AREA_TEMPLATES,
threshold=0.62,
timeout=max(timeout, 5.0),
confirmations=2,
suppress_debug=True,
)
if match.valid:
Logger.info(f"Pindle approach: confirmed temple entry ({match.name} @ {match.score*100:.1f}%)")
return True
Logger.debug(
"Pindle approach: temple marker not found after portal click"
+ (f" (best: {match.name} @ {match.score*100:.1f}%)" if match.name else "")
)
return False
def approach(self, start_loc: Location, do_pre_buff: bool) -> bool | Location:
self.approach_fail_step = None
# Go through Red Portal in A5
@@ -47,15 +65,49 @@ class Pindle:
found_loading_screen_func = lambda: loading.wait_for_loading_screen(2.0)
# Re-detect window before template search
find_and_set_window_position(force=True)
# NO pre-click "are we already there?" shortcut. Harrogath scenery around the
# portal scores 0.76-0.79 on PINDLE_7 — above its 0.62 bar — so that shortcut
# fired in town, returned A5_PINDLE_START without ever clicking the portal, and
# the bot "killed Pindle" in town: five straight runs reported success with zero
# loot and zero XP. Entry must be proven by the portal click's loading screen.
if not self._char.select_by_template("A5_RED_PORTAL", found_loading_screen_func, telekinesis=False):
Logger.warning("Pindle approach: first red portal click failed, retrying from town start")
if self._verify_in_pindle_area():
if do_pre_buff:
self._char.pre_buff()
return Location.A5_PINDLE_START
Logger.warning("Pindle approach: first red portal click failed, re-verifying position before retry")
find_and_set_window_position(force=True)
if not self._pather.traverse_nodes((Location.A5_TOWN_START, Location.A5_NIHLATHAK_PORTAL), self._char):
# Never re-run A5 node pathing from an assumed location. The failed portal
# click leaves the char somewhere unknown, and the old hardcoded
# A5_TOWN_START start walked it into the Harrogath wall — and onto the
# waypoint, whose panel the health manager then read as a chicken.
retry_loc = self._town_manager.detect_current_act()
if retry_loc is None:
# No town marker in view. That is NOT evidence we left the act — it happens
# routinely by the portal in Harrogath's NE corner, where none of the
# TOWN_MARKERS render. `loc` was confirmed to be act 5 at the top of this
# run and a failed portal click cannot move us between acts, so reuse it.
# Treating None as "wrong act" hard-failed 6 runs and burned the 5-strike
# circuit breaker, ending an otherwise healthy 31-game session.
Logger.debug("Pindle approach: no town marker in view — reusing the act-5 location confirmed at run start")
retry_loc = loc
elif retry_loc != Location.A5_TOWN_START:
Logger.warning(f"Pindle approach: not confirmed in A5 (detected {retry_loc}) — traveling to act 5")
retry_loc = self._town_manager.go_to_act(5, retry_loc)
if not retry_loc:
self.approach_fail_step = "retry_traverse_to_portal"
Logger.error("Pindle approach: could not confirm A5 town position for retry")
return False
if not self._pather.traverse_nodes((retry_loc, Location.A5_NIHLATHAK_PORTAL), self._char):
self.approach_fail_step = "retry_traverse_to_portal"
return False
wait(0.5, 0.7)
find_and_set_window_position(force=True)
if not self._char.select_by_template("A5_RED_PORTAL", found_loading_screen_func, telekinesis=False):
if self._verify_in_pindle_area():
if do_pre_buff:
self._char.pre_buff()
return Location.A5_PINDLE_START
self.approach_fail_step = "click_red_portal"
return False
# Pre-buff after entering the portal (CTA needs to be inside the instance)
@@ -65,7 +117,7 @@ class Pindle:
def battle(self) -> bool | tuple[Location, bool]:
# Kill Pindle
if not template_finder.search_and_wait(["PINDLE_0", "PINDLE_1"], threshold=0.65, timeout=20).valid:
if not self._verify_in_pindle_area(timeout=20.0):
return False
# move to pindle
# Charges-based teleport (e.g. blizz sorc with tele charges) must also use the fixed

Some files were not shown because too many files have changed in this diff Show More