Extends the town timeline to the whole cycle and times every step:
grep "TL>" log/log.txt
TL> g2 r1 | game | spawn | ok | at a5_town_start
TL> g2 r1 | town | inspect_inventory | ok | took=3.0s | in pack=2 keep=0 sell=2
TL> g2 r1 | town | repair | ok | took=19.3s | at a5_larzuk
TL> g2 r1 | town | item_sell | ok | SOUL IMPALER @ (928, 465)
TL> g2 r1 | town | resurrect_merc | fail | took=113.6s | NPC not reachable
TL> g2 r1 | town | maintenance | ok | took=137.6s | at a5_larzuk
TL> g2 r1 | run | run_pindle | start | from a5_larzuk
TL> g2 r1 | run | approach | fail | took=71.9s | step=click_red_portal
TL> g2 r2 | game | end | fail | Approach failed [step: click_red_portal]
- phases: game (start / spawn / end), town (all maintenance steps), run (approach /
battle / loot), stlth.
- every terminating line carries took=Ns; "start" stamps the clock in Bot._tl_starts
keyed by (phase, step). The sample above pays for itself immediately: a failed game
spent 113.6s of its 137.6s town visit on a merc resurrect that failed.
- stealth decisions are tracked: afk_break (timed across the sleep), skip_run and
wrong_waypoint.
Bot.timeline() is a static entry point so utils/stealth.py and inventory/personal.py can
emit without importing Bot at module level (that would be circular). Both use a lazy
guarded import and it no-ops when no Bot is live. Item sells/stashes/drops now route
through it too, so they share the game/run counters and column widths instead of being a
separately formatted line.
Prefix moved TOWN> -> TL> now that it spans more than town.
Co-Authored-By: Claude Opus 5 <[email protected]>
Town maintenance was only traceable by piecing together scattered messages, and a step
that silently did nothing was indistinguishable from one that never ran. Every step now
emits the same stable, machine-readable line:
grep "TOWN>" log/log.txt
TOWN> g2 r2 | maintenance | start | at a5_town_start
TOWN> g2 r2 | town_heal | start
TOWN> g2 r2 | inspect_inventory | ok | in pack=0 keep=0 sell=0 gold_full=False
TOWN> g2 r2 | buy_consumables | start | needs id=0 tp=0 hp=4 mana=0 rejuv=0 | sell_pending=0
TOWN> g2 r2 | buy_consumables | ok | at a4_jamella | after: Consumables(...)
TOWN> g2 r2 | stash_items | skip | nothing kept and gold not full
TOWN> g2 r2 | repair | skip | no repair due and nothing to sell
TOWN> g2 r2 | resurrect_merc | start
TOWN> g2 r2 | gamble | skip | stash not full / gambling not configured
TOWN> g2 r2 | maintenance | ok | done in 21s | at a4_jamella
Covers shop, id, stash, repair, sell, resurrect and gamble. status is start|ok|skip|fail,
and skip states the reason. Steps carry useful detail: consumable needs before and after
buying, pack contents and keep/sell counts, repair trigger, items left in the pack after
stashing, and total maintenance duration.
Individual item transfers mirror into the same stream from transfer_items() as
item_sell / item_stash / item_drop, so vendoring a rare or stashing a rune appears inline
with the steps around it.
_step() also sets _maintenance_step, so the existing failure-reporting path is unchanged.
The ">" in the prefix is deliberate: a bare "TOWN" collides with template names such as
A5_TOWN_0.
Immediately useful — the first two games after this landed showed "done in 225s" with
buy_consumables failing against "done in 21s" with it succeeding.
Co-Authored-By: Claude Opus 5 <[email protected]>
SWEEP_TAG_THRESHOLD was 0.4. A rendered name tag matches almost perfectly, so anything
mediocre is noise — and at 0.4 the noise won: the sweep stopped at the first match over
threshold, clicked empty ground, and gave up. Scores measured across a full day:
akara 0.980 halbu 0.995 malah 0.990 larzuk 0.996 <- real, dialogue opened
qual_kehk 0.424 malah 0.501 larzuk 0.494 <- false, clicked nothing
Real hits cluster at 0.98-1.00, false ones at 0.42-0.50. Raised to 0.7, in the gap with
margin either side. This is why qual_kehk failed 100% (5 timeouts in 5 attempts) while
akara succeeded 177 times.
Also corrects Bug 29 in CLAUDE.md, which blamed the Qual-Kehk asset. That was wrong. I
walked the char to the NPC with the project's own Pather, hovered a grid capturing
full-res frames, found the one where QUAL-KEHK renders, and scored the stored template
against it: 1.000 raw and 0.997 through the color_filter path npc_manager actually uses.
The template was never the problem.
NAME_TAG_THRESHOLD (0.26, the hover path) is deliberately left alone — Akara genuinely
hovers at ~0.28 per Bug 3. The two thresholds serve different paths.
Co-Authored-By: Claude Opus 5 <[email protected]>
The final input() that holds a console build open is guarded by stdin.isatty() inside a
try, but the except clause was (OSError, ValueError). EOFError is exactly what input()
raises when stdin is closed or non-interactive, and isatty() can still report True in
detached or redirected launches. Every shutdown therefore ended with:
ERROR Uncaught exception:
Traceback (most recent call last):
File "src\main.py", line 345, in <module>
input()
EOFError: EOF when reading a line
which reads like a crash while the process was in fact exiting normally after a Force
Exit. Added EOFError to the caught tuple.
Co-Authored-By: Claude Opus 5 <[email protected]>
A protected item the pickit rejects could never leave the pack. sell and drop are both
blocked by _is_protected(), and the stash filter was "keep == True" only — so it stayed
in whatever slot it landed in, permanently. Measured: 673 blocked sells across just 7
charms in one session, and one vendor trip had 5 of 6 items blocked.
The stash filter now also takes protected items, but ONLY from the loot columns. A
charm's bonus applies from the inventory, and the pickit cannot distinguish a wanted res
charm from junk — LAPIS SMALL CHARM OF VITA (+20 life, cold res 7%) logs "Discarding"
purely because the rule wants coldresist >= 11. So position is the intent signal: charms
parked in the RESERVED columns are treated as deliberate keepers and left alone (the
click guard makes them untouchable anyway), while freshly looted ones in the loot columns
get stashed and free their slot. Nothing is sold or dropped.
Also reverts two config changes from earlier today that were wrong:
- protect_charms_from_sell back to 1. Setting it to 0 vendored LARGE CHARM OF FIRE,
STOUT SMALL CHARM, SMALL CHARM OF FLAME, STOUT SMALL CHARM OF STRENGTH and LAPIS
SMALL CHARM OF VITA before it was caught. Charms give resistances from the inventory;
the blocked-sell log lines are the guard working, not a bug to fix by selling.
- num_loot_columns back to 4. Raising it to 6 shrinks restricted_inventory_area, which
is where both tomes must live (common.tome_state only searches there) — the wrong
direction when the books need room.
protect_shields_from_sell stays 0: shields give no inventory bonus and the equipped one
is protected positionally.
Co-Authored-By: Claude Opus 5 <[email protected]>
protect_charms_from_sell=1 refused to vendor or drop anything with "charm" in its name,
regardless of the pickit verdict. With shields unblocked (4bbd43d) this became the sole
remaining clog: 673 blocked sells across just 7 charms in one session — LAPIS SMALL CHARM
OF VITA 233x, LARGE CHARM OF FIRE 226x, STOUT SMALL CHARM OF STRENGTH 126x — the same
items re-judged and re-blocked every game. One vendor trip had 5 of 6 items blocked, so
those slots were permanently occupied and the cube had nowhere to go.
Config-only change: the guard in inventory/personal.py::_is_protected() is UNCHANGED and
still reads this flag, so setting it back to 1 restores the old behaviour with no code
edit. Kept deliberately for later.
Risk accepted and noted in the config comment: unlike shields there is no positional
safety net for charms (the equipped-area click guard does not apply), so a pickit misread
will sell a good charm. Charms worth keeping belong in the stash, not the inventory.
Verified live after restart: the exact five charms that had been blocked all session sold
on the first vendor trip — LARGE CHARM OF FIRE, STOUT SMALL CHARM, SMALL CHARM OF FLAME,
STOUT SMALL CHARM OF STRENGTH, LAPIS SMALL CHARM OF VITA — with 0 blocked sells.
Co-Authored-By: Claude Opus 5 <[email protected]>
"Wanted to select A5_WP, but could not find it" fired 321 times in one 362-game session —
by volume the single largest error in the log — and it was self-inflicted, not a bad
template.
open_wp starts with two speculative direct scans, for the case where the char spawns next
to the stone after a Pindle TP-back. _try_click_wp searched for A5_WP and, when the search
found nothing, called select_by_template ANYWAY. That spins out its full 4s timeout and
logs the ERROR before the code has even tried walking to the waypoint. Measured:
15:26:41 Health Manager pausing
15:26:46 ERROR Wanted to select A5_WP <- 4.8s, char still at town start
15:26:51 ERROR Wanted to select A5_WP <- 4.6s
15:26:51 Traverse from a5_town_start to a5_wp
15:26:55 Select A5_WP (73.4% confidence) <- works fine once it walks there
~9.4s wasted per waypoint use. Raising the ID-scroll threshold (ef15ccf) pushed A4 vendor
trips to 158 in that session, each needing the waypoint both ways, which is why this
became the dominant log line.
Fix: _try_click_wp takes require_visible, and the two speculative pre-scans pass it — no
stone on screen means return False immediately instead of timing out. The post-traverse
calls keep the old behaviour, since the char should be standing on the waypoint by then
and deserves the full timeout.
Verified live: 8 waypoint uses, 0 "Wanted to select A5_WP" errors, waypoint step down to
~4s. 6 games, 0 failures since restart.
Co-Authored-By: Claude Opus 5 <[email protected]>
should_buy("id", min_remaining=3) means "buy when 3 or fewer remain", so the bot ran the
20-scroll Tome of Identify down to 2 before making a vendor trip. That threshold was set
when only a handful of item types were picked up; the pickit's rare catch-all now spends
a scroll on every rare, so the tome drains far faster.
Running dry is not cosmetic: an unidentified rare is never sold. inspect_items marks it
need_id, and the sell branch requires "not (box.keep or box.need_id)" — so it is carried
instead, occupying a slot until an ID is possible.
Raised to 8, matching the tp threshold already evaluated on the same trip, so the top-up
piggybacks on a vendor visit that was happening anyway. The A5->A4 trip this can trigger
is also materially safer now that detect_current_act refuses to guess the act (Bug 28),
which was the original reason for keeping the threshold low.
Verified live: the trip fired at id=13 (7 remaining) where the old threshold would have
waited for id=17, completed at Jamella, and the need went 13 -> 0.
Co-Authored-By: Claude Opus 5 <[email protected]>
protect_shields_from_sell=1 never vendored any item with "shield" in its detected name.
That is redundant safety: the EQUIPPED shield is already protected positionally by
mouse._is_clicking_safe(), which cancels any click landing in
ui_roi[equipped_inventory_area] while the inventory is open. The name rule only ever hit
shields sitting in the inventory grid, which the pickit had already judged.
Cost measured over one day: 130 blocked sells — FIEND SHIELD 64, AERIN SHIELD 41,
HERALDIC SHIELD 23, plus DRAGON/ANCIENT. The same shields were re-evaluated and
re-blocked every game ("Discarding FIEND SHIELD." immediately followed by "Blocked sell
for protected item: FIEND SHIELD"), so they could never leave the pack and permanently
occupied slots. The rare catch-all added in the pickit made this much worse, since rare
shields are now picked up.
Verified live after restart: "Confirmed sell 'FIEND SHIELD'" on the very item that had
been blocked 64 times, followed by HATCHET HANDS, LONG SWORD, LIGHT GAUNTLETS,
DEMONHIDE BOOTS and FLAIL — six sales in five minutes with zero blocks.
Charm protection (protect_charms_from_sell) is left ON: charms live in the inventory by
design and a misread charm cannot be un-sold.
Co-Authored-By: Claude Opus 5 <[email protected]>
In nightmare the merc dies most games, so resurrect_merc runs constantly — and Qual-Kehk
detection was failing 100% of the time (5 timeouts in 5 attempts, 107 hover attempts over
12 games). Each failed hunt costs ~40s and the code retried once, so a dead merc cost
~80s in EVERY game. Game length blew out to 185-250s against a normal ~60s.
GameStats._merc_resurrect_failed did not help: log_start_game resets it, so it only ever
suppressed a second attempt within one game. Nothing carried across games.
The name tag template is degenerate rather than merely stale — every grid-sweep "hit"
reported the identical score at unrelated positions:
found name tag at (255, 227) (score 0.424)
found name tag at (1110, 100) (score 0.424)
found name tag at (930, 310) (score 0.424)
so "found" is meaningless; it is matching uniform background.
Fix is cost containment, not detection: a cross-game circuit breaker on GameStats that
log_start_game deliberately does NOT reset — _merc_resurrect_fail_streak and
_merc_resurrect_skip_until, with Bot._MERC_RESURRECT_FAIL_LIMIT=2 and
_MERC_RESURRECT_SKIP_GAMES=15. The retry is also skipped once the streak is >=1, since
that is a second guaranteed-futile 40s hunt. Both counters clear on any successful
resurrect so a transient failure cannot permanently disable resurrecting.
Simulated over 30 games with an undetectable NPC: 60 hunts -> 4 (~40 min -> ~2.7 min).
Measured live: the breaker engaged on game 2 and game times went 250s / 185s -> 14s, 14s,
43s, 71s, 111s.
Still open: recapturing qual_name_tag_white.png is the actual fix for detection.
Co-Authored-By: Claude Opus 5 <[email protected]>
Biggest single cause of run failures: 25% of games failed, dominated by
"Approach failed for run_pindle [step: click_red_portal]", because the character was
running A5 pathing while physically in Act 4.
Chain: buy_consumables travels A5 -> A4 for Jamella ("Malah unreliable", 32x in one
session), repair then runs in A4 too, and Run Pindle's go_to_act(5, a4_town_start) asked
detect_current_act() to verify. It answered A5 while standing in Act 4, so go_to_act
"corrected" the assumption and skipped the travel entirely. 11 failures traced directly
to that trip in a single session.
Root cause: detect_current_act committed to whatever marker cleared 0.68 first. On a real
Act 4 failure frame:
A5_TOWN_1 0.636 <- phantom, always at (1046,40), top-right corner
A4_TOWN_5 0.619 <- the genuine marker for the act actually occupied
A 1.7 percentage point gap decided the act.
Fix: the winning marker must now also beat the best marker from ANY OTHER act by
_ACT_DETECT_MARGIN (0.05). Below that it logs "ambiguous ... refusing to guess the act"
and returns None. That is the safe answer: every caller treats None as "keep the assumed
act", so go_to_act keeps a4_town_start, sees it differs from the target, and actually
travels. Refusing to answer yields correct behaviour; guessing wrong does not.
Validated live: failure rate 25% -> 9% (10 successes, 1 failure). The first game of the
validation run was the exact failing case — repair starting from a4_town_start — and it
recovered and completed. Verified offline against 4 real Act 4 failure frames: all now
refuse instead of claiming A5.
The one remaining failure is a different cause: open_npc_menu timing out on qual_kehk
during merc resurrect (Bugs 3/4/6/7 family), not act desync.
Co-Authored-By: Claude Opus 5 <[email protected]>
The retry path I added treated detect_current_act() returning None as "we are not in
act 5" and aborted the run. None only means no TOWN_MARKERS template is on screen, which
happens routinely by the red portal in Harrogath's NE corner — the same blind spot that
makes on_init bail there.
All 6 occurrences in the 2026-08-26 19:0x session were "detected None"; not one was an
actual wrong act. Five of them landed consecutively, tripped the 5-strike circuit breaker,
disabled run_pindle and stopped an otherwise healthy 31-game session (level 32 -> 43,
526k gold stashed, 26/31 runs successful).
Fix: None now falls back to `loc`, the act-5 location already confirmed by go_to_act(5) at
the top of the same approach() call — a failed portal click cannot move the character
between acts. A genuinely different detected act still travels to act 5 as before, so the
Bug 9 desync protection is unchanged.
Co-Authored-By: Claude Opus 5 <[email protected]>
Gem conversion is off: [transmute] transmute= is now empty, which makes run_transmutes()
bail at "No gem tiers configured". That path also defeats force=True
(tools/gem_transmute.py), unlike transmute_every_x_game=0. Rejuv potion conversion is
unaffected — it runs independently via town_manager.convert_rejuv_potions().
Docs: record that the ACTIVE pickit set is config/bnip/Den gode.bnip and that it is
gitignored, so config/default.bnip edits have no effect on runs — an easy hour to lose.
Also document how should_pickup / should_id / should_keep interact, since that is what
makes a trailing catch-all rule work (pickup ignores the '#' clause, should_keep returns
on first match, so specific rules above still win).
Loot rule changes themselves live in the gitignored pickit file and are not in this commit:
- catch-all "[Quality] == Rare # [Strength] >= 999" so every rare is picked up and
identified, kept only if a specific rule matches, otherwise vendored for gamble gold.
Previously only 7 rare types had any rule, so rare weapons/armour/helms/shields were
never picked up and never sold.
- Flawless and Perfect gem lines uncommented (all gem rules had been commented out, so
no gems were being collected at all).
Co-Authored-By: Claude Opus 5 <[email protected]>
convert_all_gems never reached the GEMS tab. It logged "expected result ... not found in
GEMS convert panel; trying first slot fallback" on every one of 800+ iterations and kept
going, ctrl+shift+clicking blind into the personal stash grid.
Root cause: GEMS_TAB_Y = 100. Measured off the live client, the stash tab labels occupy
y=63..78 and the stash GRID starts at y~87 — so every tab-switch click landed on a stash
slot, not a tab. The X constants were already correct; only Y was wrong, by ~30px.
Measured centres: PERSONAL 68 | SHARED 144 | GEMS 220 | MATERIALS 295 | RUNES 370, y=70.
Second, independent bug found alongside it: params.ini had stash_tabs=6 with only 5 tabs
on screen. tab_properties() divides the bar by that count, giving centres of
63/127/192/256/320/384 against real centres of 68/144/220/295/370 — tabs 2, 3 and 4 were
clicking the gaps between tabs. Set to 5.
Tab switches are no longer fire-and-forget: _switch_to_tab() confirms the tab actually
became active, retries up to 3x, and returns False; convert_all_gems now aborts rather
than converting in the wrong tab. Active-tab detection measures the cell BACKGROUND
(p30 > 52; active ~67, inactive ~38) rather than glyph brightness — text brightness
scales with label length, so an active "GEMS" peaks at 167 while "PERSONAL" hits 215 and
any glyph threshold misreads the short label as inactive.
Verified live: four consecutive PERSONAL<->GEMS switches, each confirmed, using the real
class constants and detector.
Co-Authored-By: Claude Opus 5 <[email protected]>
run_pindle had a 100% failure rate, and worse, was reporting success while doing
nothing. Five root causes, found by recording the client and replaying the pather's
own matching against the captured failure frames.
1. a5_red_portal.png was the only MASKED template in a5_town/ (4-chan, 60.9% opaque)
because a hover tooltip had been baked into the capture and hidden with alpha.
That routed it to cv2.matchTemplate(TM_CCOEFF_NORMED, mask=...), which OpenCV only
supports for TM_SQDIFF/TM_CCORR_NORMED — hence 0.50-0.60 scores and match positions
that wandered onto unrelated scenery. Recaptured as a plain 3-channel opaque crop of
the portal's upper arch (the lower ring is occluded by branches).
Present 0.949-1.000 / absent 0.398-0.514, straddling the 0.68 threshold.
2. pindle.approach() opened with an "already in Pindle area?" shortcut. Harrogath
scenery scores 0.76-0.79 on PINDLE_7, over its 0.62 bar, so it fired in town and
returned A5_PINDLE_START without ever clicking the portal — the bot "killed Pindle"
in town for five straight games with zero loot and zero XP while logging
runs_failed_total: 0. Shortcut removed; entry is proven by the loading screen.
3. pather.find_abs_node_pos fell back to a 0.55 first-match search that fabricated node
positions (A5_TOWN_1 at 0.60-0.62 on scenery, three different frames, three different
phantom positions), steering the char into the town wall. Raised to 0.62, forced
best_match, and added a per-node heading gate that rejects a low-confidence match
implying a >90 deg reversal. Confident matches are never gated.
4. Walking onto the waypoint opened the WP panel, which health_manager counted toward a
chicken — 3 of 6 games died at full health. WP panels are now escaped without
counting, bounded at 6 attempts.
5. pindle retry re-pathed from a hardcoded A5_TOWN_START; it now verifies the act first.
Verified live: +349,890 XP over baseline, loot drops (Ring, gold), 6 games,
runs_failed_total 0. Docs updated with all four bugs, template asset conventions, and
how to verify a boss run actually killed something (XP delta + loot, never failed:false).
Co-Authored-By: Claude Opus 5 <[email protected]>
- Restore FIRST-RUN.md onboarding guide
- Pin environment.yml to requirements.txt (was loose 24 packages)
- Remove debug screenshots from git (debug_*.png -> .gitignore)
- Remove .hermes/ dev plans from git (already in .gitignore)
- Archive botty_next/ test harness to alexpolo1/my-botty-tools
- Archive docs/legacy-go/ to alexpolo1/my-botty-tools
- Move 14 dev tools from repo root to tools/ directory
- Extend .gitignore to prevent dev artifacts re-entering root
- Promote stable branch with all bug fixes from main
- Bump version 0.8.4 -> 0.9.1
Pindle (src/run/pindle.py):
- Pre-check if already in Pindle area before portal click
- Extended verify timeout to max(5s) for temple marker detection
A5 waypoint (src/town/a5.py):
- Two-tier WP scan: 0.45 threshold first, then 0.55
- Progressive stash thresholds (0.60 -> 0.50 -> 0.40) with direct click fallback
NPC detection (src/npc_manager.py):
- Close waypoint panel before NPC search (prevents WP UI blocking template match)
Diablo battle (src/char/paladin/hammerdin.py):
- Extended spawn wait from 15s to 20s
- Mid-fight target re-verification and repositioning
- Extra redemption burst to ensure kill
Auto-fixer (test/auto/auto_fixer.py):
- Updated to detect and verify all applied fixes
CI (.github/workflows/ci.yml):
- Added log analyzer tests to CI pipeline
- Excluded self-healing orchestrator (requires live D2R) and broken tests
New modules:
- test/auto/log_analyzer.py: parses bot logs and event JSONL files,
categorizes failures (approach, maintenance, battle, chicken, timeout,
crash, OCR) into structured BotFailure objects
- test/auto/auto_fixer.py: maps failure patterns to targeted code fixes,
checks if fixes are already applied, reports what needs work
- test/auto/test_self_healing.py: orchestrator that launches the bot,
monitors for failures in real-time, analyzes logs, applies fixes,
and retries up to 3 rounds
Supporting changes:
- test/auto/test_log_analyzer.py: 8 tests against historical run data
- pytest.ini: added repo root to pythonpath for test package imports
- test/__init__.py: new, enables test/ as importable package
All 95 existing tests pass. 8 new auto-test tests pass.
GetAsyncKeyState only detects keys when the bot process has focus.
When D2R is focused, F11/F12/End hotkeys were silently ignored.
Replaced polling loop with SetWindowsHookEx WH_KEYBOARD_LL which
intercepts all keystrokes globally before they reach any app.
Keeps polling as fallback if hook installation fails.
Also:
- Removed if-gate on enforce_d2r_window in game_controller.start()
- Added try/except pywintypes.error around all SetWindowPos calls
- Added pywintypes import to misc.py
- Added stop_hotkeys() cleanup in on_exit
enforce_d2r_window() now returns False when D2R is elevated, but the
if-gate caused all subsequent setup (window position, health manager,
death manager, game thread) to be skipped. Removed the if-gate so
startup continues regardless of window move result.
Wraps SetWindowPos in try/except pywintypes.error in move_d2r_window,
set_d2r_always_on_top, and restore_d2r_window_visibility. When D2R runs
as Administrator, Windows denies the call. Bot now continues gracefully
instead of crashing.
Wrapping SetWindowPos in try/except pywintypes.error in both
set_d2r_always_on_top() and restore_d2r_window_visibility(). When
D2R runs as Administrator and the bot runs as standard user,
Windows denies the call, causing a crash. Now it logs a debug
message and continues gracefully.
After Pindle fast-save/exit, the character spawns near the Nihlathak
portal area. During maintenance, identify() often fails and resets
_curr_loc to A5_TOWN_START, causing the pather to navigate from the
wrong starting position. The WP stone is often already visible on
screen, but the bot wasted 45s trying node-based pathing first.
Add an immediate full-screen WP scan as step 0 before any pathing.
This finds the WP directly when it's visible, bypassing stale
location tracking entirely.
Every fatal message told the user THAT something failed but not what to do
about it. The worst was "conda env create failed. See output above." --
useless when run_install_capture.bat redirects that output to a 56 KB log.
Each error now names the likely cause and the concrete next step:
- download failed -> the URL tried, firewall/proxy hint, manual-install
fallback that install.bat will detect on re-run
- truncated download -> got N bytes vs expected ~78 MB, bad file deleted
- installer failed -> antivirus/UAC hint, how to run it by hand
- conda found but dead -> the exact command to reproduce the real error
- env create failed -> disk/network/antivirus causes, plus the
"env remove -n botty -y" recovery for a half
finished install
- pip install failed -> notes the env itself is fine and a re-run resumes
- python.exe missing -> explains partial env, gives the recovery commands
- find_python.bat -> distinguishes "never installed" from "install.bat
did not finish", pointing at the capture log
Added a shared ":fail" exit so every fatal path ends with how to produce a
full log for a bug report, and states that nothing else was changed.
Also added a disk-space pre-flight before env creation: under 3 GB now
fails immediately with a clear message instead of letting conda die halfway
through with an opaque error; 3-6 GB warns. The environment needs ~4 GB
plus ~1 GB of downloads.
Verified: install.bat still completes with exit 0; the pre-flight was
exercised at real, simulated-2 GB and simulated-5 GB levels and all three
branches render and exit correctly; find_python.bat still resolves; 140
tests pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
install.bat had 9 pause statements on failure paths and none on the
success path. The README tells users to double-click install.bat, so on a
successful install the console vanished the moment it finished -- a new
user never saw "Installation complete", the OCR verdict, or the dependency
verification, and had no way to tell whether it had worked.
Added pause to the success path.
run_install_capture.bat redirects stdout to install_log.txt, so that new
pause would have blocked behind the redirect: an empty window silently
waiting on a keypress the user cannot see. It now feeds stdin from nul,
reports success/failure with the log path, and pauses itself.
Verified non-interactive: run_install_capture.bat completes in ~28s with
exit 0 and no hang.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
test_setup_bat_files.py asserted that run_asset_extractor.bat and
run_quest_debug.bat exist in the repo root. Those are developer tools that
the end-user `stable` branch deliberately strips, so a fresh clone of
stable shipped 4 failing tests even though the bot was fine.
Split the list into CORE_BATS (install/find_python/run_botty -- required on
every branch) and OPTIONAL_BATS (dev tooling -- validated only when
present). The username, absolute-path and find_python checks now iterate
over the files that actually exist rather than a hardcoded list.
Found by cloning stable from GitHub onto a clean machine and running the
suite as a new user would.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Includes the pefile import-chain technique that found it, since WinError
126 names the importing DLL and never the missing dependency.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
tesserocr never loaded -- install.bat always reported "tesserocr: not
available (DLL issue)" and the bot ran on the pytesseract fallback, which
shells out to tesseract.exe per OCR call instead of using the in-process
C++ API.
Root cause, found by walking the import table with pefile:
tesserocr.pyd -> tesseract52.dll -> leptonica-1.78.0.dll -> tiff.dll
-> libdeflate.dll <- MISSING
Current conda-forge libdeflate (>=1.20) installs the library as
"deflate.dll", but the older tiff.dll from the tesseract=4.x stack still
imports the previous name "libdeflate.dll". Nothing provided that name, so
tiff.dll failed to load and every DLL above it failed with WinError 126
("The specified module could not be found") -- which is why the error
looked like a missing module even though every file was present.
Fix: install libdeflate explicitly alongside tesseract=4.*, then copy
deflate.dll to the legacy name libdeflate.dll when that name is absent.
Same library, same exports.
Verified: removing the alias reproduces the failure exactly; running
install.bat recreates it and the installer now reports "tesserocr: OK
(fast path)". tesserocr initialises and performs real OCR with both bundled
models, and the bot logs "OCR backend: tesserocr (primary)" at startup.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
run_install_capture.bat writes install_log.txt into the repo root. It was
untracked but not ignored, so it showed up as noise in git status and was
easy to commit by accident.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Both were found by running install.bat under simulated clean-machine
conditions (conda removed, winget stripped from PATH, Tesseract hidden).
Bug 20: an unescaped ")" in an echo inside a parenthesised block aborted
the script at parse time, killing the conda direct-download path -- the
only path available without winget.
Bug 21: winget defaulted to machine scope, so the installer needed admin
and failed silently on a normal double-click.
Also documents the two recurring batch pitfalls with an awk audit command,
and the measured limitation that Tesseract has no per-user install path.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Testing the direct-download fallback with Tesseract absent and winget
unavailable showed the official installer self-elevates and its elevated
relaunch discards /D=, so it always installs machine-wide to
"C:\Program Files\Tesseract-OCR" regardless of TS_DEST.
The previous comment claimed this path gave a per-user install needing no
admin rights, which is not true: there is no per-user install path with
the official Tesseract installer, and it requires admin/UAC. Corrected the
comment rather than the code -- /D= is harmless as best-effort, and both
find_tesseract and src\d2r_image\ocr.py already search the machine-wide
and per-user locations, so either outcome works at runtime.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The Miniforge direct-download fallback -- the only path available on a
clean machine without winget -- could never complete. install.bat aborted
with ". was unexpected at this time." immediately after running the
Miniforge installer, so conda was installed but the botty env was never
created and the bot was unusable.
Cause: line 133 echoed "(exit code %errorlevel%)" inside a parenthesised
if-block. An unescaped ")" inside a block terminates the block, leaving
"." as a stray token. cmd parses the entire if-block when it reaches it,
so this fired even when the installer SUCCEEDED and the block body was
never meant to run -- verified with a minimal repro: the unescaped form
exits 255 on a false condition, the escaped form exits 0.
Fix: escape as ^(exit code %errorlevel%^), matching the convention the
rest of the file already uses ("^(fast path^)"). Audited every echo
inside a block; this was the only remaining unescaped instance.
Found by running install.bat with winget removed from PATH to simulate a
clean Windows 10 machine.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Live clean-install test caught a syntax bug in the new Tesseract setup
block: install.bat died with "so was unexpected at this time." right
after "Setting up OCR...", so OCR setup and the whole dependency
verification stage never ran.
Cause: "::" comment lines placed INSIDE parenthesised if-blocks. Two
problems compound there -- a "::" line inside a ( ) block is itself a
parse error, and any parenthesis in the comment text closes the block
early. The text "(non-zero when already installed), so after each" left
"so" as a stray token.
Fix: move every comment out of the parenthesised blocks, in both the
Tesseract block and the conda winget block added earlier. The conda one
had survived only because its text happened to contain no parentheses.
Verified: install.bat now runs to completion with exit 0 --
Tesseract: C:\Program Files\Tesseract-OCR\tesseract.exe
pytesseract: OK (tesserocr: DLL issue, expected)
cv2/mss/numpy/transitions/rapidfuzz/pydantic/pytesseract/yaml/discord: OK
All dependencies verified.
140 tests pass against the freshly created env.
Co-Authored-By: Claude Opus 4.8 <[email protected]>