Two-layer keybind verification/setting driven by params.ini

Layer 1 (Controls page / .keyo): tools/set_controls_keyo.py parses the
character keyo (same binary format as utils/key_detector) and verifies every
skill hotkey in params.ini is bound to a skill slot; --fix writes missing keys
into free skill slots with a backup (D2R must be closed). Picks the configured
character file. Verified: fistman controls match params.

Layer 2 (skill assignment / picker): enabled the existing skill_hotkey_setter
machinery for the hammerdin build:
- skill_preflight: hammerdin build rules (blessed_hammer left/required,
  concentration right/required, redemption/vigor/conviction/holy_shield/
  teleport right/optional) + PALADIN_TEMPLATE_ALIASES, merged TEMPLATE_ALIASES.
- skill_hotkey_setter: paladin picker template aliases with slot-icon fallback.
- tools/set_binds_from_params.py: end-to-end runner - opens the in-game picker,
  binds each skill to its params hotkey, verifies via slot icon, reports
  match/mismatch per skill.
- tools/capture_skill_hotkeys.py now saves crops straight into
  assets/templates/ui/skills/ so captures immediately become live templates
  for both the preflight and the picker search.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
alexpolo1
2026-06-12 08:30:43 +02:00
parent 63248a9437
commit 77b35df26b
6 changed files with 848 additions and 194 deletions

View File

