Files
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

379 lines
16 KiB
Python

"""
Testbed: named in-game test runs for individual bot subsystems.
Each scenario assumes D2R is in the required state (noted below), runs ONE
subsystem in isolation, and reports — so features can be tested and improved
without farming runs around them.
<botty-env-python> tools/testbed.py <scenario>
Scenarios:
binds Verify + auto-set skill hotkeys from params/profile (in a game).
capture Capture skill-slot icon templates for the current binds (in a game).
audit Audit all stash pages against the current pickit; list FREE-able
items (in a game, stash OPEN).
gems Force-run the gem transmute routine: cube flawless -> perfect from
stash, restock per stash_destination (in a game, stash OPEN).
gems_all Convert gem tiers to perfect in one pass: chipped->flawed->
standard->flawless->perfect (in a game, stash OPEN).
Optional filters: a tier and/or gem type, e.g. "gems_all diamond"
or "gems_all flawless diamond".
For chipped-only conversion with before/after analysis, use
run_chipped_gems.bat instead.
stash Force-run the stashing routine: deposit gold + stash keep-flagged
inventory items, with page rotation (in a game, stash OPEN).
keyo Verify Controls-page key bindings (.keyo) match params (anywhere;
--fix requires D2R closed).
count Read gem counts from the GEMS stash tab via OCR and print the
transmute plan (in a game, stash OPEN on GEMS tab).
loot Unit-check pickup priority order + dive-mode flag (no game needed).
hermes Interactive LLM vision agent: grabs live D2R screen and sends it to
the local Qwen model for game-state analysis / feature development.
Optional: pass an initial prompt as extra args (no game state needed
for the LLM call itself, but D2R must be visible to grab).
State tip: to get 'stash OPEN with the bot paused', start the bot, and the
moment the log prints 'Stashing items', press F11 — or run a watcher like
log/_stash_pause_watcher.ps1.
"""
import os
import subprocess
import sys
TOOLS = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(TOOLS)
PY = sys.executable
def _run_tool(script: str, *args) -> int:
return subprocess.call([PY, os.path.join(TOOLS, script), *args], cwd=ROOT)
def _patch_ssl():
# The conda env's python chokes on a malformed cert in the Windows store
# (ssl.SSLError: ASN1 NOT_ENOUGH_DATA) when aiohttp builds its default
# context at import time. Fall back to certifi's bundle.
import ssl
_orig = ssl.SSLContext.load_default_certs
def _safe(self, purpose=ssl.Purpose.CLIENT_AUTH):
try:
_orig(self, purpose)
except ssl.SSLError:
try:
import certifi
self.load_verify_locations(certifi.where())
except Exception:
pass
ssl.SSLContext.load_default_certs = _safe
def _gems() -> int:
sys.path.insert(0, os.path.join(ROOT, "src"))
_patch_ssl()
import ctypes
ctypes.windll.user32.SetProcessDPIAware()
import time
print("Stash must be OPEN in-game. Forcing gem transmute in 5s...")
time.sleep(5)
from screen import start_detecting_window, stop_detecting_window
from game_stats import GameStats
from transmute import Transmute
start_detecting_window()
try:
Transmute(GameStats()).run_transmutes(force=True)
finally:
stop_detecting_window()
return 0
def _gems_all() -> int:
sys.path.insert(0, os.path.join(ROOT, "src"))
_patch_ssl()
import ctypes
ctypes.windll.user32.SetProcessDPIAware()
import time
print("Converting ALL gems to perfect. Opening stash if needed in 3s...")
time.sleep(3)
from screen import start_detecting_window, stop_detecting_window, grab
from game_stats import GameStats
from transmute import Transmute
from ui_manager import is_visible, ScreenObjects
from inventory import common
start_detecting_window()
try:
# Fast-path: ESC any open panels, then try clicking the stash chest directly
from input_layer import keyboard
from utils.misc import wait
for _ in range(3):
keyboard.send("esc")
wait(0.3, 0.3)
img = grab()
stash_open = is_visible(ScreenObjects.GoldBtnStash, img) or common.left_inventory_ready(img)
if not stash_open:
import template_finder as _tf
_m = _tf.search(["A5_STASH", "A5_STASH_2"], grab(), threshold=0.45,
use_grayscale=True, best_match=True)
if _m.valid:
from input_layer import mouse
print(f"gems_all: clicking stash chest directly @ {_m.center_monitor}")
mouse.move(*_m.center_monitor)
wait(0.2, 0.2)
mouse.click("left")
wait(1.5, 1.5)
img = grab()
stash_open = is_visible(ScreenObjects.GoldBtnStash, img) or common.left_inventory_ready(img)
if not stash_open:
print("gems_all: stash not open — navigating to stash...")
import template_finder
from ui import main_menu
from config import Config
from pather import Pather
from item.pickit import PickIt
from town import TownManager, A1, A2, A3, A4, A5
pather = Pather()
if Config().char["type"] in ("hammerdin", "paladin"):
from char.paladin.hammerdin import Hammerdin
char = Hammerdin(Config().hammerdin, pather, PickIt())
else:
from char.basic import Basic
char = Basic(Config().basic, pather)
char.discover_capabilities()
tm = TownManager(A1(pather, char), A2(pather, char), A3(pather, char),
A4(pather, char), A5(pather, char))
if template_finder.search(main_menu.MAIN_MENU_MARKERS, grab(), best_match=True).valid:
print("gems_all: on main menu — creating a game first...")
if not main_menu.start_game():
print("gems_all: FAIL — could not create game")
return 1
act = tm.wait_for_town_spawn()
else:
act = tm.detect_current_act()
if act is None:
from pather import Location
act = Location.A5_TOWN_START
print("gems_all: act detection failed, assuming A5")
if not tm.open_stash(act):
print("gems_all: FAIL — could not open stash")
return 1
if not common.wait_for_left_inventory():
print("gems_all: FAIL — stash UI not ready")
return 1
tier_names = {"chipped", "flawed", "standard", "flawless"}
gem_names = {"topaz", "amethyst", "sapphire", "diamond", "ruby", "emerald", "skull"}
args = [arg.strip().lower() for arg in sys.argv[2:] if arg.strip()]
max_transmutes = None
if "--max" in args:
max_idx = args.index("--max")
try:
max_transmutes = int(args[max_idx + 1])
del args[max_idx:max_idx + 2]
except (IndexError, ValueError):
print("gems_all: --max requires an integer")
return 1
tiers = [arg for arg in args if arg in tier_names] or None
gems = [arg for arg in args if arg in gem_names] or None
unknown = [arg for arg in args if arg not in tier_names and arg not in gem_names]
if unknown:
print(f"gems_all: ignoring unknown filter(s): {', '.join(unknown)}")
Transmute(GameStats()).convert_all_gems_to_perfect(
tier_filter=tiers,
gem_filter=gems,
max_transmutes=max_transmutes,
)
print("gems_all: DONE")
# Leave stash open so re-runs can skip navigation
finally:
stop_detecting_window()
return 0
def _stash() -> int:
sys.path.insert(0, os.path.join(ROOT, "src"))
_patch_ssl()
import ctypes
ctypes.windll.user32.SetProcessDPIAware()
import time
print("Requires being in a game, in town. Running stash routine in 5s...")
time.sleep(5)
from screen import start_detecting_window, stop_detecting_window, grab
start_detecting_window()
try:
from config import Config
from inventory import personal, common
from inventory import stash as stash_state
from ui_manager import is_visible, ScreenObjects
img = grab()
stash_open = (is_visible(ScreenObjects.GoldBtnStash, img)
or is_visible(ScreenObjects.GoldBtnInventory, img))
if not stash_open:
print("stash: stash UI not open — walking to the stash chest...")
import template_finder
from ui import main_menu
from pather import Pather
from item.pickit import PickIt
from town import TownManager, A1, A2, A3, A4, A5
pather = Pather()
if Config().char["type"] in ("hammerdin", "paladin"):
from char.paladin.hammerdin import Hammerdin
char = Hammerdin(Config().hammerdin, pather, PickIt())
else:
from char.basic import Basic
char = Basic(Config().basic, pather)
char.discover_capabilities()
tm = TownManager(A1(pather, char), A2(pather, char), A3(pather, char),
A4(pather, char), A5(pather, char))
if template_finder.search(main_menu.MAIN_MENU_MARKERS, grab(), best_match=True).valid:
print("stash: on main menu — creating a game first...")
if not main_menu.start_game():
print("stash: FAIL — could not create a game from the main menu")
return 1
act = tm.wait_for_town_spawn()
else:
act = tm.detect_current_act()
if act is None:
print("stash: FAIL — no town marker found (is the char in town?)")
return 1
if not tm.open_stash(act):
print("stash: FAIL — could not open the stash chest")
return 1
if not common.wait_for_left_inventory():
print("stash: FAIL — stash UI not detected after opening")
return 1
# "all" widens the scan to all 10 inventory columns so existing items
# (charms etc. in cols 4-9) get inspected — used to exercise the
# keep-item transfer branch when the loot columns (0-3) are empty.
if "all" in sys.argv[2:]:
prev_cols = Config().char["num_loot_columns"]
Config().char["num_loot_columns"] = 10
print(f"stash: scanning all 10 columns (was {prev_cols}) to test keep-item path")
items = personal.inspect_items(grab(), close_window=False, ignore_sell=True) or []
keep = [i for i in items if i.keep]
print(f"stash: inventory has {len(items)} item(s), {len(keep)} flagged keep"
+ (f" ({', '.join(i.name for i in keep)})" if keep else ""))
pages = stash_state.get_curr_stash()
print(f"stash: starting pages — items={pages['items']}, gold={pages['gold']}")
personal.set_inventory_gold_full(True) # force the gold-deposit branch
left = personal.stash_all_items(items if keep else None)
leftover = [i for i in (left or []) if i.keep]
gold_done = is_visible(ScreenObjects.GoldNone)
print(f"stash: gold deposited: {'yes' if gold_done else 'NO — gold still in inventory'}")
if leftover:
if "all" in sys.argv[2:]:
# In `all` mode cols 4-9 include the charm/belt area. Ctrl+click on those
# positions is blocked by D2R (equipped-area guard) so the items can't be
# stashed — they're meant to STAY in inventory. The correct behavior after
# the Bug-19 fix is: detect the transfer failure, leave them in inventory,
# and never call stash_full(). That is a PASS, not a FAIL.
unstashable = [i for i in leftover if i.column is not None and i.column >= 4]
real_fail = [i for i in leftover if i.column is None or i.column < 4]
if unstashable:
print(f"stash: OK — {len(unstashable)} item(s) in equipped-area cols "
f"correctly left in inventory (not stash-full): "
f"{', '.join(i.name for i in unstashable)}")
if real_fail:
print(f"stash: FAIL — {len(real_fail)} keep item(s) in loot cols "
f"left in inventory: {', '.join(i.name for i in real_fail)}")
return 1
print("stash: PASS — gold branch ran; equipped-area items correctly retained")
return 0
print(f"stash: FAIL — {len(leftover)} keep item(s) left in inventory: "
f"{', '.join(i.name for i in leftover)}")
return 1
print("stash: PASS — gold branch ran and no keep items left in inventory")
return 0
finally:
stop_detecting_window()
def _loot() -> int:
sys.path.insert(0, os.path.join(ROOT, "src"))
_patch_ssl()
import time
import health_manager as hm
from item.pickit import PickIt
class FakeItem:
def __init__(self, name, dist):
self.Name, self.Distance = name, dist
items = [FakeItem("SUPER MANA POTION", 50), FakeItem("JAH RUNE", 400), FakeItem("700 GOLD", 20)]
ordered = sorted(items, key=lambda i: (0 if PickIt._is_high_value(i) else 1, i.Distance))
assert ordered[0].Name == "JAH RUNE", [i.Name for i in ordered]
hm.set_loot_priority(True, duration=0.2)
assert hm.loot_priority_active()
time.sleep(0.3)
assert not hm.loot_priority_active()
print("loot: PASS (Jah-first ordering, dive flag + expiry)")
return 0
def _count() -> int:
"""Read gem counts from GEMS tab via OCR (primary) and print plan."""
sys.path.insert(0, os.path.join(ROOT, "src"))
_patch_ssl()
import ctypes
ctypes.windll.user32.SetProcessDPIAware()
import time
print("Stash must be OPEN in-game. Reading gem counts in 3s...")
time.sleep(3)
from screen import start_detecting_window, stop_detecting_window, grab
from game_stats import GameStats
from transmute import Transmute
from inventory.common import left_inventory_ready
from ui_manager import is_visible, ScreenObjects
start_detecting_window()
try:
img = grab()
if not (is_visible(ScreenObjects.GoldBtnStash, img) or left_inventory_ready(img)):
print("count: stash not open")
return 1
t = Transmute(GameStats())
t._switch_to_gems_tab()
time.sleep(0.6)
print("\n--- OCR counts (primary) ---")
ocr = t._count_gems_by_ocr()
if ocr:
for k, v in sorted(ocr.items()):
display = str(v) if v != 999 else "999 (OCR failed)"
print(f" {k}: {display}")
else:
print(" (none)")
print("\n--- Plan (3+ of same type) ---")
plan = t._plan_transmutes(ocr)
if plan:
for n, gem, _, tier in plan:
print(f" {n}x {gem} [{tier}]")
else:
print(" nothing to transmute")
finally:
stop_detecting_window()
return 0
def main():
scenarios = {
"binds": lambda: _run_tool("set_binds_from_params.py"),
"capture": lambda: _run_tool("capture_skill_hotkeys.py"),
"spellbook": lambda: _run_tool("map_spellbook.py", *sys.argv[2:]),
"audit": lambda: _run_tool("audit_stash_pickit.py"),
"keyo": lambda: _run_tool("set_controls_keyo.py", *sys.argv[2:]),
"gems": _gems,
"gems_all": _gems_all,
"count": _count,
"stash": _stash,
"loot": _loot,
"hermes": lambda: _run_tool("hermes.py", *sys.argv[2:]),
}
if len(sys.argv) < 2 or sys.argv[1] not in scenarios:
print(__doc__)
raise SystemExit(1)
raise SystemExit(scenarios[sys.argv[1]]())
if __name__ == "__main__":
main()