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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
"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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
D2R UI hitboxes are tight; a 1px offset on the Join Game tab click was
enough to miss the button. Final SetCursorPos pass after the existing
verification check.
exception safety, on_end_run town shortcut, config enabled parse
- bot.py: GameRecovery(None) -> GameRecovery(DeathManager()) (None crashed
on death-screen handling); hide spot now clicks once (char actually walks
there); wait loop breaks on any non-InGame screen (death/kick) instead of
only MainMenu; whole handler wrapped in try/except with best-effort
recovery so the bot never stops; on_end_run() short-circuits for baal_xp
(char is already in town — skip TP logic, go straight to maintenance)
- config.py: enabled parsed as plain bool (was bool(int(str)) crash on
'true'); empty game_name_filter no longer crashes float()/int() overrides
- game_browser.py: drop unused imports (keyboard, Config, focus_d2r_window,
select_screen_object_match)
- src/ui/game_browser.py: new module for game browser interaction
(Play button → Join Game tab → OCR game list → click → loading)
- src/config.py: [baal_xp] section with enabled, game_name_filter,
max_wait_s, xp_threshold, min_hp_pct, hide_x/y, join_timeout_s
- config/params.ini: [baal_xp] section + route doc
- src/bot.py: baal_xp state, on_run_baal_xp handler (8-phase cycle:
leave own game → hero select → join public game → wait in-game →
corpse/nopickup/pre_buff → walk to hide spot → wait loop (XP/HP/timer)
→ leave → recover to own game), _recover_to_own_game helper
Enable by adding run_baal_xp to [routes] order in params.ini.
Launching D2R by hand kept failing in two different ways, both silent:
1. D2R.exe direct is fast but can come up with "Cannot Connect to Server" when
the client has no Battle.net session.
2. The launcher's Play button always yields an authenticated client, but a
hardcoded coordinate for it clicks the DESKTOP whenever the launcher has
moved, been minimised to tray, or is DPI-scaled — which opened unrelated
applications rather than reporting a failure.
So: try the exe, fall back to Play, and find Play by COLOUR rather than a fixed
point. It is the large saturated-blue block in the launcher; sampled live it is
HSV ~(104, 255, 122), and the value channel being that low is why a naive
"bright blue" threshold matches nothing.
show_launcher() also restores/maximises the window first, since the button
cannot be found while the launcher is hidden in the tray.
Never passes params.ini launch_options: those resolve to "-mod profile -txt",
and -mod puts D2R in offline mode where ladder does not exist.
client_size() imports utils.misc for its side effect of setting per-monitor DPI
awareness. Without it GetClientRect returns logical pixels, so a correct
1280x720 client reads as 1024x576 under 125% scaling and looks like a
resolution fault that is not there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- return_to_town() walks pather nodes [705, 702] (A1 outdoor->town) in
reverse, using the pather's auto-recovery sweep to handle mis-positioning
- _drink_if_needed() drinks belt HP/mana potions mid-run so a lvl 1 sorc
doesn't die before it can walk back to town
- bot.py: _current_run tracks the active run object; on_end_run() calls
run_obj.return_to_town() for no-TP chars instead of wasting time on
tp_town() retries
Movement was the else-branch of "did the scan find anything", and motion
detection almost always finds something, so roamed was 0 in every single run.
She never relocated — she churned on one spot re-detecting the same movement,
which is why XP crawled (140 xp in 6 minutes) and why she appeared to ignore
the rest of the map.
Make the rotation unconditional: move N steps (--move-clicks, default 3), then
scan, then fight until clear or --max-engagements, then repeat.
Also stop her running past targets. At range she now force-moves toward the
target instead of left-clicking: a left-click that lands on ground rather than
on the monster is a MOVE order, so any small offset in the motion-blob centre
turned an attack into a walk-past. Only inside --melee-dist does she click, and
then with stand-still held so a swing can never be reinterpreted as movement.
Adds --max-engagements, which the rotation referenced but was never defined as
an argument — that raised AttributeError mid-run and aborted the session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- on_init: cold-plains-only profiles skip town-marker detection and start
directly (char is already standing in A1 town), same as run_level-only
- on_end_run: chars with no teleport at all (pre-clvl-18) walk back to
town instead of failing on TP; _walk_back_to_town() walks south in
steps checking for town markers, then maintenance's open_wp()
re-detects the physical act and traverses from there
Enables the bloodmoor profile: lvl 1 sorc firebolt grinder that walks
out to Cold Plains, scans for enemies, kills, and walks back.
Windows refuses SetForegroundWindow from a process that does not already own
the foreground. The refusal is silent-ish and was swallowed, so the window never
came forward and every subsequent key and click went to whatever window WAS
focused. Measured on a live client: pressing the inventory key changed 0.89 mean
pixel value (i.e. nothing happened); after the fix, 16.44.
Attach our input queue to the current foreground thread for the duration of the
call — the documented way round the foreground lock — and return True only if
D2R actually ended up foreground, instead of returning True for "no exception
was raised".
Also harden tools/firebolt_roam.py with two preflights, because both failure
modes are silent — the character just stands there:
- refuse to start if D2R cannot be brought to the foreground
- refuse to start if the attack hotkey does not change the right-skill icon,
i.e. no skill is bound to it (--skip-skill-check overrides)
Verified live: with nothing bound to F1 the tool now exits with an actionable
message rather than roaming silently casting nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Old template was too large and included background elements that changed
between D2R versions, causing template matching to fail at all thresholds.
New crop isolates just the waypoint stone itself.
- Restore FIRST-RUN.md onboarding guide
- Pin environment.yml to requirements.txt (was loose 24 packages)
- Remove debug screenshots from git (debug_*.png -> .gitignore)
- Remove .hermes/ dev plans from git (already in .gitignore)
- Archive botty_next/ test harness to alexpolo1/my-botty-tools
- Archive docs/legacy-go/ to alexpolo1/my-botty-tools
- Move 14 dev tools from repo root to tools/ directory
- Extend .gitignore to prevent dev artifacts re-entering root
- Promote stable branch with all bug fixes from main
- Bump version 0.8.4 -> 0.9.1
Pindle (src/run/pindle.py):
- Pre-check if already in Pindle area before portal click
- Extended verify timeout to max(5s) for temple marker detection
A5 waypoint (src/town/a5.py):
- Two-tier WP scan: 0.45 threshold first, then 0.55
- Progressive stash thresholds (0.60 -> 0.50 -> 0.40) with direct click fallback
NPC detection (src/npc_manager.py):
- Close waypoint panel before NPC search (prevents WP UI blocking template match)
Diablo battle (src/char/paladin/hammerdin.py):
- Extended spawn wait from 15s to 20s
- Mid-fight target re-verification and repositioning
- Extra redemption burst to ensure kill
Auto-fixer (test/auto/auto_fixer.py):
- Updated to detect and verify all applied fixes
CI (.github/workflows/ci.yml):
- Added log analyzer tests to CI pipeline
- Excluded self-healing orchestrator (requires live D2R) and broken tests
New modules:
- test/auto/log_analyzer.py: parses bot logs and event JSONL files,
categorizes failures (approach, maintenance, battle, chicken, timeout,
crash, OCR) into structured BotFailure objects
- test/auto/auto_fixer.py: maps failure patterns to targeted code fixes,
checks if fixes are already applied, reports what needs work
- test/auto/test_self_healing.py: orchestrator that launches the bot,
monitors for failures in real-time, analyzes logs, applies fixes,
and retries up to 3 rounds
Supporting changes:
- test/auto/test_log_analyzer.py: 8 tests against historical run data
- pytest.ini: added repo root to pythonpath for test package imports
- test/__init__.py: new, enables test/ as importable package
All 95 existing tests pass. 8 new auto-test tests pass.
GetAsyncKeyState only detects keys when the bot process has focus.
When D2R is focused, F11/F12/End hotkeys were silently ignored.
Replaced polling loop with SetWindowsHookEx WH_KEYBOARD_LL which
intercepts all keystrokes globally before they reach any app.
Keeps polling as fallback if hook installation fails.
Also:
- Removed if-gate on enforce_d2r_window in game_controller.start()
- Added try/except pywintypes.error around all SetWindowPos calls
- Added pywintypes import to misc.py
- Added stop_hotkeys() cleanup in on_exit
enforce_d2r_window() now returns False when D2R is elevated, but the
if-gate caused all subsequent setup (window position, health manager,
death manager, game thread) to be skipped. Removed the if-gate so
startup continues regardless of window move result.