@@ -1,10 +1,10 @@
{
"generated_at": "2026-06-10T07:33:03.259243+00:00",
"generated_at": "2026-06-12T06:22:04.139244+00:00",
"mode": "offline",
"offline_dir": "data\\d2jsp_pages",
"ladder_start_date": "2026-05-12",
"days": 21,
"topics_scanned": 879,
"ladder_start_date": "2026-06-05",
"days": 14,
"topics_scanned": 840,
"estimates": {
"day_1": {},
"day_2": {},
@@ -12,15 +12,7 @@
"day_4": {},
"day_5": {},
"day_6": {},
"day_7": {},
"day_8": {},
"day_9": {},
"day_10": {},
"day_11": {},
"day_12": {},
"day_13": {},
"day_14": {},
"day_15": {
"day_7": {
"CTA": {
"median_fg": 100.0,
"avg_fg": 358.0,
@@ -35,62 +27,13 @@
"max_fg": 600.0,
"samples": 3
},
"Gul Rune": {
"median_fg": 25.0,
"avg_fg": 63.1,
"min_fg": 5.0,
"max_fg": 200.0,
"samples": 8
},
"Dual Leech Ring": {
"median_fg": 85.0,
"avg_fg": 85.0,
"min_fg": 70.0,
"max_fg": 100.0,
"samples": 2
},
"Pul Rune": {
"median_fg": 60.0,
"avg_fg": 72.1,
"min_fg": 5.0,
"max_fg": 190.0,
"samples": 7
},
"Um Rune": {
"median_fg": 75.0,
"avg_fg": 99.6,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 10
},
"Sorc Torch": {
"median_fg": 100.0,
"avg_fg": 100.0,
"min_fg": 100.0,
"max_fg": 100.0,
"samples": 1
},
"Lem Rune": {
"median_fg": 60.0,
"avg_fg": 72.1,
"min_fg": 5.0,
"max_fg": 190.0,
"samples": 7
},
"Lo Rune": {
"median_fg": 25.0,
"avg_fg": 38.8,
"min_fg": 5.0,
"Aldur's Advance": {
"median_fg": 27.5,
"avg_fg": 43.8,
"min_fg": 20.0,
"max_fg": 100.0,
"samples": 4
},
"Stealth RW": {
"median_fg": 55.0,
"avg_fg": 55.0,
"min_fg": 10.0,
"max_fg": 100.0,
"samples": 2
},
"Ist Rune": {
"median_fg": 95.0,
"avg_fg": 112.2,
@@ -98,6 +41,20 @@
"max_fg": 400.0,
"samples": 12
},
"Um Rune": {
"median_fg": 75.0,
"avg_fg": 99.6,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 10
},
"Gul Rune": {
"median_fg": 25.0,
"avg_fg": 63.1,
"min_fg": 5.0,
"max_fg": 200.0,
"samples": 8
},
"Mal Rune": {
"median_fg": 100.0,
"avg_fg": 112.0,
@@ -105,13 +62,34 @@
"max_fg": 400.0,
"samples": 13
},
"Aldur's Advance": {
"median_fg": 27.5,
"avg_fg": 43.8,
"min_fg": 20.0,
"Stealth RW": {
"median_fg": 55.0,
"avg_fg": 55.0,
"min_fg": 10.0,
"max_fg": 100.0,
"samples": 2
},
"Dual Leech Ring": {
"median_fg": 85.0,
"avg_fg": 85.0,
"min_fg": 70.0,
"max_fg": 100.0,
"samples": 2
},
"Lo Rune": {
"median_fg": 25.0,
"avg_fg": 38.8,
"min_fg": 5.0,
"max_fg": 100.0,
"samples": 4
},
"Sorc Torch": {
"median_fg": 100.0,
"avg_fg": 100.0,
"min_fg": 100.0,
"max_fg": 100.0,
"samples": 1
},
"Cham Rune": {
"median_fg": 30.0,
"avg_fg": 121.0,
@@ -119,82 +97,19 @@
"max_fg": 450.0,
"samples": 5
},
"Shako": {
"median_fg": 25.0,
"avg_fg": 91.0,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 6
},
"Skin": {
"median_fg": 20.0,
"avg_fg": 18.3,
"Lem Rune": {
"median_fg": 60.0,
"avg_fg": 72.1,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
"max_fg": 190.0,
"samples": 7
},
"Vex Rune": {
"median_fg": 20.0,
"avg_fg": 18.3,
"Pul Rune": {
"median_fg": 60.0,
"avg_fg": 72.1,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Black": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"BK Ring": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Jah Rune": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Key": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Oculus": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Ohm Rune": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"White": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Moser": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
"max_fg": 190.0,
"samples": 7
},
"Bone": {
"median_fg": 20.0,
@@ -203,41 +118,6 @@
"max_fg": 30.0,
"samples": 3
},
"Vampire Gaze": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Spirit": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Ber Rune": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Insight": {
"median_fg": 25.0,
"avg_fg": 91.0,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 6
},
"Monarch": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Java SK": {
"median_fg": 20.0,
"avg_fg": 18.3,
@@ -245,14 +125,119 @@
"max_fg": 30.0,
"samples": 3
},
"5/5 Facet": {
"median_fg": 90.0,
"avg_fg": 163.7,
"min_fg": 1.0,
"max_fg": 400.0,
"Moser": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Mara": {
"Black": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Key": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Shako": {
"median_fg": 25.0,
"avg_fg": 91.0,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 6
},
"Vex Rune": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Ohm Rune": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Insight": {
"median_fg": 25.0,
"avg_fg": 91.0,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 6
},
"White": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Ber Rune": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Skin": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Vampire Gaze": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Jah Rune": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Monarch": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Oculus": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"BK Ring": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"Spirit": {
"median_fg": 20.0,
"avg_fg": 18.3,
"min_fg": 5.0,
"max_fg": 30.0,
"samples": 3
},
"5/5 Facet": {
"median_fg": 90.0,
"avg_fg": 163.7,
"min_fg": 1.0,
@@ -265,13 +250,470 @@
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 3
},
"Mara": {
"median_fg": 90.0,
"avg_fg": 163.7,
"min_fg": 1.0,
"max_fg": 400.0,
"samples": 3
}
},
"day_16": {},
"day_17": {},
"day_18": {},
"day_19": {},
"day_20": {},
"day_21": {}
"day_8": {
"Pul Rune": {
"median_fg": 15.0,
"avg_fg": 59.5,
"min_fg": 1.0,
"max_fg": 1425.0,
"samples": 75
},
"Highlord": {
"median_fg": 25.0,
"avg_fg": 82.8,
"min_fg": 5.0,
"max_fg": 1000.0,
"samples": 61
},
"Sacred": {
"median_fg": 30.0,
"avg_fg": 138.4,
"min_fg": 5.0,
"max_fg": 1750.0,
"samples": 149
},
"Griffon": {
"median_fg": 20.0,
"avg_fg": 115.4,
"min_fg": 1.0,
"max_fg": 1600.0,
"samples": 77
},
"Giant": {
"median_fg": 20.0,
"avg_fg": 134.6,
"min_fg": 5.0,
"max_fg": 1750.0,
"samples": 84
},
"Ist Rune": {
"median_fg": 20.0,
"avg_fg": 89.6,
"min_fg": 1.0,
"max_fg": 1200.0,
"samples": 135
},
"Key": {
"median_fg": 20.0,
"avg_fg": 58.6,
"min_fg": 3.0,
"max_fg": 470.0,
"samples": 44
},
"Shako": {
"median_fg": 20.0,
"avg_fg": 83.0,
"min_fg": 1.0,
"max_fg": 1200.0,
"samples": 122
},
"Arach": {
"median_fg": 42.5,
"avg_fg": 113.7,
"min_fg": 5.0,
"max_fg": 1200.0,
"samples": 96
},
"Vex Rune": {
"median_fg": 30.0,
"avg_fg": 185.2,
"min_fg": 1.0,
"max_fg": 1750.0,
"samples": 106
},
"Ohm Rune": {
"median_fg": 20.0,
"avg_fg": 61.8,
"min_fg": 1.0,
"max_fg": 480.0,
"samples": 76
},
"Um Rune": {
"median_fg": 25.0,
"avg_fg": 122.3,
"min_fg": 1.0,
"max_fg": 1750.0,
"samples": 160
},
"Pcomb SK": {
"median_fg": 35.0,
"avg_fg": 110.9,
"min_fg": 3.0,
"max_fg": 1200.0,
"samples": 93
},
"White": {
"median_fg": 20.0,
"avg_fg": 48.6,
"min_fg": 5.0,
"max_fg": 1600.0,
"samples": 94
},
"Lem Rune": {
"median_fg": 12.0,
"avg_fg": 25.8,
"min_fg": 1.0,
"max_fg": 160.0,
"samples": 92
},
"Skin": {
"median_fg": 25.0,
"avg_fg": 123.4,
"min_fg": 2.0,
"max_fg": 1750.0,
"samples": 103
},
"Vampire Gaze": {
"median_fg": 50.0,
"avg_fg": 423.9,
"min_fg": 5.0,
"max_fg": 1750.0,
"samples": 36
},
"Gul Rune": {
"median_fg": 24.0,
"avg_fg": 93.3,
"min_fg": 1.0,
"max_fg": 1750.0,
"samples": 122
},
"Monarch": {
"median_fg": 20.0,
"avg_fg": 109.0,
"min_fg": 5.0,
"max_fg": 1750.0,
"samples": 212
},
"Oculus": {
"median_fg": 20.0,
"avg_fg": 155.7,
"min_fg": 5.0,
"max_fg": 600.0,
"samples": 27
},
"Lo Rune": {
"median_fg": 30.0,
"avg_fg": 149.5,
"min_fg": 1.0,
"max_fg": 1500.0,
"samples": 96
},
"Unid Torch": {
"median_fg": 20.0,
"avg_fg": 134.3,
"min_fg": 5.0,
"max_fg": 550.0,
"samples": 39
},
"BK Ring": {
"median_fg": 25.0,
"avg_fg": 97.2,
"min_fg": 3.0,
"max_fg": 1750.0,
"samples": 117
},
"Viper Gorge": {
"median_fg": 49.0,
"avg_fg": 134.9,
"min_fg": 1.0,
"max_fg": 1750.0,
"samples": 145
},
"Mal Rune": {
"median_fg": 24.0,
"avg_fg": 64.0,
"min_fg": 1.0,
"max_fg": 500.0,
"samples": 110
},
"Mara": {
"median_fg": 60.0,
"avg_fg": 145.2,
"min_fg": 5.0,
"max_fg": 1200.0,
"samples": 67
},
"Crescent": {
"median_fg": 20.0,
"avg_fg": 44.3,
"min_fg": 5.0,
"max_fg": 180.0,
"samples": 60
},
"Thresher": {
"median_fg": 27.5,
"avg_fg": 130.0,
"min_fg": 3.0,
"max_fg": 1750.0,
"samples": 134
},
"Jah Rune": {
"median_fg": 90.0,
"avg_fg": 311.2,
"min_fg": 1.0,
"max_fg": 1750.0,
"samples": 106
},
"Bone": {
"median_fg": 50.0,
"avg_fg": 167.4,
"min_fg": 2.0,
"max_fg": 1750.0,
"samples": 239
},
"Treachery": {
"median_fg": 62.5,
"avg_fg": 112.9,
"min_fg": 10.0,
"max_fg": 1600.0,
"samples": 42
},
"Insight": {
"median_fg": 35.0,
"avg_fg": 112.8,
"min_fg": 5.0,
"max_fg": 1600.0,
"samples": 112
},
"Ber Rune": {
"median_fg": 90.0,
"avg_fg": 278.3,
"min_fg": 1.0,
"max_fg": 1600.0,
"samples": 87
},
"Guardian's Light": {
"median_fg": 50.0,
"avg_fg": 145.8,
"min_fg": 5.0,
"max_fg": 1750.0,
"samples": 245
},
"Enigma": {
"median_fg": 95.0,
"avg_fg": 324.4,
"min_fg": 30.0,
"max_fg": 1600.0,
"samples": 36
},
"Fortitude": {
"median_fg": 30.0,
"avg_fg": 102.2,
"min_fg": 5.0,
"max_fg": 1600.0,
"samples": 23
},
"Black": {
"median_fg": 39.5,
"avg_fg": 131.8,
"min_fg": 2.0,
"max_fg": 1750.0,
"samples": 116
},
"Cham Rune": {
"median_fg": 15.0,
"avg_fg": 65.1,
"min_fg": 1.0,
"max_fg": 700.0,
"samples": 44
},
"Spirit": {
"median_fg": 25.0,
"avg_fg": 105.2,
"min_fg": 1.0,
"max_fg": 1600.0,
"samples": 156
},
"5/5 Facet": {
"median_fg": 100.0,
"avg_fg": 263.5,
"min_fg": 10.0,
"max_fg": 1200.0,
"samples": 39
},
"War Traveler": {
"median_fg": 14.0,
"avg_fg": 239.7,
"min_fg": 10.0,
"max_fg": 1600.0,
"samples": 7
},
"CTA": {
"median_fg": 100.0,
"avg_fg": 275.5,
"min_fg": 10.0,
"max_fg": 1600.0,
"samples": 72
},
"Grief": {
"median_fg": 60.0,
"avg_fg": 139.6,
"min_fg": 15.0,
"max_fg": 1600.0,
"samples": 26
},
"Aldur's Advance": {
"median_fg": 50.0,
"avg_fg": 65.0,
"min_fg": 10.0,
"max_fg": 150.0,
"samples": 24
},
"Doom": {
"median_fg": 137.5,
"avg_fg": 245.8,
"min_fg": 50.0,
"max_fg": 550.0,
"samples": 24
},
"Wind": {
"median_fg": 40.0,
"avg_fg": 155.7,
"min_fg": 10.0,
"max_fg": 1600.0,
"samples": 58
},
"Cold SK": {
"median_fg": 100.0,
"avg_fg": 100.0,
"min_fg": 50.0,
"max_fg": 150.0,
"samples": 12
},
"Mithril": {
"median_fg": 4200.0,
"avg_fg": 3900.0,
"min_fg": 2100.0,
"max_fg": 5400.0,
"samples": 12
},
"Java SK": {
"median_fg": 100.0,
"avg_fg": 1325.3,
"min_fg": 2.0,
"max_fg": 5400.0,
"samples": 37
},
"Unid Anni": {
"median_fg": 39.0,
"avg_fg": 152.6,
"min_fg": 20.0,
"max_fg": 1200.0,
"samples": 40
},
"Fury": {
"median_fg": 75.0,
"avg_fg": 93.1,
"min_fg": 20.0,
"max_fg": 250.0,
"samples": 21
},
"Light SK": {
"median_fg": 120.0,
"avg_fg": 767.9,
"min_fg": 5.0,
"max_fg": 1800.0,
"samples": 7
},
"Stealth RW": {
"median_fg": 1600.0,
"avg_fg": 1600.0,
"min_fg": 1600.0,
"max_fg": 1600.0,
"samples": 1
},
"Ward": {
"median_fg": 50.0,
"avg_fg": 45.4,
"min_fg": 10.0,
"max_fg": 100.0,
"samples": 35
},
"Moser": {
"median_fg": 10.0,
"avg_fg": 13.3,
"min_fg": 10.0,
"max_fg": 20.0,
"samples": 12
},
"Blessed": {
"median_fg": 20.0,
"avg_fg": 23.3,
"min_fg": 10.0,
"max_fg": 50.0,
"samples": 24
},
"Fist": {
"median_fg": 10.0,
"avg_fg": 16.7,
"min_fg": 10.0,
"max_fg": 30.0,
"samples": 12
},
"Bulwark": {
"median_fg": 75.0,
"avg_fg": 64.0,
"min_fg": 20.0,
"max_fg": 75.0,
"samples": 5
},
"Sanctuary": {
"median_fg": 17.5,
"avg_fg": 30.0,
"min_fg": 10.0,
"max_fg": 75.0,
"samples": 16
},
"Sorc Torch": {
"median_fg": 20.0,
"avg_fg": 160.7,
"min_fg": 20.0,
"max_fg": 750.0,
"samples": 15
},
"Hellfire": {
"median_fg": 100.0,
"avg_fg": 216.7,
"min_fg": 50.0,
"max_fg": 750.0,
"samples": 12
},
"Light Jewel": {
"median_fg": 90.0,
"avg_fg": 65.5,
"min_fg": 10.0,
"max_fg": 100.0,
"samples": 42
},
"Troll": {
"median_fg": 39.0,
"avg_fg": 37.3,
"min_fg": 24.0,
"max_fg": 49.0,
"samples": 21
},
"Dual Leech Ring": {
"median_fg": 50.0,
"avg_fg": 80.0,
"min_fg": 10.0,
"max_fg": 180.0,
"samples": 12
}
},
"day_9": {},
"day_10": {},
"day_11": {},
"day_12": {},
"day_13": {},
"day_14": {}
}
}

View File

@@ -7,7 +7,7 @@ from screen import convert_screen_to_monitor, grab
import template_finder
from utils.misc import wait
from utils.skill_preflight import (
SORC_TEMPLATE_ALIASES,
TEMPLATE_ALIASES,
SkillCheck,
_check_skill_icon,
get_build_skill_checks,
@@ -18,6 +18,14 @@ 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"),
# paladin: dedicated PICKER_* captures win; slot-icon crops are the fallback
"blessed_hammer": ("PICKER_BLESSED_HAMMER", "BLESSED_HAMMER"),
"concentration": ("PICKER_CONCENTRATION", "CONCENTRATION"),
"redemption": ("PICKER_REDEMPTION", "REDEMPTION"),
"vigor": ("PICKER_VIGOR", "VIGOR"),
"conviction": ("PICKER_CONVICTION", "CONVICTION"),
"holy_shield": ("PICKER_HOLY_SHIELD", "HOLY_SHIELD"),
"teleport": ("PICKER_TELEPORT", "TELE_INACTIVE", "TELE_ACTIVE"),
}
@@ -45,7 +53,7 @@ def _expanded_roi(side: str) -> list[int] | None:
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,)))
template_names = PICKER_TEMPLATE_ALIASES.get(check.skill, TEMPLATE_ALIASES.get(check.skill, (check.template,)))
for template_name in template_names:
if template_name in templates:
return template_name

View File

@@ -29,6 +29,19 @@ SORC_TEMPLATE_ALIASES = {
"thunder_storm": ("THUNDER_STORM", "THUNDERSTORM"),
}
PALADIN_TEMPLATE_ALIASES = {
"blessed_hammer": ("BLESSED_HAMMER", "BLESSEDHAMMER"),
"concentration": ("CONCENTRATION",),
"redemption": ("REDEMPTION",),
"vigor": ("VIGOR",),
"conviction": ("CONVICTION",),
"holy_shield": ("HOLY_SHIELD", "HOLYSHIELD"),
"teleport": ("TELE_ACTIVE", "TELE_INACTIVE"),
}
# merged lookup used by icon checks; per-build dicts kept for clarity
TEMPLATE_ALIASES = {**SORC_TEMPLATE_ALIASES, **PALADIN_TEMPLATE_ALIASES}
def _configured_hotkey(section: dict, skill: str) -> str:
return _normalize_key(str(section.get(skill, "")))
@@ -46,6 +59,23 @@ def get_build_skill_checks(config_instance: Config, char_type: str | None = None
char_type = char_type or config_instance.char.get("type", "")
checks = []
if char_type == "hammerdin":
char_cfg = config_instance.char
# blessed hammer lives on left-click; auras/teleport go to right
checks.append(SkillCheck(char_type, "blessed_hammer",
_configured_hotkey(char_cfg, "blessed_hammer"),
"BLESSED_HAMMER", "left", True))
checks.append(SkillCheck(char_type, "concentration",
_configured_hotkey(char_cfg, "concentration"),
"CONCENTRATION", "right", True))
for skill, template in [("redemption", "REDEMPTION"), ("vigor", "VIGOR"),
("conviction", "CONVICTION"), ("holy_shield", "HOLY_SHIELD"),
("teleport", "TELE_ACTIVE")]:
hotkey = _configured_hotkey(char_cfg, skill)
if hotkey:
checks.append(SkillCheck(char_type, skill, hotkey, template, "right", False))
return checks
if char_type != "blizz_sorc":
return checks
@@ -75,7 +105,7 @@ def _skill_roi(side: str, pad: int = 6) -> list[int]:
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,)))
template_name = _first_existing_template(TEMPLATE_ALIASES.get(check.skill, (check.template,)))
if template_name is None:
return None, check.template, -1.0

