Files
my-botty/tools/map_spellbook.py
T
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

287 lines
11 KiB
Python

"""
Map the in-game skill-bind grid to config, using screenshots and clicks only.
Opens the D2R skill-bind screen (default key `s`), walks every cell, and for
each one:
1. hovers it and OCRs the tooltip -> the skill's NAME
2. OCRs the icon's upper-right box -> the F-key it is bound to (if any)
3. saves the icon as a template with that corner REMOVED
Then compares what the game actually has against the skill keys in config and
prints the lines that disagree. Exits 1 on a mismatch so it can gate a run.
<botty-env-python> tools/map_spellbook.py # map + report
<botty-env-python> tools/map_spellbook.py --assets # also write templates
<botty-env-python> tools/map_spellbook.py --no-open # grid already open
WHY THE CORNER IS CROPPED
-------------------------
D2R draws the hotkey label ("F5", "F8") over the upper-right corner of the
icon. An icon captured WITH that label only matches while the skill stays on
that key — rebind it and the template silently stops matching, which looks like
template rot rather than a bind change. So the corner is excluded from the
saved template, and read separately as the binding. Same pixels, two purposes.
WHY THIS EXISTS
---------------
Gear changes rebind skills. On 2026-08-28 an Enigma put Teleport on F5, which
displaced Conviction, while config still said `conviction=f5` — so every
attack-aura cast would have teleported the character mid-fight. The startup
preflight did report it and it was dismissed as a marginal template. This tool
answers the question directly instead of inferring it.
"""
import argparse
import difflib
import os
import re
import sys
import time
import types
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src"))
sys.modules.setdefault("discord", types.ModuleType("discord"))
import ssl
ssl.SSLContext.load_default_certs = lambda *a, **k: None
import cv2
import screen
from config import Config
from d2r_image.ocr import image_to_text
from input_layer import keyboard, mouse
from screen import convert_screen_to_monitor
from utils.misc import focus_d2r_window
# ── Grid geometry, measured against a 1280x720 client ────────────────────────
CELL0 = (683, 410) # centre of the top-left cell
CELL_W = 46.5
CELL_H = 45.0
CELL_HALF = 22 # half-width of the icon box around a centre
# The hotkey label sits in this box, relative to a cell's top-left corner.
CORNER = (26, 2, 22, 16) # x, y, w, h
# The tooltip renders directly ABOVE the hovered cell and grows upward, so its
# position moves with the row. A fixed ROI reads the game world instead — the
# first version of this tool did exactly that and returned "YEW Y" and "PET".
TOOLTIP_BAND = (350, 1150, 280, 12) # x0, x1, height_above, gap_above_cell
# Identification is a fuzzy match against this list rather than a demand for
# clean OCR. Real tooltip reads included "XI BLESSED HAMMER" and "HOLY SHIELD
# L", and one frame OCR'd Fist of the Heavens as "LNMEPULE" on its first line
# while the full text still contained the name.
KNOWN_SKILLS = [
"BLESSED HAMMER", "CONCENTRATION", "CONVICTION", "FIST OF THE HEAVENS",
"HOLY BOLT", "HOLY SHIELD", "REDEMPTION", "VIGOR", "CLEANSING",
"TELEPORT", "VENGEANCE", "SALVATION", "DEFIANCE", "MIGHT", "PRAYER",
"TOME OF TOWN PORTAL", "TOME OF IDENTIFY", "OAK SAGE", "RAVEN",
]
MATCH_MIN = 0.72
ASSET_DIR = os.path.join("assets", "templates", "skills_spellbook")
# config key -> the name D2R shows in the tooltip
CONFIG_SKILL_NAMES = {
"blessed_hammer": "BLESSED HAMMER",
"concentration": "CONCENTRATION",
"conviction": "CONVICTION",
"foh": "FIST OF THE HEAVENS",
"holy_bolt": "HOLY BOLT",
"holy_shield": "HOLY SHIELD",
"redemption": "REDEMPTION",
"vigor": "VIGOR",
"cleansing": "CLEANSING",
"teleport": "TELEPORT",
}
def _cell_centre(col: int, row: int) -> tuple[int, int]:
return int(CELL0[0] + CELL_W * col), int(CELL0[1] + CELL_H * row)
def _ocr(img, psm: int = 6) -> str:
if img is None or img.size == 0:
return ""
try:
res = image_to_text([img], psm=psm, crop_pad=False, invert=True, threshold=25)
return (res[0].text or "").strip() if res else ""
except Exception:
return ""
def read_bound_key(cell_img) -> str | None:
"""OCR the upper-right corner -> 'f5', or None when unbound."""
x, y, w, h = CORNER
corner = cell_img[y:y + h, x:x + w]
if corner.size == 0:
return None
corner = cv2.resize(corner, None, fx=4.0, fy=4.0, interpolation=cv2.INTER_CUBIC)
text = _ocr(corner, psm=7).upper().replace(" ", "")
m = re.search(r"F\s*(\d{1,2})", text)
if not m:
return None
n = int(m.group(1))
return f"f{n}" if 1 <= n <= 12 else None
def icon_without_corner(cell_img):
"""The icon with the hotkey label blanked — safe to store as a template."""
icon = cell_img.copy()
x, y, w, h = CORNER
icon[y:y + h, x:x + w] = 0
return icon
def read_skill_name(img, cy: int) -> str:
"""Identify the hovered skill from its tooltip, fuzzily."""
x0, x1, above, gap = TOOLTIP_BAND
band = img[max(0, cy - above):max(1, cy - gap), x0:x1]
text = re.sub(r"[^A-Z ]", " ", _ocr(band, psm=6).upper())
text = re.sub(r"\s+", " ", text).strip()
if not text:
return ""
for known in KNOWN_SKILLS: # exact substring is decisive
if known in text:
return known
best, score, words = "", 0.0, text.split()
for known in KNOWN_SKILLS: # else slide a window of the right length
kw = known.split()
for i in range(max(1, len(words) - len(kw) + 1)):
cand = " ".join(words[i:i + len(kw)])
r = difflib.SequenceMatcher(None, known, cand).ratio()
if r > score:
best, score = known, r
return best if score >= MATCH_MIN else ""
def map_grid(cols: int, rows: int, save_assets: bool) -> list[dict]:
found = []
if save_assets:
os.makedirs(ASSET_DIR, exist_ok=True)
for row in range(rows):
for col in range(cols):
cx, cy = _cell_centre(col, row)
mx, my = convert_screen_to_monitor((cx, cy))
mouse.move(mx, my, randomize=0, delay_factor=[0.25, 0.35])
time.sleep(0.55)
img = screen.grab()
name = read_skill_name(img, cy)
if not name:
continue # empty cell — no tooltip appeared
cell = img[cy - CELL_HALF:cy + CELL_HALF, cx - CELL_HALF:cx + CELL_HALF]
key = read_bound_key(cell)
found.append({"name": name, "key": key, "col": col, "row": row})
print(f" ({col},{row}) {name:<24} {key or '-'}", flush=True)
if save_assets:
slug = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
cv2.imwrite(os.path.join(ASSET_DIR, f"{slug}.png"),
icon_without_corner(cell))
return found
def compare_with_config(found: list[dict]) -> int:
cfg = Config()
char_type = str(cfg.char.get("type", "")).lower()
section = getattr(cfg, char_type, {}) or {}
configured = {k: str(v).strip().lower()
for k, v in section.items() if str(v).strip()}
tp = str(cfg.char.get("teleport", "")).strip().lower()
if tp:
configured["teleport"] = tp
in_game = {d["name"]: d["key"] for d in found if d["key"]}
problems = 0
print("\n config skill expects game has verdict")
print(" " + "-" * 66)
for skill, key in sorted(configured.items()):
want = CONFIG_SKILL_NAMES.get(skill)
actual = next((n for n, k in in_game.items() if k == key), None)
if want and actual == want:
verdict = "ok"
elif actual is None:
verdict = "nothing bound to that key"
problems += 1
else:
verdict = f"MISMATCH -> that key casts {actual}"
problems += 1
print(f" {skill:<19} {key:<9} {actual or '-':<17} {verdict}")
extra = [(s, in_game[n]) for s, n in CONFIG_SKILL_NAMES.items()
if n in in_game and s not in configured]
if extra:
print("\n Bound in game but not used by config:")
for s, k in extra:
print(f" {s:<19} is on {k}")
if problems:
print("\n Config lines matching what the game actually has:")
for name, key in sorted(in_game.items()):
slug = next((s for s, n in CONFIG_SKILL_NAMES.items() if n == name), None)
if slug:
print(f" {slug}={key}")
return problems
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--cols", type=int, default=6)
ap.add_argument("--rows", type=int, default=5)
ap.add_argument("--key", default="s", help="key that opens the bind grid")
ap.add_argument("--assets", action="store_true",
help="save corner-cropped icon templates")
ap.add_argument("--no-open", action="store_true",
help="grid is already open; do not press the key")
args = ap.parse_args()
screen.start_detecting_window()
screen.find_and_set_window_position(force=True)
if not focus_d2r_window():
print("map_spellbook: could not focus D2R — is it running?")
return 2
time.sleep(0.5)
if not args.no_open:
keyboard.send(args.key)
time.sleep(1.0)
print(f"Scanning {args.cols}x{args.rows} bind grid...", flush=True)
found = map_grid(args.cols, args.rows, args.assets)
bound = sum(1 for d in found if d["key"])
# A tool that cannot read the screen must say so, not report its blindness
# as findings. The first version scanned a closed grid, identified nothing,
# and then confidently declared all seven configured keys unbound.
if len(found) < 3 or bound == 0:
print(f"\nmap_spellbook: read {len(found)} skill(s), {bound} bound — that is "
f"too few to trust, so NO comparison was made.\n"
f" - is the bind grid actually open? (try --key <k>, or open it "
f"yourself and pass --no-open)\n"
f" - if your client is not 1280x720, the grid geometry at the top "
f"of this file needs re-measuring.")
return 2
print(f"\nFound {len(found)} skills, {bound} of them bound.")
problems = compare_with_config(found)
if not args.no_open:
keyboard.send(args.key) # close the grid we opened
if problems:
print(f"\n{problems} mismatch(es). Fix the binds in game or the keys in "
f"config/profiles/<profile>/profile.ini before running — a wrong key "
f"means the bot casts the wrong skill.")
return 1
print("\nAll configured skill keys match the game.")
return 0
if __name__ == "__main__":
raise SystemExit(main())