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 <[email protected]>
This commit is contained in:
alex
2026-05-24 19:26:00 +02:00
co-authored by Claude Sonnet 4.6
parent a77e87e138
commit dc224bec5c
17 changed files with 671 additions and 17 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+2 -2
View File
@@ -15,7 +15,7 @@ bnet_pass=
; Character name to auto-select from the character selection screen
; If empty, bot relies on the saved character template from previous sessions
char_name=
char_name=ZapZap
; messaging
custom_loot_message_hook=
@@ -128,7 +128,7 @@ belt_rows=4
casting_frames=8
cta_casting_frames=8
attack_frames=15
cta_available=1
cta_available=0
;Do we want to cast non-cta buffs (ex energy shield) with cta
buff_with_cta=1
+3 -1
View File
@@ -517,10 +517,12 @@ class Pather:
return (rel_loc[0] + pos_abs[0], rel_loc[1] + pos_abs[1])
def traverse_nodes_fixed(self, key: str | list[tuple[float, float]], char: IChar) -> 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]
+161 -10
View File
@@ -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
+177
View File
@@ -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
+154
View File
@@ -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
+48
View File
@@ -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") == []
+77
View File
@@ -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 '<empty>':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())
+4 -4
View File
@@ -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}")
+45
View File
@@ -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())