View File

@@ -37,7 +37,10 @@ SKILLS = [
]
def main():
out_dir = os.path.join(os.path.dirname(__file__), "..", "assets", "templates", "skills_capture")
# save straight into the live template dir: crops auto-load as templates
# (CONCENTRATION etc.) for both the startup preflight verify and the
# skill_hotkey_setter picker fallback
out_dir = os.path.join(os.path.dirname(__file__), "..", "assets", "templates", "ui", "skills")
os.makedirs(out_dir, exist_ok=True)
print("Focus the D2R window (in a game). Capturing starts in 5s...")

View File

@@ -0,0 +1,57 @@
"""
Make the in-game skill assignment match config/params.ini (layer 2 of hotkeys).
For each skill in the build rules (utils/skill_preflight.get_build_skill_checks)
this opens the in-game skill picker ('s'), finds the skill icon by template,
hovers it and presses the hotkey from params.ini - that assigns the bind on the
character (server-side for online chars; no file can be edited instead). Each
bind is then verified by pressing the hotkey and matching the slot icon.
Prerequisites:
1. Controls layer verified: python tools/set_controls_keyo.py
2. Skill icon templates exist: run tools/capture_skill_hotkeys.py once with
correct binds, OR they ship in assets/templates/ui/skills/. Without a
template for a skill, that skill is reported as 'template missing'.
3. D2R in a game (town), nothing else on screen.
Usage (from repo root): <botty-env-python> tools/set_binds_from_params.py
"""
import os
import sys
import time
import ctypes
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
ctypes.windll.user32.SetProcessDPIAware()
from config import Config
from screen import start_detecting_window, stop_detecting_window
from utils.skill_preflight import get_build_skill_checks
from utils.skill_hotkey_setter import bind_skill_hotkey
def main():
print("Focus the D2R window (in a game). Binding starts in 5s...")
time.sleep(5)
start_detecting_window()
try:
checks = get_build_skill_checks(Config())
if not checks:
raise SystemExit(f"no build rules for char type {Config().char.get('type')!r}")
results = [bind_skill_hotkey(c) for c in checks]
finally:
stop_detecting_window()
print("\nResults:")
ok = True
for r in results:
state = "OK" if (r.bound and r.verified) else ("BOUND, UNVERIFIED" if r.bound else "FAILED")
ok &= r.bound and r.verified
print(f" {r.skill:16s} -> {r.hotkey:>4s} {state} {r.reason}")
print("\nAll binds match params.ini." if ok else
"\nSome binds failed - fix in-game or capture missing templates "
"(tools/capture_skill_hotkeys.py), then re-run.")
if __name__ == "__main__":
main()

114
tools/set_controls_keyo.py Normal file
View File

@@ -0,0 +1,114 @@
"""
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}
SKILL_KEYS = ["blessed_hammer", "concentration", "redemption", "vigor",
"conviction", "holy_shield", "teleport", "battle_orders", "battle_command"]
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 (same choice the bot makes)
char = str(Config().general.get("name", "")).strip().lower()
for f in files:
if char and f.lower().startswith(char):
return os.path.join(saved, f)
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 = {}
for cfg_key in SKILL_KEYS:
key_name = str(Config().char.get(cfg_key, "")).strip().lower()
if key_name:
wanted[cfg_key] = key_name
print(f"keyo: {path}")
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()