Files
my-botty/tools/set_controls_keyo.py
alexpolo1andClaude Opus 5 81c8d65375 fix(set_controls_keyo): also cover builds without skill_preflight rules
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]>
2026-08-22 19:44:46 +02:00

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()