From 2feb13f44aaa7bd658d8d886958c7c7c833ee7fb Mon Sep 17 00:00:00 2001 From: alexpolo1 Date: Fri, 28 Aug 2026 10:27:07 +0200 Subject: [PATCH 1/2] =?UTF-8?q?tools:=20add=20spellbook=20mapper=20?= =?UTF-8?q?=E2=80=94=20read=20skill=20binds=20from=20the=20game,=20compare?= =?UTF-8?q?=20to=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tools/map_spellbook.py | 286 +++++++++++++++++++++++++++++++++++++++++ tools/testbed.py | 1 + 2 files changed, 287 insertions(+) create mode 100644 tools/map_spellbook.py diff --git a/tools/map_spellbook.py b/tools/map_spellbook.py new file mode 100644 index 0000000..b856080 --- /dev/null +++ b/tools/map_spellbook.py @@ -0,0 +1,286 @@ +""" +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. + + tools/map_spellbook.py # map + report + tools/map_spellbook.py --assets # also write templates + 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 , 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.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()) diff --git a/tools/testbed.py b/tools/testbed.py index 0c936b7..6e20fc8 100644 --- a/tools/testbed.py +++ b/tools/testbed.py @@ -358,6 +358,7 @@ 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, From fd889a4df77e79e3c1e5418cb66cdf330c7bf65d Mon Sep 17 00:00:00 2001 From: alexpolo1 Date: Fri, 28 Aug 2026 10:42:36 +0200 Subject: [PATCH 2/2] tools(spellbook): detect grid state, template-match the hotkey labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from running it against the live game: 1. It blind-pressed the open key. The grid was ALREADY open, so the press CLOSED it and the scan read grass. Now it detects the grid via its bottom hint and toggles only when needed, restoring the state it found. The hint match is fuzzy because Tesseract renders it "PRESS FI-F8 TO BIRD A SKIN" (BIND->BIRD, SKILL->SKIN). 2. The tooltip ROI was fixed, but the tooltip renders ABOVE the hovered cell and moves with the row, so it was reading the game world. Now taken relative to the cell. Skill identification is a fuzzy match against a known list rather than a demand for clean OCR: real reads included "XI BLESSED HAMMER" and one frame OCR'd Fist of the Heavens as "LNMEPULE". 3. Hotkey labels are read by TEMPLATE MATCH, not OCR. They are ~20x12px of white glyph over whatever icon is behind them; brightness thresholding cannot separate the two when the icon is also bright (F4's swirl, the row-4 weapons) and OCR managed 4/8. A top-hat isolates small bright features regardless of background: 8/8 with zero false positives. Matching a same-SIZED crop is 1px-brittle — int() rounding in the grid geometry lands a pixel off the measured centre and the score collapses from ~1.0 to ~0.4. Exactly the three cells where truncation differed failed. A 4px search slack fixes it; 8/8 held at every threshold 0.60-0.78. Adds assets/templates/skill_binds/f1..f8.png, cut from a frame with all eight labels visible. Verified live: correctly reported conviction=f5 as casting TELEPORT and concentration=f8 as casting CONVICTION — both confirmed by hand beforehand — and found Concentration sitting unbound. Co-Authored-By: Claude Opus 5 --- assets/templates/skill_binds/f1.png | Bin 0 -> 1141 bytes assets/templates/skill_binds/f2.png | Bin 0 -> 1136 bytes assets/templates/skill_binds/f3.png | Bin 0 -> 1013 bytes assets/templates/skill_binds/f4.png | Bin 0 -> 1123 bytes assets/templates/skill_binds/f5.png | Bin 0 -> 1051 bytes assets/templates/skill_binds/f6.png | Bin 0 -> 1104 bytes assets/templates/skill_binds/f7.png | Bin 0 -> 1062 bytes assets/templates/skill_binds/f8.png | Bin 0 -> 1115 bytes tools/map_spellbook.py | 112 +++++++++++++++++++++++----- 9 files changed, 94 insertions(+), 18 deletions(-) create mode 100644 assets/templates/skill_binds/f1.png create mode 100644 assets/templates/skill_binds/f2.png create mode 100644 assets/templates/skill_binds/f3.png create mode 100644 assets/templates/skill_binds/f4.png create mode 100644 assets/templates/skill_binds/f5.png create mode 100644 assets/templates/skill_binds/f6.png create mode 100644 assets/templates/skill_binds/f7.png create mode 100644 assets/templates/skill_binds/f8.png diff --git a/assets/templates/skill_binds/f1.png b/assets/templates/skill_binds/f1.png new file mode 100644 index 0000000000000000000000000000000000000000..d4ec24b7069178d9674d402f26108dca7be39c42 GIT binary patch literal 1141 zcmV-*1d98KP)4^SnKDdO51DSwR&;|ic6*{=*Q|(1IhHM(`+&DawSUL6H`7Iyp9VlwNpMy3 zqGW4*nK9V6p+f+mP~J(I4^BL|zdG60 zy?NvAx89Lux~b>cV(%S2_UWmCZ!dmN zF!ZlNm8cCPvP$8YKqscjolc~2>%#|b4g8gE-f z31_qOW%&Bm4OLGlGTC?NaK2a?86MFTDj@o8Xvnr5?)x&Ou4gCWOZv|Z#1g43UCVM6 zQOrCtG|f?isRj)TBohz5w6$C2LXnj{2i~8W$$oeFCrTtB#PHQ?ssv0!lOeKvFH)Dd zaA7c(NIloHGQHrIvr{r9z>KVEGBM$+?iM+`SO<4w->`pQ&sT$@R&rUpr$~&$;J(-}%1&?e~MrnimMb36U`-BBVT| z96$tJh!71_D!6W#j0Pg)md*+eLI?@~5rhaMzkx$C+7-=|F-?{T zU_>?q484*MAi_|Tn2Z2L1i_FBE{YVUY;FEbLul&bT#%61zd6?T*r%Q=Nf>_gN>j)M z00=^$O2`idM3O{s2>5i-GgRFVyijl>qDn-^u!JN+eQH~gTrnhA`E6(_bP5P#*z&>> z3^@am$3wvZVu4~O49oJ1IfxYj7^gy#6(>7tnA*ssH<9^PQX!I>X&AjGzvJ8NodMn}g001yHpz}EKdPSII7uWr+h&nL6_{N!Zg zlj-)&TmHEFCv_b{2;BGa>WOT&!nQ`H-0?y&ptL}7`_@gJneOvHTvTKnQbuH%b5U-a z2Q&LS_jjB*f2pF(oS#l#ICtu)=4DA!!x+Pk?Hd;^U$tn_qU*iAXV0AK`MGCKN@?Hy z#lgefw{G01v~?+BA`nC@3jy`g)hpvUHy*Q}PgeugJbL*@L@WK7`h4_D27u{GV;I5sgsFp*?A@co!tI^1>gc3=P8c?}4m z;gLsV@~?b}$^`4W#)Q<>ePqwB-I?QOk`<|xr4x+d*Be{(hUXSHEm0Ju=#}(HB$qFw z47R_sD*)=d>(&DZdT-pE=>4vLXe^+iRi5h1bbhvN$Key-&Z&w;Q!|C1L|y%tU+wAW>^hV=7LVJD z>t@`3RD^y}mnHbk_O^eb4Ry8EwjE=Phdc}@TQFD{_Y>0 z*tPFKBAEdHv*M+ Cm>~fG literal 0 HcmV?d00001 diff --git a/assets/templates/skill_binds/f3.png b/assets/templates/skill_binds/f3.png new file mode 100644 index 0000000000000000000000000000000000000000..d9c970d47ac1537e949d687f3037156e6af46265 GIT binary patch literal 1013 zcmVI6sOX0n3xdYbt#tvb{V-Z7PRqMTJy$*FectENKe5jTE)E_>_XAuUJdExKIQYy% zN^6RlFQo814cf6S-}9JZas-?U49fQDx321LB z=e_{YQXv392$>Xfr2z;fSRuWg<&I%;pG%|yra`HJK>)$hm%)IM$SR8I-SsTA)K~aY zA*C>AiHN6^(g;g#xVd?eLg9Hpm_|wo%@~cXE!h^VC;XJBD$0sN7Nj<#uFdAQJlJ1X zMhs$F4i_SwOkqN`R$NMi^w9b7s+yYB)is0=074qQ_s+4{=4wOTp3~>Xrl$TVDtsW| zx?LS@D;pbd=*em@jEayoy%du|#WPuhnI^-YCjB3tA08e5?#lND!%_%BnbtZKz>%=+ zOXK&u%OzoZ^upM};^OG=S$OrS2Q$e`_XkJE&wc>}Cz3m{9j`d-a-n;NMu$f}{rSfA zihM>42O(hxEI{nnsTrk^;nK&-b2{EW8I7fSPxe7|se5jqhZ<%yx^-&k^5qNf-~8?G zI}<-q$LKmfu$kDNoS1YirUBi!{-c+PTeg7}2(4>s9@*Q_u=k0^=9Yu8L>g{h?c2`y zLMkDpWw2yAQ;?t2_12kJUfkE#@#f6T{FSReP>Mn8Tep5eJXsWRJT3%~*SnAHsjc1I ziobTW%QQ6fooJt*TX7uAvdz-6LM8l2$PMQPHsa~}hUS66k0-BPD=i2vMB@~2(8hLv zF+v>6NTm77MykD|^T?65{m(xK$6Fue86Hjf1S7|!w`Z4GsOZ2;Pe*c{#>SSRk49G( zr%Q^%Y;)oE{D#FCrI`B?0hU^o=etIJ^(UWy87bZckJnb6>F@No@7QKfcYipPm!D%& zE&tu2!^0yZW1s!EFdYuNWOZ&ax-Ed%MD2Uw&_93OnVp&5T~*N9`ED?n)AVdJ)Yn!X zXl}|2I+U@CBVQHeIq9sQ@B? jDLpwc(b@UNRx0~HZ!X}w+|OBH00000NkvXXu0mjfi6Qa5 literal 0 HcmV?d00001 diff --git a/assets/templates/skill_binds/f4.png b/assets/templates/skill_binds/f4.png new file mode 100644 index 0000000000000000000000000000000000000000..b47970d42404014ab520c19541b6fc8fee0f39ad GIT binary patch literal 1123 zcmV-p1f2VcP)-E#27Jd4{E@W zR2GXs$`Xl*58^^J1X~GcS`d?}ElX=VEig0PX6wwockVgAd>^h_UoS~2x*~*R06?5E zkOY9_2|^AefQWiS(S=k@QriSP}xjQu&M{7!$VR zp{g>|Z)Xd0OiWBWqnH7XzJj@G;q0O#vZ&#)ceimxja8NZb#p9W`OUCZD%w6XARVWF zIBgdTQA?9tFW6FmjFlVxcak^anY5k_Mj06vYCT#tmykQ1tQ z$woxSaRt*gU3;h^%oT0;ernNd(=Y<}r}H<{DXw4utR-{Jup+w$C-HFGy7+{xDLzRK z5J&(GOF3Jl9pMoGW1dG$Wtps-E|iu(Hdj;q*GCKEZbH*EqvWp*VO?cFa*jJ18x#~C zFe?=ChzObYfFP2T$>j}|$ERkHfTUM$Y2_7B>3V6QB;6Ea3g%gDRFA5{vi$hM`lXU7 zoZ}lGd{k9aolK+%2muIi@7{gGLxTrey)}F{gVTs)c%fz0 z!A!0g3=qD!W>NFn$GiU?X?y3rLr0E(cm9IP(USy>Gsb)hUszjF7WRGL-9K{sdNdl@ zw0V1cEMd%22&Ba#HDTsM=}Ew%?c_36ZhXaEnLP{x0QQaLF80fp!D*=JA z-@3YN+m%A-n%1ypbz|dGTU&RI#``xmw~mh9HD)p7T(SL+%rQ7IUG?yK-$1cY&@{Ee zl7oYTKVG<~QmN>au3Y)uuYeUWBH`$lCp%WIZk(8$Oy0ZI)Z988A2Z51L#BGh(rSyN zRES~Arsnq#v^&K@NG%*acIuy-|IM$ScjNNUnYm~@ zI)yxLUb`$7iw&j4Cm+9kw4*bbajL7T*R6kgas9))Hat;N8Ic@q$5X)Lh$nuy(6nj$ p-FpdUk*YD4SC`jCIHO{r@IRg*7NCSkh}8f9002ovPDHLkV1n435E}ph literal 0 HcmV?d00001 diff --git a/assets/templates/skill_binds/f5.png b/assets/templates/skill_binds/f5.png new file mode 100644 index 0000000000000000000000000000000000000000..d828d65b616d061d9f71bb985c78f7d71dacf723 GIT binary patch literal 1051 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 literal 0 HcmV?d00001 diff --git a/assets/templates/skill_binds/f8.png b/assets/templates/skill_binds/f8.png new file mode 100644 index 0000000000000000000000000000000000000000..4599480f119bd8615c7a2ef527f26b6254ff2a98 GIT binary patch literal 1115 zcmV-h1f=_kP)d~oif8Cn}ecD4)3}VVxf&&4wI2vLR*BTr5 zN(v*%l;q#X{CR6Rpk-;2@Z)CixDC{_)k))5WE}Euw;DDY~64HtW6MnJF zhsKq~1(5;(LtvrI209rS1ptg`MNF`CCP@1FvFZ{btZTyou3Y#a)LvCx9goL}01yDMsd3YtTh|`0t~}iSnJ@KlJicII-CG+sUcT}xu3IprrXrTf zWcw4I@ufkOfgg69bWZMW{j{~Mz4Mzc!9W=R#uzdio7T@i{mWl1J6}J0qBGyMx~}%mC@atw4(Yh<)q4!CyqD2v%O(O!*74w#P3h;umm4Zc}~az zfRWMEwCag__IO+{sz)nV&Dusa=A{k2I#Ue%AA2g?Y)em*<9)8Bc3d zkmuIcHGF)uqrd+~Z_hxvE&V_;Bv;_xj<4_D8``<8Ic5iS^{Y2GZ!4{+!Gn8W^8%Ic zIyq@Lk}+TiI74J?$&xi~$2#Kip3(cGk<`Dg>oBP^N-N=P*s|;S=aw>t-PgO{*zo3+ zzi;8Z=@n!k&@#lhGExyTX1O752#Il@#I%Pho?Se1^2DO9bHAJ0-FFk7(n?D8XMZv$ hwNyGB60xGZ{{X`4C8RT88GQf%002ovPDHLkV1gLY9033T literal 0 HcmV?d00001 diff --git a/tools/map_spellbook.py b/tools/map_spellbook.py index b856080..d1d84ab 100644 --- a/tools/map_spellbook.py +++ b/tools/map_spellbook.py @@ -34,6 +34,7 @@ answers the question directly instead of inferring it. import argparse import difflib +import glob import os import re import sys @@ -58,10 +59,24 @@ from utils.misc import focus_d2r_window 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 +CELL_HALF = 24 # 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 hotkey label box, relative to a cell's top-left corner (cell is +# 2*CELL_HALF square). This is both the region blanked from saved templates and +# the region searched for the binding. +CORNER = (24, 2, 24, 16) # x, y, w, h + +# The label is read by TEMPLATE MATCH, not OCR. It is ~20x12px of white glyph +# over whatever icon happens to be behind it; brightness thresholding cannot +# separate the two when the icon is also bright (F4's swirl, the row-4 +# weapons), and OCR managed only 4/8. A top-hat isolates small bright features +# regardless of background, and the glyphs are a fixed UI font. +BIND_TEMPLATE_DIR = os.path.join("assets", "templates", "skill_binds") +BIND_MIN = 0.70 +# Search slack. Matching a same-sized crop is 1px-brittle: int() rounding in the +# grid geometry lands a pixel off the measured centre and the score collapses +# from ~1.0 to ~0.4. With slack, 8/8 matched at every threshold 0.60-0.78. +BIND_PAD = 4 # 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 @@ -80,6 +95,16 @@ KNOWN_SKILLS = [ ] MATCH_MIN = 0.72 +# The bind screen shows this hint along the bottom. It is how we tell whether +# the grid is ALREADY open — pressing the key blindly toggles an open grid +# CLOSED, which is exactly how the first live run came back with 0 skills. +# Matched fuzzily: Tesseract reliably renders this as +# "PRESS FI-F8 TO BIRD A SKIN" (BIND->BIRD, SKILL->SKIN), so an exact compare +# never fires. Closed, the same ROI yields "" or "7," — the gap is wide. +HINT_ROI = (450, 620, 400, 25) # x, y, w, h +HINT_TEXT = "PRESS F F TO BIND A SKILL" +HINT_MIN = 0.55 + ASSET_DIR = os.path.join("assets", "templates", "skills_spellbook") # config key -> the name D2R shows in the tooltip @@ -97,6 +122,18 @@ CONFIG_SKILL_NAMES = { } +def grid_is_open(img=None) -> bool: + """True when the skill-bind grid is showing.""" + img = screen.grab() if img is None else img + x, y, w, h = HINT_ROI + crop = cv2.resize(img[y:y + h, x:x + w], None, fx=2.5, fy=2.5) + text = re.sub(r"[^A-Z ]", " ", _ocr(crop, psm=7).upper()) + text = re.sub(r"\s+", " ", text).strip() + if not text: + return False + return difflib.SequenceMatcher(None, HINT_TEXT, text).ratio() >= HINT_MIN + + def _cell_centre(col: int, row: int) -> tuple[int, int]: return int(CELL0[0] + CELL_W * col), int(CELL0[1] + CELL_H * row) @@ -111,19 +148,45 @@ def _ocr(img, psm: int = 6) -> str: return "" -def read_bound_key(cell_img) -> str | None: - """OCR the upper-right corner -> 'f5', or None when unbound.""" +def _isolate_glyph(bgr): + """Top-hat: keeps small bright features, drops whatever is behind them.""" + g = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY).astype("float32") + k = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 9)) + th = cv2.morphologyEx(g, cv2.MORPH_TOPHAT, k) + return cv2.normalize(th, None, 0, 255, cv2.NORM_MINMAX).astype("uint8") + + +_BIND_TEMPLATES: dict | None = None + + +def _bind_templates() -> dict: + global _BIND_TEMPLATES + if _BIND_TEMPLATES is None: + _BIND_TEMPLATES = {} + for path in glob.glob(os.path.join(BIND_TEMPLATE_DIR, "*.png")): + im = cv2.imread(path) + if im is not None: + _BIND_TEMPLATES[os.path.basename(path)[:-4].lower()] = _isolate_glyph(im) + return _BIND_TEMPLATES + + +def read_bound_key(img, cx: int, cy: int) -> str | None: + """Template-match the hotkey label -> 'f5', or None when unbound.""" x, y, w, h = CORNER - corner = cell_img[y:y + h, x:x + w] - if corner.size == 0: + x0 = cx - CELL_HALF + x + y0 = cy - CELL_HALF + y + win = img[y0 - BIND_PAD:y0 + h + BIND_PAD, x0 - BIND_PAD:x0 + w + BIND_PAD] + if win.size == 0 or win.shape[0] < 10: 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 + g = _isolate_glyph(win) + best, score = None, 0.0 + for name, tpl in _bind_templates().items(): + if g.shape[0] < tpl.shape[0] or g.shape[1] < tpl.shape[1]: + continue + s = cv2.matchTemplate(g, tpl, cv2.TM_CCOEFF_NORMED).max() + if s > score: + best, score = name, s + return best if score >= BIND_MIN else None def icon_without_corner(cell_img): @@ -173,7 +236,7 @@ def map_grid(cols: int, rows: int, save_assets: bool) -> list[dict]: 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) + key = read_bound_key(img, cx, cy) found.append({"name": name, "key": key, "col": col, "row": row}) print(f" ({col},{row}) {name:<24} {key or '-'}", flush=True) @@ -247,9 +310,22 @@ def main() -> int: return 2 time.sleep(0.5) - if not args.no_open: + # Detect, do not assume. Toggling an already-open grid closes it. + opened_by_us = False + if not grid_is_open(): + if args.no_open: + print("map_spellbook: --no-open was given but the bind grid is not " + "open. Open it and re-run, or drop --no-open.") + return 2 keyboard.send(args.key) time.sleep(1.0) + opened_by_us = True + if not grid_is_open(): + print(f"map_spellbook: pressed {args.key!r} but the bind grid did not " + f"open. Use --key if your bind screen is on another key.") + return 2 + else: + print("Bind grid already open — leaving it as found.") print(f"Scanning {args.cols}x{args.rows} bind grid...", flush=True) found = map_grid(args.cols, args.rows, args.assets) @@ -270,8 +346,8 @@ def main() -> int: 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 opened_by_us: + keyboard.send(args.key) # close it only if we opened it if problems: print(f"\n{problems} mismatch(es). Fix the binds in game or the keys in "