get_build_skill_checks() only knows hammerdin/fohdin/blizz_sorc. For any other build (basic, basic_ranged, ...) it returns an empty list, so the tool fell back to [char] keys alone and never checked the build's own hotkeys — e.g. a basic_ranged character reported only town_portal, leaving right_attack and buff_1 unverified. When no preflight rules exist, read the section named after the build type instead. Only applies when the rule-based lookup found nothing, so hammerdin and blizz_sorc are unaffected. Verified across three builds against real .keyo files: hammerdin (profile1) 10/10 ok blizz_sorc (ding) 7/7 ok basic_ranged (ding_level) 3/3 ok — was 1/3 Co-Authored-By: Claude Opus 5 <[email protected]>
168 lines
6.8 KiB
Python
168 lines
6.8 KiB
Python
"""
|
|
Verify (and optionally fix) the D2R Controls layer against config/params.ini.
|
|
|
|
Layer 1 of hotkeys: the Controls page (Options -> Controls) maps physical keys
|
|
to "Skill 1".."Skill 16" slots, persisted in the per-character .keyo file:
|
|
4-byte header + 137 x 10-byte entries + 2 trailing bytes
|
|
entry = [pad:u16, VK:u16, action:u16, pad:u16, slot:u16], action=1 = skill key
|
|
(see src/utils/key_detector.py, which the bot uses to read this at startup).
|
|
|
|
This tool checks that every skill hotkey configured in params.ini (f1, f8, b...)
|
|
is bound to SOME skill slot. With --fix it writes missing keys into free skill
|
|
slots (backup saved next to the file). D2R MUST BE CLOSED when fixing - the game
|
|
rewrites the file on exit and would clobber the change.
|
|
|
|
Usage (from repo root, botty env python):
|
|
python tools/set_controls_keyo.py # report only
|
|
python tools/set_controls_keyo.py --fix # write missing binds (D2R closed!)
|
|
"""
|
|
import os
|
|
import shutil
|
|
import struct
|
|
import sys
|
|
import time
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
|
|
from config import Config
|
|
from utils.key_detector import VK_MAP
|
|
|
|
NAME_TO_VK = {v: k for k, v in VK_MAP.items() if k < 256}
|
|
|
|
# Keys that live in [char] for every build. battle_orders/battle_command are
|
|
# CTA-only and are skipped unless cta_available is set, so a character without a
|
|
# Call to Arms does not report two permanent false "unbound" entries.
|
|
COMMON_SKILL_KEYS = ["teleport", "town_portal"]
|
|
CTA_SKILL_KEYS = ["battle_orders", "battle_command"]
|
|
|
|
|
|
def wanted_skill_keys() -> dict:
|
|
"""Map cfg_key -> key name for every skill the ACTIVE BUILD puts on the bar.
|
|
|
|
Build-specific skills are taken from skill_preflight, which already resolves
|
|
them out of the build's own config section ([hammerdin], [blizz_sorc],
|
|
[sorceress], ...). Hardcoding a single build's skill list here meant a sorc
|
|
was only ever checked for [char] keys, so blizzard/ice_blast/static_field
|
|
were silently never verified.
|
|
"""
|
|
cfg = Config()
|
|
wanted = {}
|
|
try:
|
|
from utils.skill_preflight import get_build_skill_checks
|
|
for check in get_build_skill_checks(cfg):
|
|
if check.hotkey:
|
|
wanted[check.skill] = check.hotkey
|
|
except Exception as exc:
|
|
print(f"warning: could not load build skill list ({exc}); checking [char] keys only")
|
|
|
|
if not wanted:
|
|
# Builds with no skill_preflight rules (basic, basic_ranged, ...) still
|
|
# keep their hotkeys in a section named after the build type. Without
|
|
# this, such a build is only ever checked for [char] keys.
|
|
build_cfg = getattr(cfg, str(cfg.char.get("type", "")), None) or {}
|
|
for name, value in build_cfg.items():
|
|
value = str(value).strip().lower()
|
|
if value:
|
|
wanted.setdefault(name, value)
|
|
|
|
common = list(COMMON_SKILL_KEYS)
|
|
if cfg.char.get("cta_available"):
|
|
common += CTA_SKILL_KEYS
|
|
for cfg_key in common:
|
|
key_name = str(cfg.char.get(cfg_key, "")).strip().lower()
|
|
if key_name:
|
|
wanted.setdefault(cfg_key, key_name)
|
|
return wanted
|
|
|
|
HEADER = 4
|
|
ENTRY = 10
|
|
UNBOUND_VK = 0xFFFF
|
|
|
|
|
|
def find_keyo() -> str:
|
|
saved = os.path.expanduser("~/Saved Games/Diablo II Resurrected")
|
|
files = [f for f in sorted(os.listdir(saved)) if f.lower().endswith(".keyo")]
|
|
if not files:
|
|
raise SystemExit(f"no .keyo file found in {saved}")
|
|
# Prefer the configured character's file — the same choice the bot makes.
|
|
# This must key off char_name (e.g. "fistman"), NOT name, which is the bot
|
|
# profile ("profile1") and matches no .keyo, silently falling through to
|
|
# files[0] — i.e. verifying, and with --fix WRITING, another character.
|
|
cfg = Config().general
|
|
char = str(cfg.get("char_name") or cfg.get("name") or "").strip().lower()
|
|
if char:
|
|
# D2R suffixes the file with an account id ("Fistman211469871.keyo"),
|
|
# so normalise both sides and prefix-match, as key_detector does.
|
|
norm_char = "".join(c for c in char if c.isalnum())
|
|
for f in files:
|
|
norm_file = "".join(c for c in os.path.splitext(f)[0].lower() if c.isalnum())
|
|
if norm_char and norm_file.startswith(norm_char):
|
|
return os.path.join(saved, f)
|
|
raise SystemExit(
|
|
f"no .keyo matching char_name {char!r} in {saved}; "
|
|
f"found: {', '.join(files)}. "
|
|
"Refusing to fall back to an arbitrary character's bindings."
|
|
)
|
|
return os.path.join(saved, files[0])
|
|
|
|
|
|
def read_entries(path: str) -> tuple[bytes, list[tuple[int, int, int, int, int]], bytes]:
|
|
# mirror key_detector._parse_binary: entries at offset 4, remainder = trailer
|
|
data = open(path, "rb").read()
|
|
n = (len(data) - HEADER) // ENTRY
|
|
entries = [struct.unpack_from("<5H", data, HEADER + i * ENTRY) for i in range(n)]
|
|
return data[:HEADER], entries, data[HEADER + n * ENTRY:]
|
|
|
|
|
|
def main():
|
|
fix = "--fix" in sys.argv
|
|
path = find_keyo()
|
|
header, entries, trailer = read_entries(path)
|
|
|
|
skill_slot_by_vk = {e[1]: e[4] for e in entries if e[2] == 1 and e[1] != UNBOUND_VK}
|
|
free = [i for i, e in enumerate(entries) if e[2] == 1 and e[1] == UNBOUND_VK]
|
|
|
|
wanted = wanted_skill_keys()
|
|
|
|
print(f"keyo: {path}")
|
|
print(f"build: {Config().char.get('type', '?')}")
|
|
missing = []
|
|
for cfg_key, key_name in wanted.items():
|
|
vk = NAME_TO_VK.get(key_name)
|
|
if vk is None:
|
|
print(f" {cfg_key:16s} [{key_name:>4s}] UNSUPPORTED key name")
|
|
continue
|
|
if vk in skill_slot_by_vk:
|
|
print(f" {cfg_key:16s} [{key_name:>4s}] ok (skill slot {skill_slot_by_vk[vk]})")
|
|
else:
|
|
print(f" {cfg_key:16s} [{key_name:>4s}] NOT bound to any skill slot")
|
|
missing.append((cfg_key, key_name, vk))
|
|
|
|
if not missing:
|
|
print("\nControls layer matches params.ini.")
|
|
return
|
|
if not fix:
|
|
print(f"\n{len(missing)} key(s) unbound. Re-run with --fix while D2R is CLOSED to write them.")
|
|
return
|
|
|
|
import psutil
|
|
if any(p.name().lower() == "d2r.exe" for p in psutil.process_iter(["name"])):
|
|
raise SystemExit("D2R is running - close it first (it rewrites the keyo on exit).")
|
|
if len(free) < len(missing):
|
|
raise SystemExit(f"only {len(free)} free skill slots for {len(missing)} keys - free some in-game.")
|
|
|
|
backup = path + f".bak_{time.strftime('%Y%m%d_%H%M%S')}"
|
|
shutil.copyfile(path, backup)
|
|
for (cfg_key, key_name, vk), idx in zip(missing, free):
|
|
pad0, _vk, action, pad1, slot = entries[idx]
|
|
entries[idx] = (pad0, vk, action, pad1, slot)
|
|
print(f" bound {key_name} -> skill slot {slot} (for {cfg_key})")
|
|
body = b"".join(struct.pack("<HHHHH", *e) for e in entries)
|
|
with open(path, "wb") as f:
|
|
f.write(header + body + trailer)
|
|
print(f"\nwritten. backup: {backup}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|