import configparser import string import threading import numpy as np import os from dataclasses import dataclass from logger import Logger config_lock = threading.Lock() from utils.misc import wait, only_lowercase_letters def _default_iff(value, iff, default = None): return default if value == iff else value class Config: data_loaded = False configs = {} # config data general = {} advanced_options = {} gamble = {} ui_roi = {} ui_pos = {} routes = {} routes_order = [] char = {} colors = {} shop = {} path = {} blizz_sorc = {} blizzorb_sorc = {} light_sorc = {} nova_sorc = {} hydra_sorc = {} hammerdin = {} fohdin = {} fire_lock = {} echo_lock = {} abyss_lock = {} javazon = {} trapsin = {} barbarian = {} poison_necro = {} bone_necro = {} necro = {} basic = {} basic_ranged = {} _instance = None def __new__(cls): if cls._instance is None: cls._instance = super(Config, cls).__new__(cls) with config_lock: if not cls._instance.data_loaded: cls._instance.data_loaded = True cls._instance.load_data() return cls._instance def _select_optional(self, section: string, key: string, default = None): try: return self._select_val(section=section, key=key) except: return default def _select_val(self, section: str, key: str = None): found_in = "" try: if section in self.configs["custom"]["parser"] and key in self.configs["custom"]["parser"][section]: found_val = self.configs["custom"]["parser"][section][key] found_in = "custom" elif section in self.configs["config"]["parser"]: found_val = self.configs["config"]["parser"][section][key] found_in = "config" elif section in self.configs["shop"]["parser"]: found_val = self.configs["shop"]["parser"][section][key] found_in = "shop" else: found_val = self.configs["game"]["parser"][section][key] found_in = "game" for var_name in self.configs[found_in]["vars"]: # set variable. if var_name in found_val: var_val = self.configs[found_in]["vars"][var_name] found_val = found_val.replace(var_name, var_val) return found_val except KeyError: Logger.error(f"Key '{key}' not found in section '{section}'") Logger.error("Closing in 10 seconds..") wait(10) os._exit(1) def _normalize_difficulty(self, value: str) -> str: normalized = (value or "").strip().lower() # Accept common variants/typos instead of silently falling through to Hell. if normalized in {"normal", "norm", "n"}: return "normal" if normalized in {"nightmare", "night", "nm", "nigthmare", "nigtmare"}: return "nightmare" if normalized in {"hell", "h"}: return "hell" Logger.warning(f"Unknown difficulty '{value}', defaulting to 'hell'") return "hell" def turn_off_goldpickup(self): Logger.info("All stash tabs and character are full of gold, turn off gold pickup") with config_lock: self.char["stash_gold"] = False def turn_on_goldpickup(self): Logger.info("All stash tabs and character are no longer full of gold. Turn gold stashing back on.") self.char["stash_gold"] = True def load_data(self): Logger.debug("Loading config") self.configs = { "config": {"parser": configparser.ConfigParser(), "vars": {}}, "game": {"parser": configparser.ConfigParser(), "vars": {}}, "shop": {"parser": configparser.ConfigParser(), "vars": {}}, "transmute": {"parser": configparser.ConfigParser(), "vars": {}}, "custom": {"parser": configparser.ConfigParser(), "vars": {}}, } self.configs["config"]["parser"].read('config/params.ini') self.configs["game"]["parser"].read('config/game.ini') self.configs["shop"]["parser"].read('config/shop.ini') self.configs["transmute"]["parser"].read('config/transmute.ini') if os.environ.get('RUN_ENV') != "test" and os.path.exists('config/custom.ini'): try: self.configs["custom"]["parser"].read('config/custom.ini') except configparser.MissingSectionHeaderError: Logger.error("custom.ini missing section header, defaulting to params.ini") for config_name in self.configs: config = self.configs[config_name] try: for var in config["parser"]["variables"]: config["vars"][var] = config["parser"]["variables"][var] # set var name to var value except KeyError: # "" header section was not found for this config file. pass self.general = { "saved_games_folder": self._select_val("general", "saved_games_folder"), "name": _default_iff(self._select_val("general", "name"), "", "botty"), "max_game_length_s": float(self._select_val("general", "max_game_length_s")), "max_consecutive_fails": int(self._select_val("general", "max_consecutive_fails")), "max_runtime_before_break_m": float(_default_iff(self._select_val("general", "max_runtime_before_break_m"), '', 0)), "break_length_m": float(_default_iff(self._select_val("general", "break_length_m"), '', 0)), "randomize_runs": bool(int(self._select_val("general", "randomize_runs"))), "difficulty": self._normalize_difficulty(self._select_val("general", "difficulty")), "message_api_type": self._select_val("general", "message_api_type"), "custom_message_hook": self._select_val("general", "custom_message_hook"), "custom_loot_message_hook": self._select_val("general", "custom_loot_message_hook"), "discord_status_count": False if not self._select_val("general", "discord_status_count") else int(self._select_val("general", "discord_status_count")), "discord_status_runs": False if not self._select_optional("general", "discord_status_runs", "") else int(self._select_optional("general", "discord_status_runs", "")), "discord_log_chicken": bool(int(self._select_val("general", "discord_log_chicken"))), "info_screenshots": bool(int(self._select_val("general", "info_screenshots"))), "pickit_screenshots": bool(int(self._select_val("general", "pickit_screenshots"))), "d2r_path": _default_iff(self._select_val("general", "d2r_path"), "", r"C:\Program Files (x86)\Diablo II Resurrected"), "restart_d2r_when_stuck": bool(int(self._select_val("general", "restart_d2r_when_stuck"))), "hardcore": bool(int(self._select_val("general", "hardcore"))), "auto_login": bool(int(self._select_val("general", "auto_login"))), "bnet_name": self._select_val("general", "bnet_name"), "bnet_pass": self._select_val("general", "bnet_pass"), "char_name": self._select_val("general", "char_name"), } self.stealth = { "wait_jitter_min": float(self._select_optional("stealth", "wait_jitter_min", "0.85")), "wait_jitter_max": float(self._select_optional("stealth", "wait_jitter_max", "1.20")), "click_variance": int(self._select_optional("stealth", "click_variance", "8")), "reshuffle_each_rotation": bool(int(self._select_optional("stealth", "reshuffle_each_rotation", "1"))), "skip_run_chance": int(self._select_optional("stealth", "skip_run_chance", "10")), "afk_break_chance": int(self._select_optional("stealth", "afk_break_chance", "5")), "afk_break_min_m": float(self._select_optional("stealth", "afk_break_min_m", "2")), "afk_break_max_m": float(self._select_optional("stealth", "afk_break_max_m", "12")), "run_duration_variance": float(self._select_optional("stealth", "run_duration_variance", "0.15")), "micro_pause_min_ms": int(self._select_optional("stealth", "micro_pause_min_ms", "20")), "micro_pause_max_ms": int(self._select_optional("stealth", "micro_pause_max_ms", "120")), "human_curve_complexity": float(self._select_optional("stealth", "human_curve_complexity", "1.0")), "vary_kill_time": bool(int(self._select_optional("stealth", "vary_kill_time", "1"))), } self.routes = {} order_str = self._select_val("routes", "order") self.routes_order = [x.strip() for x in order_str.split(",")] del self.configs["config"]["parser"]["routes"]["order"] for key in self.routes_order: self.routes[key] = True # Botty only knows "run_shenk" but in orders we split run_eldritch and run_eldritch_shenk self.routes_order = ["run_shenk" if x in ["run_eldritch", "run_eldritch_shenk"] else x for x in self.routes_order] self.char = { "type": self._select_val("char", "type"), "show_items": self._select_val("char", "show_items"), "inventory_screen": self._select_val("char", "inventory_screen"), "teleport": self._select_val("char", "teleport"), "stand_still": self._select_val("char", "stand_still"), "force_move": self._select_val("char", "force_move"), "num_loot_columns": int(self._select_val("char", "num_loot_columns")), "take_health_potion": float(self._select_val("char", "take_health_potion")), "take_mana_potion": float(self._select_val("char", "take_mana_potion")), "take_rejuv_potion_health": float(self._select_val("char", "take_rejuv_potion_health")), "take_rejuv_potion_mana": float(self._select_val("char", "take_rejuv_potion_mana")), "heal_merc": float(self._select_val("char", "heal_merc")), "heal_rejuv_merc": float(self._select_val("char", "heal_rejuv_merc")), "chicken": float(self._select_val("char", "chicken")), "merc_chicken": float(self._select_val("char", "merc_chicken")), "town_portal": self._select_val("char", "town_portal"), "belt_rows": int(self._select_val("char", "belt_rows")), "show_belt": self._select_val("char", "show_belt"), "potion1": self._select_val("char", "potion1"), "potion2": self._select_val("char", "potion2"), "potion3": self._select_val("char", "potion3"), "potion4": self._select_val("char", "potion4"), "belt_rejuv_columns": int(self._select_val("char", "belt_rejuv_columns")), "belt_hp_columns": int(self._select_val("char", "belt_hp_columns")), "belt_mp_columns": int(self._select_val("char", "belt_mp_columns")), "stash_gold": bool(int(self._select_val("char", "stash_gold"))), "use_merc": bool(int(self._select_val("char", "use_merc"))), "open_chests": bool(int(self._select_val("char", "open_chests"))), "fill_shared_stash_first": bool(int(self._select_val("char", "fill_shared_stash_first"))), "pre_buff_every_run": bool(int(self._select_val("char", "pre_buff_every_run"))), "cta_available": bool(int(self._select_val("char", "cta_available"))), "buff_with_cta": bool(int(self._select_val("char", "buff_with_cta"))), "weapon_switch": self._select_val("char", "weapon_switch"), "battle_orders": self._select_val("char", "battle_orders"), "battle_command": self._select_val("char", "battle_command"), "casting_frames": int(self._select_val("char", "casting_frames")), "cta_casting_frames": int(self._select_val("char", "cta_casting_frames")), "attack_frames": int(self._select_val("char", "attack_frames")), "atk_len_arc": float(self._select_val("char", "atk_len_arc")), "atk_len_trav": float(self._select_val("char", "atk_len_trav")), "atk_len_pindle": float(self._select_val("char", "atk_len_pindle")), "atk_len_eldritch": float(self._select_val("char", "atk_len_eldritch")), "atk_len_shenk": float(self._select_val("char", "atk_len_shenk")), "atk_len_nihlathak": float(self._select_val("char", "atk_len_nihlathak")), "atk_len_diablo_vizier": float(self._select_val("char", "atk_len_diablo_vizier")), "atk_len_diablo_deseis": float(self._select_val("char", "atk_len_diablo_deseis")), "atk_len_diablo_infector": float(self._select_val("char", "atk_len_diablo_infector")), "atk_len_diablo": float(self._select_val("char", "atk_len_diablo")), "atk_len_cs_trashmobs": float(self._select_val("char", "atk_len_cs_trashmobs")), "kill_cs_trash": bool(int(self._select_val("char", "kill_cs_trash"))), "cs_town_visits": bool(int(self._select_val("char", "cs_town_visits"))), "cs_mob_detect": bool(int(self._select_val("char", "cs_mob_detect"))), "runs_per_stash": False if not self._select_val("char", "runs_per_stash") else int(self._select_val("char", "runs_per_stash")), "runs_per_repair": False if not self._select_val("char", "runs_per_repair") else int(self._select_val("char", "runs_per_repair")), "gamble_items": False if not self._select_val("char", "gamble_items") else self._select_val("char", "gamble_items").replace(" ","").split(","), "sell_junk": bool(int(self._select_val("char", "sell_junk"))), "pick_rares_for_gold": bool(int(self._select_optional("char", "pick_rares_for_gold", "0"))), "pick_gold": bool(int(self._select_optional("char", "pick_gold", "1"))), "enable_no_pickup": bool(int(self._select_val("char", "enable_no_pickup"))), "safer_routines": bool(int(self._select_val("char", "safer_routines"))), } # Sorc base config sorc_base_cfg = dict(self.configs["config"]["parser"]["sorceress"]) if "sorceress" in self.configs["custom"]["parser"]: sorc_base_cfg.update(dict(self.configs["custom"]["parser"]["sorceress"])) # blizz sorc self.blizz_sorc = dict(self.configs["config"]["parser"]["blizz_sorc"]) if "blizz_sorc" in self.configs["custom"]["parser"]: self.blizz_sorc.update(dict(self.configs["custom"]["parser"]["blizz_sorc"])) self.blizz_sorc.update(sorc_base_cfg) # blizzorb sorc self.blizzorb_sorc = dict(self.configs["config"]["parser"]["blizzorb_sorc"]) if "blizzorb_sorc" in self.configs["custom"]["parser"]: self.blizzorb_sorc.update(dict(self.configs["custom"]["parser"]["blizzorb_sorc"])) self.blizzorb_sorc.update(sorc_base_cfg) # light sorc self.light_sorc = dict(self.configs["config"]["parser"]["light_sorc"]) if "light_sorc" in self.configs["custom"]["parser"]: self.light_sorc.update(dict(self.configs["custom"]["parser"]["light_sorc"])) self.light_sorc.update(sorc_base_cfg) # nova sorc self.nova_sorc = dict(self.configs["config"]["parser"]["nova_sorc"]) if "nova_sorc" in self.configs["custom"]["parser"]: self.nova_sorc.update(dict(self.configs["custom"]["parser"]["nova_sorc"])) self.nova_sorc.update(sorc_base_cfg) # hydra sorc self.hydra_sorc = dict(self.configs["config"]["parser"]["hydra_sorc"]) if "hydra_sorc" in self.configs["custom"]["parser"]: self.hydra_sorc.update(dict(self.configs["custom"]["parser"]["hydra_sorc"])) self.hydra_sorc.update(sorc_base_cfg) # Warlock base config warlock_base_cfg = dict(self.configs["config"]["parser"]["warlock"]) if "warlock" in self.configs["custom"]["parser"]: warlock_base_cfg.update(dict(self.configs["custom"]["parser"]["warlock"])) # fire warlock self.fire_lock = dict(self.configs["config"]["parser"]["fire_lock"]) if "fire_lock" in self.configs["custom"]["parser"]: self.fire_lock.update(dict(self.configs["custom"]["parser"]["fire_lock"])) self.fire_lock.update(warlock_base_cfg) # echo warlock self.echo_lock = dict(self.configs["config"]["parser"]["echo_lock"]) if "echo_lock" in self.configs["custom"]["parser"]: self.echo_lock.update(dict(self.configs["custom"]["parser"]["echo_lock"])) self.echo_lock.update(warlock_base_cfg) # abyss warlock self.abyss_lock = dict(self.configs["config"]["parser"]["abyss_lock"]) if "abyss_lock" in self.configs["custom"]["parser"]: self.abyss_lock.update(dict(self.configs["custom"]["parser"]["abyss_lock"])) self.abyss_lock.update(warlock_base_cfg) # Paladin base config paladin_base_cfg = dict(self.configs["config"]["parser"]["paladin"]) if "paladin" in self.configs["custom"]["parser"]: paladin_base_cfg.update(dict(self.configs["custom"]["parser"]["paladin"])) # Hammerdin config self.hammerdin = self.configs["config"]["parser"]["hammerdin"] if "hammerdin" in self.configs["custom"]["parser"]: self.hammerdin.update(self.configs["custom"]["parser"]["hammerdin"]) self.hammerdin.update(paladin_base_cfg) # FoHdin config self.fohdin = dict(self.configs["config"]["parser"]["fohdin"]) if "fohdin" in self.configs["custom"]["parser"]: self.fohdin.update(self.configs["custom"]["parser"]["fohdin"]) self.fohdin.update(paladin_base_cfg) # Amazon base config amazon_base_cfg = dict(self.configs["config"]["parser"]["amazon"]) if "amazon" in self.configs["custom"]["parser"]: amazon_base_cfg.update(dict(self.configs["custom"]["parser"]["amazon"])) # javazon self.javazon = dict(self.configs["config"]["parser"]["javazon"]) if "javazon" in self.configs["custom"]["parser"]: self.javazon.update(dict(self.configs["custom"]["parser"]["javazon"])) self.javazon.update(amazon_base_cfg) # Assassin config self.trapsin = self.configs["config"]["parser"]["trapsin"] if "trapsin" in self.configs["custom"]["parser"]: self.trapsin.update(self.configs["custom"]["parser"]["trapsin"]) # Barbarian config self.barbarian = self.configs["config"]["parser"]["barbarian"] if "barbarian" in self.configs["custom"]["parser"]: self.barbarian.update(self.configs["custom"]["parser"]["barbarian"]) self.barbarian = dict(self.barbarian) self.barbarian["cry_frequency"] = float(self.barbarian["cry_frequency"]) # Basic config self.basic = self.configs["config"]["parser"]["basic"] if "basic" in self.configs["custom"]["parser"]: self.basic.update(self.configs["custom"]["parser"]["basic"]) # Basic Ranged config self.basic_ranged = self.configs["config"]["parser"]["basic_ranged"] if "basic_ranged" in self.configs["custom"]["parser"]: self.basic_ranged.update(self.configs["custom"]["parser"]["basic_ranged"]) # Necro config self.necro = self.configs["config"]["parser"]["necro"] if "necro" in self.configs["custom"]["parser"]: self.necro.update(self.configs["custom"]["parser"]["necro"]) self.poison_necro = self.configs["config"]["parser"]["poison_necro"] if "poison_necro" in self.configs["custom"]["parser"]: self.poison_necro.update(self.configs["custom"]["parser"]["poison_necro"]) self.bone_necro = self.configs["config"]["parser"]["bone_necro"] if "bone_necro" in self.configs["custom"]["parser"]: self.bone_necro.update(self.configs["custom"]["parser"]["bone_necro"]) self.advanced_options = { "pathing_delay_factor": min(max(int(self._select_val("advanced_options", "pathing_delay_factor")), 1), 10), "message_headers": self._select_val("advanced_options", "message_headers"), "message_body_template": self._select_val("advanced_options", "message_body_template"), "graphic_debugger_layer_creator": bool(int(self._select_val("advanced_options", "graphic_debugger_layer_creator"))), "logg_lvl": self._select_val("advanced_options", "logg_lvl"), "exit_key": self._select_val("advanced_options", "exit_key"), "resume_key": self._select_val("advanced_options", "resume_key"), "auto_settings_key": self._select_val("advanced_options", "auto_settings_key"), "restore_settings_from_backup_key": self._select_val("advanced_options", "restore_settings_from_backup_key"), "settings_backup_key": self._select_val("advanced_options", "settings_backup_key"), "select_runs_key": self._select_val("advanced_options", "select_runs_key"), "graphic_debugger_key": self._select_val("advanced_options", "graphic_debugger_key"), "hwnd_window_title": _default_iff(Config()._select_val("advanced_options", "hwnd_window_title"), ''), "hwnd_window_process": _default_iff(Config()._select_val("advanced_options", "hwnd_window_process"), ''), "window_client_area_offset": tuple(map(int, Config()._select_val("advanced_options", "window_client_area_offset").split(","))), "ocr_during_pickit": bool(int(self._select_val("advanced_options", "ocr_during_pickit"))), "launch_options": self._build_launch_options(), "override_capabilities": _default_iff(Config()._select_optional("advanced_options", "override_capabilities"), ""), } self.colors = {} for key in self.configs["game"]["parser"]["colors"]: self.colors[key] = np.split(np.array([int(x) for x in self._select_val("colors", key).split(",")]), 2) self.ui_pos = {} for key in self.configs["game"]["parser"]["ui_pos"]: self.ui_pos[key] = int(self._select_val("ui_pos", key)) self.ui_roi = {} for key in self.configs["game"]["parser"]["ui_roi"]: self.ui_roi[key] = np.array([int(x) for x in self._select_val("ui_roi", key).split(",")]) open_width = int(self.ui_pos["slot_width"] * self.char["num_loot_columns"]) # calc roi for restricted inventory area self.ui_roi["restricted_inventory_area"] = self.ui_roi["right_inventory"].copy() self.ui_roi["restricted_inventory_area"][0] += open_width # left self.ui_roi["restricted_inventory_area"][2] -= open_width # width # calc roi for open inventory area self.ui_roi["open_inventory_area"] = self.ui_roi["right_inventory"].copy() self.ui_roi["open_inventory_area"][2] = open_width # width self.path = {} for key in self.configs["game"]["parser"]["path"]: self.path[key] = np.reshape(np.array([int(x) for x in self._select_val("path", key).split(",")]), (-1, 2)) self.shop = { "shop_trap_claws": bool(int(self._select_val("claws", "shop_trap_claws"))), "shop_melee_claws": bool(int(self._select_val("claws", "shop_melee_claws"))), "shop_3_skills_ias_gloves": bool(int(self._select_val("gloves", "shop_3_skills_ias_gloves"))), "shop_2_skills_ias_gloves": bool(int(self._select_val("gloves", "shop_2_skills_ias_gloves"))), "trap_min_score": int(self._select_val("claws", "trap_min_score")), "melee_min_score": int(self._select_val("claws", "melee_min_score")), "shop_hammerdin_scepters": bool(int(self._select_val("scepters", "shop_hammerdin_scepters"))), "speed_factor": float(self._select_val("scepters", "speed_factor")), "apply_pather_adjustment": bool(int(self._select_val("scepters", "apply_pather_adjustment"))), } # Auto-detect key bindings from D2R .key file try: from utils.key_detector import apply_key_bindings apply_key_bindings(self) except Exception as e: Logger.debug(f"Key auto-detection skipped: {e}") stash_destination_str = self._select_val("transmute","stash_destination") transmute_str = self._select_val("transmute","transmute") self.configs["transmute"]["parser"] = { "stash_destination": [int(x.strip()) for x in stash_destination_str.split(",")], "transmute_every_x_game": self._select_val("transmute","transmute_every_x_game"), "transmute": [x.strip() for x in transmute_str.split(",")], } def _build_launch_options(self) -> str: """Build D2R launch options, appending auto-login flags when bnet credentials are set.""" opts = self._select_val("advanced_options", "launch_options").replace( "", only_lowercase_letters(self.general["name"].lower()) ) if self.general.get("auto_login", False): bnet_name = self.general.get("bnet_name", "") bnet_pass = self.general.get("bnet_pass", "") if bnet_name and bnet_pass: opts += f" -bnetname {bnet_name} -bnetpass {bnet_pass}" return opts if __name__ == "__main__": from copy import deepcopy config = Config()