From dc224bec5cf0a103ccba5686fd6320552bc6e54a Mon Sep 17 00:00:00 2001 From: alex Date: Sun, 24 May 2026 19:26:00 +0200 Subject: [PATCH] Fix CTA spam, traverse_nodes_fixed crash, and add skill preflight/setter - config: cta_available=0 (user has no CTA weapon; was causing 5 failed weapon-swap attempts per run) - pather: traverse_nodes_fixed now allows can_teleport_with_charges chars through instead of raising ValueError; when charges deplete mid-path char.move() falls back to walking gracefully - utils: add skill_preflight.py (visual hotkey verification, all blizz_sorc skills use side=right) and skill_hotkey_setter.py (automated picker binding with step-by-step logging) - test: add test_skill_preflight.py (3 passing tests) - assets: add sorc skill icon templates for preflight matching - tools: add capture_sorc_skill_icons.py and set_sorc_skill_hotkeys.py - key_detector: extend VK map with numpad/F-key/symbol codes; add validate_key_bindings() and parse_key_file(); fix char_name lookup Co-Authored-By: Claude Sonnet 4.6 --- assets/templates/ui/skills/blizzard.png | Bin 0 -> 4016 bytes assets/templates/ui/skills/energy_shield.png | Bin 0 -> 4016 bytes assets/templates/ui/skills/frozenarmor.png | Bin 0 -> 4016 bytes assets/templates/ui/skills/ice_blast.png | Bin 0 -> 4670 bytes .../templates/ui/skills/picker_blizzard.png | Bin 0 -> 3055 bytes .../ui/skills/picker_frozenarmor.png | Bin 0 -> 2953 bytes assets/templates/ui/skills/static_field.png | Bin 0 -> 4016 bytes assets/templates/ui/skills/telekinesis.png | Bin 0 -> 4016 bytes config/params.ini | 4 +- src/pather.py | 4 +- src/utils/key_detector.py | 171 ++++++++++++++++- src/utils/skill_hotkey_setter.py | 177 ++++++++++++++++++ src/utils/skill_preflight.py | 154 +++++++++++++++ test/test_skill_preflight.py | 48 +++++ tools/capture_sorc_skill_icons.py | 77 ++++++++ tools/inspect_key_bindings.py | 8 +- tools/set_sorc_skill_hotkeys.py | 45 +++++ 17 files changed, 671 insertions(+), 17 deletions(-) create mode 100644 assets/templates/ui/skills/blizzard.png create mode 100644 assets/templates/ui/skills/energy_shield.png create mode 100644 assets/templates/ui/skills/frozenarmor.png create mode 100644 assets/templates/ui/skills/ice_blast.png create mode 100644 assets/templates/ui/skills/picker_blizzard.png create mode 100644 assets/templates/ui/skills/picker_frozenarmor.png create mode 100644 assets/templates/ui/skills/static_field.png create mode 100644 assets/templates/ui/skills/telekinesis.png create mode 100644 src/utils/skill_hotkey_setter.py create mode 100644 src/utils/skill_preflight.py create mode 100644 test/test_skill_preflight.py create mode 100644 tools/capture_sorc_skill_icons.py create mode 100644 tools/set_sorc_skill_hotkeys.py diff --git a/assets/templates/ui/skills/blizzard.png b/assets/templates/ui/skills/blizzard.png new file mode 100644 index 0000000000000000000000000000000000000000..fd1737e24439b4ef27220cc2d4fb3ed81c91e896 GIT binary patch literal 4016 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 literal 0 HcmV?d00001 diff --git a/assets/templates/ui/skills/static_field.png b/assets/templates/ui/skills/static_field.png new file mode 100644 index 0000000000000000000000000000000000000000..fd1737e24439b4ef27220cc2d4fb3ed81c91e896 GIT binary patch literal 4016 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 Wbf@eD8GwiY0000 bool: - if not char.capabilities.can_teleport_natively: + if not char.capabilities.can_teleport_natively and not char.capabilities.can_teleport_with_charges: error_msg = "Teleport is required for static pathing" Logger.error(error_msg) raise ValueError(error_msg) + if char.capabilities.can_teleport_with_charges and not char.capabilities.can_teleport_natively: + Logger.debug("traverse_nodes_fixed: using charge-based teleport (charges may deplete mid-path)") char.pre_move(wait_tp = True) if type(key) == str: path = Config().path[key] diff --git a/src/utils/key_detector.py b/src/utils/key_detector.py index ed1276d..0ad0fc6 100644 --- a/src/utils/key_detector.py +++ b/src/utils/key_detector.py @@ -19,11 +19,15 @@ VK_MAP = { 77: "m", 78: "n", 79: "o", 80: "p", 81: "q", 82: "r", 83: "s", 84: "t", 85: "u", 86: "v", 87: "w", 88: "x", 89: "y", 90: "z", - 97: "a", 98: "b", 99: "c", 100: "d", 101: "e", 102: "f", - 103: "g", 104: "h", 105: "i", 106: "j", 107: "k", 108: "l", - 109: "m", 110: "n", 111: "o", 112: "p", 113: "q", 114: "r", - 115: "s", 116: "t", 117: "u", 118: "v", 119: "w", - 120: "x", 121: "y", 122: "z", + 96: "numpad0", 97: "numpad1", 98: "numpad2", 99: "numpad3", + 100: "numpad4", 101: "numpad5", 102: "numpad6", 103: "numpad7", + 104: "numpad8", 105: "numpad9", 106: "numpad*", 107: "numpad+", + 109: "numpad-", 110: "numpad.", 111: "numpad/", + 112: "f1", 113: "f2", 114: "f3", 115: "f4", 116: "f5", + 117: "f6", 118: "f7", 119: "f8", 120: "f9", 121: "f10", + 122: "f11", 123: "f12", + 186: ";", 187: "=", 188: ",", 189: "-", 190: ".", 191: "/", + 192: "`", 219: "[", 220: "\\", 221: "]", 222: "'", 16: "capslock", 9: "tab", 27: "esc", 28: "enter", 32: "space", 160: "left shift", 161: "right shift", 162: "left ctrl", 163: "right ctrl", @@ -45,20 +49,93 @@ def _normalize_key(k: str) -> str: return k +def _try_fill(cfg: dict, key: str, detected: str) -> None: + current = _normalize_key(str(cfg.get(key, ""))) + detected = _normalize_key(str(detected)) + if not current: + cfg[key] = detected + + +def _auto_fill_char(char_cfg: dict, bindings: dict) -> None: + key_map = { + "inventory": "inventory_screen", + "inventory_screen": "inventory_screen", + "show_items": "show_items", + "stand_still": "stand_still", + "show_belt": "show_belt", + "force_move": "force_move", + "potion1": "potion1", + "potion2": "potion2", + "potion3": "potion3", + "potion4": "potion4", + } + for detected_key, config_key in key_map.items(): + if detected_key in bindings: + _try_fill(char_cfg, config_key, bindings[detected_key]) + + def vk_to_name(vk_code: int) -> str | None: return VK_MAP.get(vk_code, None) +def parse_key_file(filepath: str) -> dict: + """Compatibility parser used by debug tooling and tests.""" + try: + with open(filepath, "r", encoding="utf-8") as f: + lines = [line.strip() for line in f if line.strip()] + if lines and lines[0].isdigit(): + bindings = {} + for line in lines[1:]: + vk_str, action_str, param_str = line.split()[:3] + key_name = vk_to_name(int(vk_str)) + if key_name is None: + continue + action = int(action_str) + param = int(param_str) + if action == 1: + bindings[f"skill{param}"] = key_name + elif action == 2: + bindings["inventory"] = _normalize_key(key_name) + elif action == 3: + bindings["show_items"] = _normalize_key(key_name) + elif action == 4: + bindings["stand_still"] = _normalize_key(key_name) + elif action == 5: + bindings["show_belt"] = _normalize_key(key_name) + elif action == 6 and 1 <= param <= 4: + bindings[f"potion{param}"] = key_name + elif action == 13: + bindings["force_move"] = key_name + return bindings + except UnicodeDecodeError: + pass + + skills, _non_skill_keys, char_bindings = _parse_binary(filepath) + bindings = dict(char_bindings) + if "inventory_screen" in bindings: + bindings["inventory"] = bindings["inventory_screen"] + for slot, key_name in skills.items(): + bindings[f"skill{slot}"] = key_name + return bindings + + def _find_key_file(d2r_path: str, char_name: str) -> str | None: candidates = [] saved_games = os.path.expanduser("~/Saved Games/Diablo II Resurrected") + char_name = (char_name or "").strip() for ext in ("keyo", "key"): candidates.append(os.path.join(saved_games, f"{char_name}.{ext}")) if os.path.isdir(saved_games): try: - for f in sorted(os.listdir(saved_games)): - if f.endswith(".keyo") or f.endswith(".key"): - candidates.append(os.path.join(saved_games, f)) + key_files = [f for f in sorted(os.listdir(saved_games)) if f.lower().endswith((".keyo", ".key"))] + if char_name: + normalized_char = "".join(ch for ch in char_name.lower() if ch.isalnum()) + for f in key_files: + normalized_file = "".join(ch for ch in os.path.splitext(f)[0].lower() if ch.isalnum()) + if normalized_file.startswith(normalized_char): + candidates.append(os.path.join(saved_games, f)) + for f in key_files: + candidates.append(os.path.join(saved_games, f)) except OSError: pass for ext in ("keyo", "key"): @@ -100,7 +177,7 @@ def _parse_binary(filepath: str) -> tuple: with open(filepath, "rb") as f: data = f.read() if len(data) < 14: - return skills, non_skill_keys + return skills, non_skill_keys, char_bindings entry_size = 10 n_entries = (len(data) - 4) // entry_size @@ -148,7 +225,7 @@ def _apply_char_bindings(char_cfg: dict, bindings: dict) -> None: def apply_key_bindings(config_instance) -> None: d2r_path = config_instance.general.get("d2r_path", "") - char_name = config_instance.general.get("name", "") + char_name = config_instance.general.get("char_name") or config_instance.general.get("name", "") if not char_name: return @@ -182,3 +259,77 @@ def apply_key_bindings(config_instance) -> None: ) Logger.info("Key auto-detection complete.") + + +def validate_key_bindings(config_instance, routes=None) -> bool: + d2r_path = config_instance.general.get("d2r_path", "") + char_name = config_instance.general.get("char_name") or config_instance.general.get("name", "") + key_file = _find_key_file(d2r_path, char_name) + if key_file is None: + Logger.error(f"Key preflight failed: no D2R key file found for '{char_name}'. Set [general] char_name.") + return False + + skills, non_skill_keys, char_bindings = _parse_binary(key_file) + detected_skill_keys = {_normalize_key(v) for v in skills.values()} + detected_non_skill_keys = {_normalize_key(v) for v in non_skill_keys} + configured_char_keys = { + key: _normalize_key(str(config_instance.char.get(key, ""))) + for key in ( + "force_move", "show_items", "show_belt", "stand_still", + "potion1", "potion2", "potion3", "potion4", "town_portal", "teleport", + "weapon_switch", "battle_command", "battle_orders", + ) + } + Logger.info(f"Key preflight using: {key_file}") + Logger.info(f"Detected skill hotkeys: {sorted(detected_skill_keys)}") + Logger.info(f"Detected character key bindings: {char_bindings}") + + errors = [] + warnings = [] + for key in ("force_move", "show_items", "show_belt", "stand_still", "potion1", "potion2", "potion3", "potion4", "town_portal"): + if not configured_char_keys[key]: + errors.append(f"[char] {key} is empty") + + char_type = config_instance.char.get("type", "") + required_build_skills = { + "blizz_sorc": ("blizzard",), + "blizzorb_sorc": ("blizzard", "glacial_spike"), + "nova_sorc": ("nova",), + "hydra_sorc": ("hydra", "alt_attack"), + "light_sorc": ("lightning",), + } + section_cfg = getattr(config_instance, char_type, None) + if section_cfg: + for skill in required_build_skills.get(char_type, ()): + hotkey = _normalize_key(str(section_cfg.get(skill, ""))) + if not hotkey: + errors.append(f"[{char_type}] {skill} is empty") + elif hotkey not in detected_skill_keys: + errors.append(f"[{char_type}] {skill}={hotkey!r} is not bound as a skill in {os.path.basename(key_file)}") + for skill, hotkey in section_cfg.items(): + hotkey = _normalize_key(str(hotkey)) + if hotkey and hotkey in detected_non_skill_keys: + errors.append(f"[{char_type}] {skill}={hotkey!r} is detected as a non-skill key") + + if config_instance.char.get("cta_available"): + missing_cta = [ + key for key in ("battle_command", "battle_orders") + if configured_char_keys[key] and configured_char_keys[key] not in detected_skill_keys + ] + if missing_cta: + warnings.append( + "CTA is enabled but these CTA skill hotkeys are not detected as skill keys: " + + ", ".join(missing_cta) + + ". Disabling CTA for this session." + ) + config_instance.char["cta_available"] = False + + for warning in warnings: + Logger.warning(f"Key preflight: {warning}") + for error in errors: + Logger.error(f"Key preflight: {error}") + if errors: + Logger.error("Key preflight failed. Fix D2R key bindings or config before running.") + return False + Logger.info("Key preflight passed.") + return True diff --git a/src/utils/skill_hotkey_setter.py b/src/utils/skill_hotkey_setter.py new file mode 100644 index 0000000..4d44764 --- /dev/null +++ b/src/utils/skill_hotkey_setter.py @@ -0,0 +1,177 @@ +from dataclasses import dataclass + +from config import Config +from input_layer import keyboard, mouse +from logger import Logger +from screen import convert_screen_to_monitor, grab +import template_finder +from utils.misc import wait +from utils.skill_preflight import ( + SORC_TEMPLATE_ALIASES, + SkillCheck, + _check_skill_icon, + get_build_skill_checks, +) + + +MENU_TEMPLATES = ["SAVE_AND_EXIT_NO_HIGHLIGHT", "SAVE_AND_EXIT_HIGHLIGHT"] +PICKER_TEMPLATE_ALIASES = { + "blizzard": ("PICKER_BLIZZARD", "BLIZZARD"), + "frozen_armor": ("PICKER_FROZENARMOR", "PICKER_FROZEN_ARMOR", "FROZENARMOR", "FROZEN_ARMOR"), +} + + +@dataclass(frozen=True) +class SkillBindResult: + skill: str + hotkey: str + side: str + template: str + bound: bool + verified: bool + reason: str = "" + + +def _skill_roi(side: str) -> list[int]: + return Config().ui_roi["skill_left" if side == "left" else "skill_right"] + + +def _expanded_roi(side: str) -> list[int] | None: + key = f"skill_{side}_expanded" + if key in Config().ui_roi: + return Config().ui_roi[key] + return Config().ui_roi.get("skill_right_expanded") + + +def _first_existing_template(check: SkillCheck) -> str | None: + templates = template_finder.stored_templates() + template_names = PICKER_TEMPLATE_ALIASES.get(check.skill, SORC_TEMPLATE_ALIASES.get(check.skill, (check.template,))) + for template_name in template_names: + if template_name in templates: + return template_name + return None + + +def is_ingame_menu_open() -> bool: + return template_finder.search( + MENU_TEMPLATES, + grab(force_new=True), + threshold=0.85, + roi=Config().ui_roi["save_and_exit"], + best_match=True, + ).valid + + +def is_skill_picker_open() -> bool: + return template_finder.search( + "BIND_SKILL", + grab(force_new=True), + threshold=0.8, + roi=Config().ui_roi["bind_skill"], + use_grayscale=True, + ).valid + + +def _open_skill_picker(side: str) -> None: + if is_ingame_menu_open(): + raise RuntimeError("in-game escape menu is open") + already_open = is_skill_picker_open() + Logger.info(f"Skill setter: picker already open={already_open}") + if not already_open: + Logger.info("Skill setter: sending 's' to open skill picker") + keyboard.send("s") + wait(0.25, 0.35) + Logger.info(f"Skill setter: picker open after 's' = {is_skill_picker_open()}") + clear_x = Config().ui_pos["screen_width"] // 2 + clear_y = Config().ui_pos["screen_height"] // 3 + clear_mx, clear_my = convert_screen_to_monitor((clear_x, clear_y)) + mouse.move(clear_mx, clear_my, randomize=0) + wait(0.12, 0.18) + if is_ingame_menu_open(): + raise RuntimeError("in-game escape menu opened while trying to open skill picker") + + +def bind_skill_hotkey(check: SkillCheck, threshold: float = 0.78) -> SkillBindResult: + Logger.info(f"Skill setter: binding {check.skill} -> {check.hotkey} (side={check.side})") + + if not check.hotkey: + Logger.warning(f"Skill setter: {check.skill} skipped — no hotkey configured") + return SkillBindResult(check.skill, check.hotkey, check.side, check.template, False, False, "no hotkey configured") + + template_name = _first_existing_template(check) + if template_name is None: + Logger.error(f"Skill setter: {check.skill} — template missing (checked {PICKER_TEMPLATE_ALIASES.get(check.skill, (check.template,))})") + return SkillBindResult(check.skill, check.hotkey, check.side, check.template, False, False, "template missing") + + expanded_roi = _expanded_roi(check.side) + if expanded_roi is None: + Logger.error(f"Skill setter: {check.skill} — expanded skill ROI missing for side={check.side}") + return SkillBindResult(check.skill, check.hotkey, check.side, template_name, False, False, "expanded skill ROI missing") + + Logger.info(f"Skill setter: opening picker (side={check.side})") + try: + _open_skill_picker(check.side) + except RuntimeError as error: + Logger.error(f"Skill setter: {check.skill} — could not open picker: {error}") + return SkillBindResult(check.skill, check.hotkey, check.side, template_name, False, False, str(error)) + + if not is_skill_picker_open(): + Logger.error(f"Skill setter: {check.skill} — picker did not open after sending open key") + return SkillBindResult(check.skill, check.hotkey, check.side, template_name, False, False, "skill picker did not open") + + Logger.info(f"Skill setter: picker open — searching for {template_name} in ROI {expanded_roi}") + match = template_finder.search(template_name, grab(force_new=True), threshold=threshold, roi=expanded_roi) + if not match.valid: + Logger.error(f"Skill setter: {check.skill} — {template_name} not visible in picker (threshold={threshold})") + if is_skill_picker_open(): + keyboard.send("s") + wait(0.20, 0.30) + return SkillBindResult(check.skill, check.hotkey, check.side, template_name, False, False, "skill not visible in picker") + + Logger.info(f"Skill setter: found {template_name} at {match.center_monitor} — moving mouse") + mouse.move(*match.center_monitor) + wait(0.08, 0.12) + Logger.info(f"Skill setter: pressing hotkey {check.hotkey}") + keyboard.send(check.hotkey) + wait(0.15, 0.25) + if is_skill_picker_open(): + keyboard.send("s") + wait(0.20, 0.30) + verified, _selected_template, score = _check_skill_icon(check) + Logger.info(f"Skill setter: post-bind verify {check.skill} -> {'OK' if verified else 'FAILED'} (score={score:.2f})") + return SkillBindResult( + check.skill, + check.hotkey, + check.side, + template_name, + True, + bool(verified), + "" if verified else "selected skill visual verification failed", + ) + + +def bind_build_hotkeys( + config_instance: Config, + char_type: str | None = None, + required_only: bool = False, + only_skill: str | None = None, +) -> list[SkillBindResult]: + char_type = char_type or config_instance.char.get("type", "") + checks = get_build_skill_checks(config_instance, char_type) + if required_only: + checks = [check for check in checks if check.required] + if only_skill: + checks = [check for check in checks if check.skill == only_skill] + + results = [] + Logger.info(f"Skill hotkey setter: build={char_type}, required_only={required_only}, only_skill={only_skill}") + for check in checks: + result = bind_skill_hotkey(check) + results.append(result) + if result.bound and result.verified: + Logger.info(f"Skill hotkey setter: {result.skill} -> {result.hotkey} verified") + elif result.bound: + Logger.error(f"Skill hotkey setter: {result.skill} -> {result.hotkey} bound but not verified: {result.reason}") + else: + Logger.error(f"Skill hotkey setter: {result.skill} -> {result.hotkey} not bound: {result.reason}") + return results diff --git a/src/utils/skill_preflight.py b/src/utils/skill_preflight.py new file mode 100644 index 0000000..ff62fa5 --- /dev/null +++ b/src/utils/skill_preflight.py @@ -0,0 +1,154 @@ +from dataclasses import dataclass + +from config import Config +from input_layer import keyboard +from logger import Logger +from screen import grab +import template_finder +from utils.key_detector import _normalize_key +from utils.misc import wait + + +@dataclass(frozen=True) +class SkillCheck: + build: str + skill: str + hotkey: str + template: str + side: str + required: bool + + +SORC_TEMPLATE_ALIASES = { + "blizzard": ("BLIZZARD",), + "ice_blast": ("ICE_BLAST", "ICEBLAST"), + "static_field": ("STATIC_FIELD", "STATICFIELD"), + "frozen_armor": ("FROZEN_ARMOR", "FROZENARMOR"), + "energy_shield": ("ENERGY_SHIELD", "ENERGYSHIELD"), + "telekinesis": ("TELEKINESIS",), + "thunder_storm": ("THUNDER_STORM", "THUNDERSTORM"), +} + + +def _configured_hotkey(section: dict, skill: str) -> str: + return _normalize_key(str(section.get(skill, ""))) + + +def _first_existing_template(template_names: tuple[str, ...]) -> str | None: + templates = template_finder.stored_templates() + for template_name in template_names: + if template_name in templates: + return template_name + return None + + +def get_build_skill_checks(config_instance: Config, char_type: str | None = None) -> list[SkillCheck]: + char_type = char_type or config_instance.char.get("type", "") + checks = [] + + if char_type != "blizz_sorc": + return checks + + build_cfg = getattr(config_instance, "blizz_sorc", {}) + blizzard_key = _configured_hotkey(build_cfg, "blizzard") + checks.append(SkillCheck(char_type, "blizzard", blizzard_key, "BLIZZARD", "right", True)) + + optional_skills = { + "ice_blast": (build_cfg, "right"), + "static_field": (build_cfg, "right"), + "frozen_armor": (build_cfg, "right"), + "energy_shield": (build_cfg, "right"), + "telekinesis": (build_cfg, "right"), + "thunder_storm": (build_cfg, "right"), + } + for skill, (section, side) in optional_skills.items(): + hotkey = _configured_hotkey(section, skill) + if hotkey: + template = SORC_TEMPLATE_ALIASES[skill][0] + checks.append(SkillCheck(char_type, skill, hotkey, template, side, False)) + return checks + + +def _skill_roi(side: str, pad: int = 6) -> list[int]: + x, y, w, h = Config().ui_roi["skill_left" if side == "left" else "skill_right"] + return [max(0, x - pad), max(0, y - pad), w + pad * 2, h + pad * 2] + + +def _check_skill_icon(check: SkillCheck, threshold: float = 0.84) -> tuple[bool | None, str, float]: + template_name = _first_existing_template(SORC_TEMPLATE_ALIASES.get(check.skill, (check.template,))) + if template_name is None: + return None, check.template, -1.0 + + keyboard.send(check.hotkey) + wait(0.15, 0.25) + roi = _skill_roi(check.side) + match = template_finder.search(template_name, grab(force_new=True), threshold=threshold, roi=roi) + return match.valid, template_name, match.score + + +def _close_skill_picker_if_open() -> None: + if "BIND_SKILL" not in template_finder.stored_templates(): + return + if template_finder.search( + "BIND_SKILL", + grab(force_new=True), + threshold=0.8, + roi=Config().ui_roi["bind_skill"], + use_grayscale=True, + ).valid: + keyboard.send("s") + wait(0.20, 0.30) + + +def validate_build_skill_icons(config_instance: Config, char_type: str | None = None) -> bool: + char_type = char_type or config_instance.char.get("type", "") + checks = get_build_skill_checks(config_instance, char_type) + if not checks: + Logger.debug(f"Skill visual preflight skipped: no build rules for {char_type!r}") + return True + + Logger.info( + "Skill visual preflight: " + f"character={config_instance.general.get('char_name') or config_instance.general.get('name')}, " + f"build={char_type}" + ) + _close_skill_picker_if_open() + + errors = [] + for check in checks: + if not check.hotkey: + if check.required: + errors.append(f"{check.skill} has no configured hotkey") + continue + + matched, template_name, score = _check_skill_icon(check) + if matched is None: + Logger.warning( + "Skill visual preflight: " + f"{check.skill} key={check.hotkey} side={check.side} template={template_name} missing; " + "skipping visual match" + ) + continue + + score_text = f"{score * 100:.1f}%" if score >= 0 else "n/a" + status = "ok" if matched else "failed" + Logger.info( + "Skill visual preflight: " + f"{check.skill} key={check.hotkey} side={check.side} template={template_name} " + f"visual={status} score={score_text}" + ) + if not matched: + severity = "required" if check.required else "configured" + errors.append( + f"{severity} skill {check.skill} did not select {template_name} " + f"on {check.side} skill icon (key={check.hotkey}, score={score_text})" + ) + + for error in errors: + Logger.error(f"Skill visual preflight: {error}") + if errors: + Logger.error("Skill visual preflight failed. Fix D2R skill hotkeys or recapture templates before running.") + return False + + Logger.info("Skill visual preflight passed.") + return True diff --git a/test/test_skill_preflight.py b/test/test_skill_preflight.py new file mode 100644 index 0000000..f482af7 --- /dev/null +++ b/test/test_skill_preflight.py @@ -0,0 +1,48 @@ +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from utils.skill_preflight import get_build_skill_checks + + +class DummyConfig: + char = {"type": "blizz_sorc"} + blizz_sorc = { + "blizzard": "f1", + "ice_blast": "", + "energy_shield": "f3", + "frozen_armor": "f4", + "static_field": "f5", + "telekinesis": "f6", + "thunder_storm": "", + } + + +def test_blizz_sorc_skill_checks(): + checks = get_build_skill_checks(DummyConfig()) + by_skill = {check.skill: check for check in checks} + + assert by_skill["blizzard"].required is True + assert by_skill["blizzard"].side == "right" + assert by_skill["blizzard"].hotkey == "f1" + + assert "ice_blast" not in by_skill + assert by_skill["energy_shield"].required is False + assert by_skill["frozen_armor"].side == "right" + assert by_skill["static_field"].hotkey == "f5" + assert by_skill["telekinesis"].hotkey == "f6" + + +def test_blizz_sorc_optional_attack_is_right_skill(): + config = DummyConfig() + config.blizz_sorc = dict(DummyConfig.blizz_sorc) + config.blizz_sorc["ice_blast"] = "f2" + + by_skill = {check.skill: check for check in get_build_skill_checks(config)} + + assert by_skill["ice_blast"].side == "right" + + +def test_unknown_build_has_no_checks(): + assert get_build_skill_checks(DummyConfig(), "hammerdin") == [] diff --git a/tools/capture_sorc_skill_icons.py b/tools/capture_sorc_skill_icons.py new file mode 100644 index 0000000..2285166 --- /dev/null +++ b/tools/capture_sorc_skill_icons.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python +import argparse +import os +import sys + +import cv2 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from config import Config +from input_layer import keyboard +from screen import find_and_set_window_position, grab +import template_finder +from utils.misc import cut_roi, wait +from utils.skill_preflight import get_build_skill_checks + + +OUTPUT_DIR = os.path.join("assets", "templates", "ui", "skills") + + +def _target_filename(skill: str) -> str: + if skill == "frozen_armor": + return "frozenarmor.png" + return f"{skill}.png" + + +def capture_sorc_icons(build: str, overwrite: bool, dry_run: bool) -> int: + config = Config() + checks = get_build_skill_checks(config, build) + if not checks: + print(f"No capture rules for build {build!r}") + return 1 + + print(f"Character: {config.general.get('char_name') or config.general.get('name')}") + print(f"Build: {build}") + for check in checks: + target = os.path.join(OUTPUT_DIR, _target_filename(check.skill)) + print(f"{check.skill:14s} key={check.hotkey or '':5s} side={check.side:5s} -> {target}") + + if dry_run: + return 0 + + os.makedirs(OUTPUT_DIR, exist_ok=True) + find_and_set_window_position() + for check in checks: + if not check.hotkey: + print(f"Skipping {check.skill}: no hotkey configured") + continue + + target = os.path.join(OUTPUT_DIR, _target_filename(check.skill)) + if os.path.exists(target) and not overwrite: + print(f"Skipping {check.skill}: {target} exists (use --overwrite)") + continue + + print(f"Selecting {check.skill} with {check.hotkey}...") + keyboard.send(check.hotkey) + wait(0.25, 0.35) + roi = config.ui_roi["skill_left" if check.side == "left" else "skill_right"] + icon = cut_roi(grab(force_new=True), roi) + cv2.imwrite(target, icon) + print(f"Saved {target}") + + template_finder.stored_templates.cache_clear() + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Capture Sorceress skill icon templates from the D2R skill buttons.") + parser.add_argument("--build", default="blizz_sorc", choices=["blizz_sorc"]) + parser.add_argument("--overwrite", action="store_true") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + return capture_sorc_icons(args.build, args.overwrite, args.dry_run) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/inspect_key_bindings.py b/tools/inspect_key_bindings.py index cf8f80b..03b2235 100644 --- a/tools/inspect_key_bindings.py +++ b/tools/inspect_key_bindings.py @@ -29,9 +29,9 @@ test_cases = { 69: "e", # force_move 87: "w", # weapon_switch 16: "capslock", # stand_still - 256: "alt", # show_items - 52: "5", # teleport - 53: "6", # town_portal + 256: "left alt", # show_items + 52: "4", + 53: "5", 116: "f5", # conviction 117: "f6", # foh 118: "f7", # holy_bolt @@ -54,7 +54,7 @@ print(f" Result: {ok} passed, {fail} failed") print("\n2. Finding key file:") config = Config() d2r_path = config.general["d2r_path"] -char_name = config.general["name"] +char_name = config.general.get("char_name") or config.general["name"] print(f" D2R path: {d2r_path}") print(f" Character: {char_name}") diff --git a/tools/set_sorc_skill_hotkeys.py b/tools/set_sorc_skill_hotkeys.py new file mode 100644 index 0000000..f7f2544 --- /dev/null +++ b/tools/set_sorc_skill_hotkeys.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python +import argparse +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from config import Config +from screen import find_and_set_window_position +from utils.skill_preflight import get_build_skill_checks +from utils.skill_hotkey_setter import bind_build_hotkeys + + +def main() -> int: + parser = argparse.ArgumentParser(description="Set configured Sorceress skill hotkeys inside the D2R skill picker.") + parser.add_argument("--build", default="blizz_sorc", choices=["blizz_sorc"]) + parser.add_argument("--skill", help="Bind only one configured skill, e.g. blizzard or static_field") + parser.add_argument("--required-only", action="store_true") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + if args.dry_run: + checks = get_build_skill_checks(Config(), args.build) + if args.required_only: + checks = [check for check in checks if check.required] + if args.skill: + checks = [check for check in checks if check.skill == args.skill] + print("Skill hotkey setter dry run:") + for check in checks: + print(f" {check.skill:14s} key={check.hotkey:5s} side={check.side:5s} required={check.required}") + return 0 + + find_and_set_window_position() + results = bind_build_hotkeys(Config(), args.build, required_only=args.required_only, only_skill=args.skill) + failed = [result for result in results if not result.bound or not result.verified] + print("\nSkill hotkey setter report:") + for result in results: + status = "OK" if result.bound and result.verified else "FAIL" + reason = f" ({result.reason})" if result.reason else "" + print(f" {status:4s} {result.skill:14s} {result.hotkey:5s} {result.side:5s} {result.template}{reason}") + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main())