From a09fcee6e80451f560f0cc19f642e8bbd1b8edb1 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sun, 1 Feb 2026 16:04:57 +0100 Subject: [PATCH 01/16] Apply d4lf patch --- README.md | 52 ++- src/config/models.py | 12 + src/gui/activity_log_widget.py | 11 +- src/gui/importer/d4builds.py | 16 + src/gui/importer/importer_config.py | 1 + src/gui/importer/maxroll.py | 17 +- src/gui/importer/mobalytics.py | 30 ++ src/gui/importer/paragon_export.py | 678 ++++++++++++++++++++++++++++ src/gui/importer_window.py | 9 + src/gui/unified_window.py | 54 ++- src/main.py | 29 +- src/paragon_overlay.py | 639 ++++++++++++++++++++++++++ src/scripts/handler.py | 84 ++++ 13 files changed, 1623 insertions(+), 9 deletions(-) create mode 100644 src/gui/importer/paragon_export.py create mode 100644 src/paragon_overlay.py diff --git a/README.md b/README.md index a5c44c98..c40e9e2c 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,9 @@ feature request or issue reports join the [discord](https://discord.gg/YyzaPhAN6 - Automatically marks all common, magic, and optionally rare gear as junk - Quickly move items from your stash or inventory - Supported resolutions are all aspect ratios between 16:10 and 21:9 +- Export Paragon boards to overlay-compatible JSON from supported build planners (optional) +- Integrated Paragon Overlay (toggle via hotkey; supports loading a single JSON file or a folder) +- Multi-build Paragon overlay: load multiple JSONs and switch builds inside the overlay ## How to Setup @@ -47,6 +50,43 @@ feature request or issue reports join the [discord](https://discord.gg/YyzaPhAN6 - Use the hotkeys listed in d4lf.exe to run filtering. By default, F11 will run the loot filter and filter your items. - For most common issues, if something is wrong, you will see an error or warning when you start d4lf.exe. Join our [discord](https://discord.gg/YyzaPhAN6T) for more help. +#### Paragon Import/Export + Integrated Paragon Overlay (this fork) + +This fork adds Paragon board export during build import and an integrated Win32 overlay to display Paragon boards in-game. + +This is **best-effort** scraping: planner websites can change their HTML and break an importer. If an import suddenly fails, it usually needs a scraper update. + +Exported JSON uses **friendly slugs** (Mobalytics-style, class-prefixed where possible) for board and glyph identifiers to keep names consistent across sources. + +**Export Paragon JSON while importing a build** + +- Open **Import** in the GUI. +- Paste a build link (Mobalytics / Maxroll / D4Builds). +- Enable **"Export Paragon JSON"**. +- Click **Generate**. +- The JSON is written to **`/paragon`** (default: `C:/Users//.d4lf/paragon`). + +**Select your Paragon JSON folder** + +- In the main GUI, click **"Paragon Folder"**. +- Choose the folder that contains your `*.json` Paragon files. +- This sets `advanced_options.paragon_overlay_source_dir` in `params.ini`. + +**Toggle the overlay** + +- Press **F10** (config key: `advanced_options.toggle_paragon_overlay`). + - First press: starts the overlay + - Second press: closes the overlay + +**Run overlay directly (optional CLI mode)** + +- `d4lf.exe --paragon-overlay "C:\\path\\to\\file.json"` +- `d4lf.exe --paragon-overlay "C:\\path\\to\\folder_with_jsons"` + +Notes: +- Borderless windowed is recommended; exclusive fullscreen overlays may not show. +- If the overlay opens off-screen, delete `d4_overlay_config.json` next to `d4lf.exe` to reset window position. + ### Updating an existing installation All configurations are stored in a separate location so all you need to do is download the newest version and delete your old version. This can be done manually by downloading from the [releases page](https://github.com/d4lfteam/d4lf/releases) or by running autoupdater.bat. @@ -59,6 +99,12 @@ Example 2: You're on version 5.1.14 and updating to 6.0.0. Your profiles will no ### Common problems +- Paragon overlay does not appear / does nothing + - Ensure Diablo IV is running in **borderless windowed** (exclusive fullscreen may block overlays). + - Ensure your Paragon folder contains `*.json` files (default: `C:/Users//.d4lf/paragon`). + - Check/adjust `advanced_options.toggle_paragon_overlay` (default `f10`) and ensure it is not conflicting with other hotkeys. + - If the overlay is off-screen, delete `d4_overlay_config.json` next to `d4lf.exe` to reset its position. + - The GUI crashes immediately upon opening, with no error message given - This almost always means there is an issue in your params.ini. Delete the file and then open the GUI and configure your params.ini through the config tab. Using the GUI for configuration will ensure the file is always accurate. @@ -90,6 +136,7 @@ The config folder in `C:/Users//.d4lf` contains: automatically. - **params.ini**: Different hotkey settings and number of chest stashes that should be looked at. Management of this file should be done through the GUI in the config window. +- **paragon/\*.json**: Paragon builds for the integrated overlay. Generated by the importers when "Export Paragon JSON" is enabled. Default location: `C:/Users//.d4lf/paragon` ### params.ini @@ -126,6 +173,8 @@ The config folder in `C:/Users//.d4lf` contains: | run_vision_mode | Hotkey to start/stop vision mode | | force_refresh_only | Hotkey to reset all item statuses without running a filter after | | exit_key | Hotkey to exit d4lf.exe | +| toggle_paragon_overlay | Hotkey to open/close the Paragon overlay (default: f10) | +| paragon_overlay_source_dir | Folder containing Paragon JSON files for the overlay. Leave blank to use the default: `~/.d4lf/paragon` | | log_lvl | Logging level. Can be any of [debug, info, warning, error, critical] | | process_name | Process name of the D4 app. Defaults to "Diablo IV.exe". In case of using some remote play this might need to be adapted | | vision_mode_only | If set to true, only the vision mode will be available. All functionality that clicks the screen is disabled. | @@ -143,7 +192,8 @@ automatically picked up and no restart is necessary. Current functionality: -- Import builds from maxroll/d4builds/mobalytics +- Import builds from maxroll/d4builds/mobalytics (optionally export Paragon JSON) +- Toggle the integrated Paragon overlay (default hotkey: F10) and configure its JSON folder via "Paragon Folder" - Complete management of your settings through the config tab - A beta version of a manual profile editor/creator diff --git a/src/config/models.py b/src/config/models.py index f41e25f8..ee5653ed 100644 --- a/src/config/models.py +++ b/src/config/models.py @@ -180,6 +180,16 @@ class AdvancedOptionsModel(_IniBaseModel): description="If TTS is working for you but you are still receiving the warning, check this box to disable it.", ) exit_key: str = Field(default="f12", description="Hotkey to exit d4lf", json_schema_extra={IS_HOTKEY_KEY: "True"}) + toggle_paragon_overlay: str = Field( + default="f10", + description="Hotkey to open/close the Paragon overlay", + json_schema_extra={IS_HOTKEY_KEY: "True"}, + ) + paragon_overlay_source_dir: str = Field( + default="", + description="Folder containing Paragon JSON files for the Paragon overlay. Leave blank to use the default: ~/.d4lf/paragon", + json_schema_extra={HIDE_FROM_GUI_KEY: "True"}, + ) fast_vision_mode_coordinates: tuple[int, int] | None = Field( default=None, description="The top left coordinates of the desired location of the fast vision mode overlay in pixels. For example: (300, 500). Set to blank for default behavior.", @@ -223,6 +233,7 @@ class AdvancedOptionsModel(_IniBaseModel): def key_must_be_unique(self) -> AdvancedOptionsModel: keys = [ self.exit_key, + self.toggle_paragon_overlay, self.force_refresh_only, self.move_to_chest, self.move_to_inv, @@ -237,6 +248,7 @@ def key_must_be_unique(self) -> AdvancedOptionsModel: @field_validator( "exit_key", + "toggle_paragon_overlay", "force_refresh_only", "move_to_chest", "move_to_inv", diff --git a/src/gui/activity_log_widget.py b/src/gui/activity_log_widget.py index dd653462..ed834ebc 100644 --- a/src/gui/activity_log_widget.py +++ b/src/gui/activity_log_widget.py @@ -33,7 +33,7 @@ def __init__(self, parent=None): config = IniConfigLoader() hotkey_text = QLabel() - hotkey_text.setMaximumHeight(65) + hotkey_text.setMaximumHeight(85) hotkey_text.setWordWrap(True) hotkey_text.setTextFormat(Qt.TextFormat.RichText) hotkey_text.setStyleSheet("margin-left: 5px;") @@ -55,6 +55,7 @@ def __init__(self, parent=None): hotkeys_html += f"{config.advanced_options.run_vision_mode.upper()}: Run/Stop Vision Mode
" hotkeys_html += "Vision Mode Only - clicking functionality disabled   " + hotkeys_html += f"{config.advanced_options.toggle_paragon_overlay.upper()}: Toggle Paragon Overlay   " hotkeys_html += f"{config.advanced_options.exit_key.upper()}: Exit D4LF" hotkeys_html += "" @@ -77,9 +78,15 @@ def __init__(self, parent=None): self.editor_btn.setMinimumHeight(40) button_layout.addWidget(self.editor_btn) + self.paragon_overlay_btn = QPushButton("Paragon Folder") + self.paragon_overlay_btn.setMinimumHeight(40) + self.paragon_overlay_btn.setToolTip("Select the folder containing Paragon JSON files for the overlay") + button_layout.addWidget(self.paragon_overlay_btn) + # === CONNECT BUTTONS TO UnifiedMainWindow === self.import_btn.clicked.connect(self.parent().open_import_dialog) self.settings_btn.clicked.connect(self.parent().open_settings_dialog) self.editor_btn.clicked.connect(self.parent().open_profile_editor) + self.paragon_overlay_btn.clicked.connect(self.parent().open_paragon_overlay) - self.main_layout.addLayout(button_layout) + self.main_layout.addLayout(button_layout) \ No newline at end of file diff --git a/src/gui/importer/d4builds.py b/src/gui/importer/d4builds.py index d786cd4c..b59c2827 100644 --- a/src/gui/importer/d4builds.py +++ b/src/gui/importer/d4builds.py @@ -212,6 +212,21 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): corrected_file_name = save_as_profile(file_name=file_name, profile=profile, url=url) if config.add_to_profiles: add_to_profiles(corrected_file_name) + + if config.export_paragon: + from src.gui.importer.paragon_export import extract_d4builds_paragon_steps, export_paragon_build_json + + steps = extract_d4builds_paragon_steps(driver, class_name=class_name) + if steps: + export_paragon_build_json( + file_stem=f"{corrected_file_name}_paragon", + build_name=file_name, + source_url=url, + paragon_boards_list=steps, + ) + else: + LOGGER.warning("Paragon export enabled, but no paragon data was found on this D4Builds page.") + LOGGER.info("Finished") @@ -276,6 +291,7 @@ def _get_legendary_aspects(data: lxml.html.HtmlElement) -> list[str]: add_to_profiles=False, import_greater_affixes=True, require_greater_affixes=True, + export_paragon=False, custom_file_name=None, ) import_d4builds(config) diff --git a/src/gui/importer/importer_config.py b/src/gui/importer/importer_config.py index c10d8581..5c6f4385 100644 --- a/src/gui/importer/importer_config.py +++ b/src/gui/importer/importer_config.py @@ -9,4 +9,5 @@ class ImportConfig: add_to_profiles: bool import_greater_affixes: bool require_greater_affixes: bool + export_paragon: bool custom_file_name: str | None diff --git a/src/gui/importer/maxroll.py b/src/gui/importer/maxroll.py index e7ed9de5..05304997 100644 --- a/src/gui/importer/maxroll.py +++ b/src/gui/importer/maxroll.py @@ -184,6 +184,20 @@ def import_maxroll(config: ImportConfig): if config.add_to_profiles: add_to_profiles(corrected_file_name) + if config.export_paragon: + from src.gui.importer.paragon_export import extract_maxroll_paragon_steps, export_paragon_build_json + + steps = extract_maxroll_paragon_steps(active_profile) + if steps: + export_paragon_build_json( + file_stem=f"{corrected_file_name}_paragon", + build_name=build_name, + source_url=url, + paragon_boards_list=steps, + ) + else: + LOGGER.warning("Paragon export enabled, but no paragon steps were found in this Maxroll profile.") + LOGGER.info("Finished") @@ -396,6 +410,7 @@ def _extract_planner_url_and_id_from_guide(url: str) -> tuple[str, int]: add_to_profiles=False, import_greater_affixes=True, require_greater_affixes=True, + export_paragon=False, custom_file_name=None, ) - import_maxroll(config) + import_maxroll(config) \ No newline at end of file diff --git a/src/gui/importer/mobalytics.py b/src/gui/importer/mobalytics.py index 8bc73ee0..881608b5 100644 --- a/src/gui/importer/mobalytics.py +++ b/src/gui/importer/mobalytics.py @@ -98,6 +98,21 @@ def import_mobalytics(config: ImportConfig): f"$..['{root_document_name}'].data.buildVariants.values[0].id", full_script_data_json )[0] + # Keep the full build variant object around (needed for optional paragon export) + try: + if variant_id: + variant = jsonpath.findall( + f"$..['{root_document_name}'].data.buildVariants.values[?@.id=='{variant_id}']", + full_script_data_json, + )[0] + else: + variant = jsonpath.findall( + f"$..['{root_document_name}'].data.buildVariants.values[0]", + full_script_data_json, + )[0] + except Exception: + variant = {} + variant_name = jsonpath.findall( f"..['NgfDocumentCmWidgetContentVariantsV1DataChildVariant:{variant_id}'].title", full_script_data_json ) @@ -224,6 +239,20 @@ def import_mobalytics(config: ImportConfig): if config.add_to_profiles: add_to_profiles(corrected_file_name) + if config.export_paragon: + from src.gui.importer.paragon_export import extract_mobalytics_paragon_steps, export_paragon_build_json + + steps = extract_mobalytics_paragon_steps(variant if isinstance(variant, dict) else {}) + if steps: + export_paragon_build_json( + file_stem=f"{corrected_file_name}_paragon", + build_name=build_name, + source_url=url, + paragon_boards_list=steps, + ) + else: + LOGGER.warning("Paragon export enabled, but no paragon data was found for this Mobalytics variant.") + LOGGER.info("Finished") @@ -288,6 +317,7 @@ def _convert_raw_to_affixes(raw_stats: list[dict], import_greater_affixes=False) add_to_profiles=False, import_greater_affixes=True, require_greater_affixes=True, + export_paragon=False, custom_file_name=None, ) import_mobalytics(config) diff --git a/src/gui/importer/paragon_export.py b/src/gui/importer/paragon_export.py new file mode 100644 index 00000000..f2e038cf --- /dev/null +++ b/src/gui/importer/paragon_export.py @@ -0,0 +1,678 @@ +from __future__ import annotations + +import datetime +import json +import logging +import re +import time +from pathlib import Path +from typing import Any, TYPE_CHECKING + +from src import __version__ +from src.config.loader import IniConfigLoader + +if TYPE_CHECKING: + from selenium.webdriver.remote.webdriver import WebDriver + + +def _class_slug_from_name(class_name: str) -> str: + class_name = (class_name or "").strip().lower() + if not class_name or class_name == "unknown": + return "" + # normalize spaces/underscores + class_name = re.sub(r"[\s_]+", "-", class_name) + class_name = re.sub(r"[^a-z0-9\-]", "", class_name) + return class_name + + +def _prefix_with_class_slug(slug: str, class_slug: str) -> str: + if not slug: + return slug + if not class_slug: + return slug + if slug.startswith(class_slug + "-"): + return slug + return f"{class_slug}-{slug}" +LOGGER = logging.getLogger(__name__) + +GRID = 21 +NODES_LEN = GRID * GRID + + +# --------------------------------------------------------------------------- +# Maxroll ID -> human friendly names (ported from Diablo4Companion data files). +# Used to export Paragon JSON with readable identifiers (similar to Mobalytics). +# --------------------------------------------------------------------------- + +_MAXROLL_BOARD_ID_TO_NAME = { + "Paragon_Barb_00": "Start", + "Paragon_Barb_01": "Hemorrhage", + "Paragon_Barb_02": "Blood Rage", + "Paragon_Barb_03": "Carnage", + "Paragon_Barb_04": "Decimator", + "Paragon_Barb_05": "Bone Breaker", + "Paragon_Barb_06": "Flawless Technique", + "Paragon_Barb_07": "Warbringer", + "Paragon_Barb_08": "Weapons Master", + "Paragon_Barb_10": "Force of Nature", + "Paragon_Druid_00": "Start", + "Paragon_Druid_01": "Thunderstruck", + "Paragon_Druid_02": "Earthen Devastation", + "Paragon_Druid_03": "Survival Instincts", + "Paragon_Druid_04": "Lust for Carnage", + "Paragon_Druid_05": "Heightened Malice", + "Paragon_Druid_06": "Inner Beast", + "Paragon_Druid_07": "Constricting Tendrils", + "Paragon_Druid_08": "Ancestral Guidance", + "Paragon_Druid_10": "Untamed", + "Paragon_Necro_00": "Start", + "Paragon_Necro_01": "Cult Leader", + "Paragon_Necro_02": "Hulking Monstrosity", + "Paragon_Necro_03": "Flesh-eater", + "Paragon_Necro_04": "Scent of Death", + "Paragon_Necro_05": "Bone Graft", + "Paragon_Necro_06": "Blood Begets Blood", + "Paragon_Necro_07": "Bloodbath", + "Paragon_Necro_08": "Wither", + "Paragon_Necro_10": "Frailty", + "Paragon_Paladin_00": "Start", + "Paragon_Paladin_01": "Castle", + "Paragon_Paladin_02": "Shield Bearer", + "Paragon_Paladin_03": "Fervent", + "Paragon_Paladin_04": "Preacher", + "Paragon_Paladin_05": "Divinity", + "Paragon_Paladin_06": "Relentless", + "Paragon_Paladin_07": "Sentencing", + "Paragon_Paladin_08": "Endure", + "Paragon_Paladin_09": "Beacon", + "Paragon_Rogue_00": "Start", + "Paragon_Rogue_01": "Eldritch Bounty", + "Paragon_Rogue_02": "Tricks of the Trade", + "Paragon_Rogue_03": "Cheap Shot", + "Paragon_Rogue_04": "Deadly Ambush", + "Paragon_Rogue_05": "Leyrana's Instinct", + "Paragon_Rogue_06": "No Witnesses", + "Paragon_Rogue_07": "Exploit Weakness", + "Paragon_Rogue_08": "Cunning Stratagem", + "Paragon_Rogue_10": "Danse Macabre", + "Paragon_Sorc_00": "Start", + "Paragon_Sorc_01": "Searing Heat", + "Paragon_Sorc_02": "Frigid Fate", + "Paragon_Sorc_03": "Static Surge", + "Paragon_Sorc_04": "Elemental Summoner", + "Paragon_Sorc_05": "Burning Instinct", + "Paragon_Sorc_06": "Icefall", + "Paragon_Sorc_07": "Ceaseless Conduit", + "Paragon_Sorc_08": "Enchantment Master", + "Paragon_Sorc_10": "Fundamental Release", + "Paragon_Spirit_0": "Start", + "Paragon_Spirit_01": "In-Fighter", + "Paragon_Spirit_02": "Spiney Skin", + "Paragon_Spirit_03": "Viscous Shield", + "Paragon_Spirit_04": "Bitter Medicine", + "Paragon_Spirit_05": "Revealing", + "Paragon_Spirit_06": "Drive", + "Paragon_Spirit_07": "Convergence", + "Paragon_Spirit_08": "Sapping", +} + +_MAXROLL_GLYPH_ID_TO_NAME = { + "Rare_001_Intelligence_Main": "Enchanter", + "Rare_002_Intelligence_Main": "Unleash", + "Rare_003_Intelligence_Main": "Elementalist", + "Rare_004_Intelligence_Main": "Adept", + "Rare_005_Intelligence_Main": "Conjurer", + "Rare_006_Intelligence_Main": "Charged", + "Rare_007_Willpower_Side": "Torch", + "Rare_008_Willpower_Side": "Pyromaniac", + "Rare_009_Willpower_Side": "Cryopathy", + "Rare_010_Dexterity_Main": "Tactician", + "Rare_011_Intelligence_Side": "Guzzler", + "Rare_011_Willpower_Side": "Imbiber", + "Rare_012_Intelligence_Side": "Protector", + "Rare_012_Willpower_Side": "Reinforced", + "Rare_013_Dexterity_Side": "Poise", + "Rare_014_Dexterity_Side": "Territorial", + "Rare_014_Strength_Main": "Turf", + "Rare_014_Strength_Side": "Turf", + "Rare_015_Dexterity_Side": "Flamefeeder", + "Rare_016_Dexterity_Side": "Exploit", + "Rare_016_Intelligence_Side": "Exploit", + "Rare_016_Strength_Side": "Exploit", + "Rare_017_Dexterity_Side": "Winter", + "Rare_018_Dexterity_Side": "Electrocute", + "Rare_019_Dexterity_Side": "Destruction", + "Rare_020_Dexterity_Side": "Control", + "Rare_020_Intelligence_Main": "Control", + "Rare_020_Intelligence_Side": "Control", + "Rare_021_Strength_Main": "Ambidextrous", + "Rare_022_Strength_Main": "Might", + "Rare_023_Strength_Main": "Cleaver", + "Rare_024_Strength_Main": "Seething", + "Rare_025_Strength_Main": "Crusher", + "Rare_026_Strength_Main": "Executioner", + "Rare_027_Strength_Main": "Ire", + "Rare_028_Strength_Main": "Marshal", + "Rare_029_Dexterity_Side": "Bloodfeeder", + "Rare_030_Dexterity_Side": "Wrath", + "Rare_031_Dexterity_Side": "Weapon Master", + "Rare_032_Dexterity_Side": "Mortal Draw", + "Rare_033_Intelligence_Side": "Revenge", + "Rare_033_Willpower_Side": "Revenge", + "Rare_033_Willpower_Side_Necro": "Revenge", + "Rare_034_Intelligence_Side": "Undaunted", + "Rare_034_Willpower_Side": "Undaunted", + "Rare_035_Intelligence_Side": "Dominate", + "Rare_035_Willpower_Side": "Dominate", + "Rare_035_Willpower_Side_Necro": "Dominate", + "Rare_036_Willpower_Side": "Disembowel", + "Rare_037_Willpower_Side": "Brawl", + "Rare_038_Intelligence_Main": "Corporeal", + "Rare_039_Willpower_Main": "Fang and Claw", + "Rare_040_Willpower_Main": "Earth and Sky", + "Rare_041_Intelligence_Side": "Wilds", + "Rare_042_Willpower_Main": "Werebear", + "Rare_043_Willpower_Main": "Werewolf", + "Rare_044_Willpower_Main": "Human", + "Rare_045_Intelligence_Side": "Bane", + "Rare_045_Strength_Side": "Bane", + "Rare_046_Dexterity_Side": "Abyssal", + "Rare_046_Intelligence_Side": "Keeper", + "Rare_047_Dexterity_Side": "Fulminate", + "Rare_047_Intelligence_Side": "Fulminate", + "Rare_048_Dexterity_Side": "Tracker", + "Rare_048_Intelligence_Side": "Tracker", + "Rare_049_Dexterity_Side": "Outmatch", + "Rare_049_Strength_Main": "Outmatch", + "Rare_049_Strength_Side": "Outmatch", + "Rare_050_Dexterity_Main": "Spirit", + "Rare_050_Dexterity_Side": "Spirit", + "Rare_050_Willpower_Side": "Spirit", + "Rare_051_Dexterity_Side": "Shapeshifter", + "Rare_052_Dexterity_Main": "Versatility", + "Rare_053_Dexterity_Main": "Closer", + "Rare_054_Dexterity_Main": "Ranger", + "Rare_055_Dexterity_Main": "Chip", + "Rare_055_Dexterity_Side": "Chip", + "Rare_055_Willpower_Side": "Chip", + "Rare_056_Dexterity_Main": "Frostfeeder", + "Rare_057_Dexterity_Main": "Fluidity", + "Rare_058_Intelligence_Side": "Infusion", + "Rare_059_Dexterity_Main": "Devious", + "Rare_060_Dexterity_Side": "Warrior", + "Rare_061_Intelligence_Side": "Combat", + "Rare_062_Dexterity_Side": "Gravekeeper", + "Rare_063_Intelligence_Side": "Canny", + "Rare_064_Intelligence_Side": "Efficacy", + "Rare_065_Intelligence_Side": "Snare", + "Rare_066_Dexterity_Side": "Essence", + "Rare_067_Strength_Side": "Pride", + "Rare_068_Strength_Side": "Ambush", + "Rare_069_Intelligence_Main": "Sacrificial", + "Rare_070_Intelligence_Main": "Blood-drinker", + "Rare_071_Intelligence_Main": "Deadraiser", + "Rare_072_Intelligence_Main": "Mage", + "Rare_073_Intelligence_Main": "Amplify", + "Rare_074_Willpower_Side": "Golem", + "Rare_075_Willpower_Side": "Scourge", + "Rare_076_Strength_Main": "Diminish", + "Rare_076_Strength_Side": "Diminish", + "Rare_077_Willpower_Side": "Warding", + "Rare_078_Willpower_Side": "Darkness", + "Rare_079_Dexterity_Side": "Exploit", + "Rare_080_Strength_Main": "Twister", + "Rare_081_Strength_Main": "Rumble", + "Rare_082_Dexterity_Main": "Explosive", + "Rare_083_Intelligence_Side": "Nightstalker", + "Rare_084_Intelligence_Main": "Stalagmite", + "Rare_085_Dexterity_Side": "Invocation", + "Rare_086_Dexterity_Side": "Tectonic", + "Rare_087_Willpower_Main": "Electrocution", + "Rare_088_Intelligence_Main": "Exhumation", + "Rare_089_Willpower_Side": "Desecration", + "Rare_090_Dexterity_Main": "Menagerist", + "Rare_091_Strength_Side": "Hone", + "Rare_092_Intelligence_Side": "Consumption", + "Rare_093_Dexterity_Main": "Fitness", + "Rare_094_Intelligence_Side": "Ritual", + "Rare_095_Dexterity_Main": "Jagged Plume", + "Rare_096_Strength_Side": "Innate", + "Rare_097_Dexterity_Main": "Wildfire", + "Rare_098_Strength_Side": "Colossal", + "Rare_100_Dexterity_Main": "Talon", + "Rare_101_Strength_Side": "Hubris", + "Rare_102_Dexterity_Main": "Fester", + "Rare_103_Strength_Main": "Sentinel", + "Rare_104_Dexterity_Side": "Honed", + "Rare_105_Strength_Main": "Law", + "Rare_106_Willpower_Side": "Arbiter ", + "Rare_107_Strength_Main": "Resplendence", + "Rare_108_Intelligence_Side": "Judicator", + "Rare_109_Dexterity_Side": "Feverous", + "Rare_110_Strength_Main": "Apostle", + "Rare_Dex_Generic": "Headhunter", + "Rare_Int_Generic": "Eliminator", + "Rare_Str_Generic": "Challenger", + "Rare_Will_Generic": "Headhunter", +} + + +def _slugify(s: str) -> str: + s = (s or "").strip().lower() + s = re.sub(r"[^a-z0-9]+", "-", s) + return s.strip("-") + + +def _maxroll_class_slug(board_id: str) -> str: + # Example: "Paragon_Paladin_02" -> "paladin" + m = re.match(r"^Paragon_([A-Za-z]+)_\d+$", board_id or "") + return _slugify(m.group(1)) if m else "" + + +def _maxroll_board_slug(board_id: str) -> str: + cls = _maxroll_class_slug(board_id) + name = _MAXROLL_BOARD_ID_TO_NAME.get(board_id, board_id) + name_slug = _slugify(name) + return f"{cls}-{name_slug}" if cls and name_slug else _slugify(board_id) + + +def _maxroll_glyph_slug(glyph_id: str, board_id: str) -> str: + # We prefix with class for consistency with Mobalytics output. + cls = _maxroll_class_slug(board_id) + name = _MAXROLL_GLYPH_ID_TO_NAME.get(glyph_id, glyph_id) + name_slug = _slugify(name) + return f"{cls}-{name_slug}" if cls and name_slug else _slugify(glyph_id) + + + +def export_paragon_build_json( + file_stem: str, + build_name: str, + source_url: str, + paragon_boards_list: list[list[dict[str, Any]]], +) -> Path: + """Write a D4Companion-compatible JSON containing Name + ParagonBoardsList. + + Output format is a JSON list with a single entry, so it can be consumed by tools that expect a list. + """ + out_dir = IniConfigLoader().user_dir / "paragon" + out_dir.mkdir(parents=True, exist_ok=True) + + out_path = out_dir / f"{file_stem}.json" + + payload = [ + { + "Name": build_name, + "Source": source_url, + "GeneratedAt": datetime.datetime.now(tz=datetime.UTC).strftime("%Y-%m-%d %H:%M:%S UTC"), + "Generator": f"d4lf v{__version__}", + "ParagonBoardsList": paragon_boards_list, + } + ] + + with out_path.open("w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + + LOGGER.info(f"Exported paragon JSON: {out_path}") + return out_path + + +def extract_maxroll_paragon_steps(active_profile: dict[str, Any]) -> list[list[dict[str, Any]]]: + """Extract paragon steps from Maxroll planner data. + + Matches the rotation + node-index transformation used in Diablo4Companion. + """ + steps_out: list[list[dict[str, Any]]] = [] + paragon = (active_profile or {}).get("paragon") or {} + steps = paragon.get("steps") or [] + + for step in steps: + boards_out: list[dict[str, Any]] = [] + for bd in (step or {}).get("data") or []: + board_id = (bd or {}).get("id", "") + glyph_id = (bd or {}).get("glyph", "") + rotation = int((bd or {}).get("rotation", 0)) + nodes_bool = [False] * NODES_LEN + + nodes_dict = (bd or {}).get("nodes") or {} + for loc_key in nodes_dict.keys(): + try: + loc = int(loc_key) + except Exception: + continue + idx = _transform_maxroll_location(loc=loc, rotation=rotation) + if 0 <= idx < NODES_LEN: + nodes_bool[idx] = True + + boards_out.append( + { + "Name": _maxroll_board_slug(board_id), + "Glyph": _maxroll_glyph_slug(glyph_id, board_id) if glyph_id else "", + "Rotation": _rotation_info_maxroll(rotation), + "Nodes": nodes_bool, + "BoardId": board_id, + "GlyphId": glyph_id, + } + ) + + if boards_out: + steps_out.append(boards_out) + + return steps_out + + +def extract_mobalytics_paragon_steps(variant: dict[str, Any]) -> list[list[dict[str, Any]]]: + """Extract paragon boards from Mobalytics preloaded-state build variant. + + Matches the rotation + node-index transformation used in Diablo4Companion. + """ + paragon = (variant or {}).get("paragon") or {} + boards_data = paragon.get("boards") or [] + nodes_data = paragon.get("nodes") or [] + + boards_out: list[dict[str, Any]] = [] + + for board in boards_data: + board_slug = ((board or {}).get("board") or {}).get("slug", "") + board_slug = _fix_mobalytics_starting_board_slug(board_slug) + + glyph_slug = ((board or {}).get("glyph") or {}).get("slug", "") + rotation = int((board or {}).get("rotation", 0)) + + nodes_bool = [False] * NODES_LEN + board_nodes = [ + n + for n in nodes_data + if isinstance(n, dict) + and isinstance(n.get("slug"), str) + and n["slug"].startswith(board_slug) + ] + + for n in board_nodes: + slug = n.get("slug", "") + node_position = slug.replace(board_slug + "-", "") + try: + x_part, y_part = node_position.split("-", 1) + x = int(x_part.lstrip("x")) + y = int(y_part.lstrip("y")) + except Exception: + continue + + idx = _transform_xy_common(x=x, y=y, rotation_deg=rotation, base="mobalytics") + if 0 <= idx < NODES_LEN: + nodes_bool[idx] = True + + boards_out.append( + { + "Name": board_slug, + "Glyph": glyph_slug, + "Rotation": _rotation_info_degrees(rotation), + "Nodes": nodes_bool, + } + ) + + return [boards_out] if boards_out else [] + + +def extract_d4builds_paragon_steps(driver: WebDriver, class_name: str = "") -> list[list[dict[str, Any]]]: + """Extract paragon boards from D4Builds using Selenium. + + This mimics Diablo4Companion's approach: + - wait until the build name input (renameBuild) is populated + - click the Paragon tab via the left navigation links (builder__navigation__link) + - parse .paragon__board elements and their active tiles + """ + class_slug = _class_slug_from_name(class_name) + + try: + from selenium.webdriver.common.by import By + from selenium.webdriver.support.ui import WebDriverWait + except Exception as exc: # pragma: no cover + LOGGER.error("Selenium not available, cannot export D4Builds paragon") + raise exc + + # Wait until build is loaded (renameBuild has a non-empty value) + try: + wait = WebDriverWait(driver, 20) + + def _has_build_name(drv): + try: + el = drv.find_element(By.ID, "renameBuild") + return bool((el.get_attribute("value") or "").strip()) + except Exception: + return False + + wait.until(_has_build_name) + except Exception: + pass + + # Switch to Paragon tab (D4Builds uses left navigation links) + try: + nav_links = driver.find_elements(By.CLASS_NAME, "builder__navigation__link") + if len(nav_links) >= 3: + driver.execute_script("arguments[0].click();", nav_links[2]) + else: + # Fallback: click any element containing 'Paragon' + el = driver.find_element(By.XPATH, "//*[contains(normalize-space(.), 'Paragon')]") + driver.execute_script("arguments[0].click();", el) + time.sleep(0.25) + except Exception: + # Not fatal: sometimes paragon is already visible or site changed + pass + + # Wait for paragon boards to appear (best effort) + try: + wait = WebDriverWait(driver, 10) + wait.until(lambda d: len(d.find_elements(By.CLASS_NAME, "paragon__board")) > 0) + except Exception: + pass + + boards_out: list[dict[str, Any]] = [] + try: + board_elements = driver.find_elements(By.CLASS_NAME, "paragon__board") + except Exception: + board_elements = [] + + for board_elem in board_elements: + name_raw = "" + lines = [] + name_display = "" + try: + name_raw = board_elem.find_element(By.CLASS_NAME, "paragon__board__name").get_attribute("innerText") or "" + lines = [ln.strip() for ln in (name_raw or "").splitlines() if ln.strip()] + # Prefer first line that contains letters (D4Builds sometimes shows just a numeric index on line 1) + name_display = next((ln for ln in lines if any(ch.isalpha() for ch in ln)), (lines[0] if lines else "")) + except Exception: + name_display = "" + + # Try to detect a stable board id/slug from element attributes (best effort) + board_id = "" + try: + attrs = driver.execute_script( + "var a=arguments[0].attributes; var o={}; for (var i=0;i str: + return {0: "0°", 1: "90°", 2: "180°", 3: "270°"}.get(rot, "?°") + + +def _rotation_info_degrees(rot: int) -> str: + rot = rot % 360 + return {0: "0°", 90: "90°", 180: "180°", 270: "270°"}.get(rot, "?°") + + +def _transform_maxroll_location(loc: int, rotation: int) -> int: + """Transform a 0-based location index from Maxroll into the Nodes[] index. + + This follows the exact switch used in Diablo4Companion BuildsManagerMaxroll. + """ + x = loc % GRID + y = loc // GRID + xt = x + yt = y + + match rotation: + case 0: + return loc + case 1: + xt = GRID - y + yt = x + xt -= 1 + return yt * GRID + xt + case 2: + xt = GRID - x + yt = GRID - y + xt -= 1 + yt -= 1 + return yt * GRID + xt + case 3: + xt = y + yt = GRID - x + yt -= 1 + return yt * GRID + xt + case _: + return loc + + +def _transform_xy_common(x: int, y: int, rotation_deg: int, base: str) -> int: + """Shared x/y to Nodes[] transform. + + base: + - 'd4builds' uses 1-based r/c coordinates. + - 'mobalytics' uses 1-based x/y coordinates. + + The formulas mirror Diablo4Companion's implementations for each source. + """ + rotation_deg = rotation_deg % 360 + + xt = x + yt = y + + if base in {"d4builds", "mobalytics"}: + # both sources provide 1-based coords in the '0°' case and need (x-1, y-1) + if rotation_deg in {0, 360}: + xt -= 1 + yt -= 1 + elif rotation_deg == 90: + xt = GRID - y + yt = x + yt -= 1 + elif rotation_deg == 180: + xt = GRID - x + yt = GRID - y + elif rotation_deg == 270: + xt = y + yt = GRID - x + xt -= 1 + + return yt * GRID + xt + + +def _fix_mobalytics_starting_board_slug(board_slug: str) -> str: + # Fix naming inconsistency (ported from Diablo4Companion) + return ( + board_slug.replace("barbarian-starter-board", "barbarian-starting-board") + .replace("druid-starter-board", "druid-starting-board") + .replace("necromancer-starter-board", "necromancer-starting-board") + .replace("paladin-starter-board", "paladin-starting-board") + .replace("rogue-starter-board", "rogue-starting-board") + .replace("sorcerer-starter-board", "sorcerer-starting-board") + .replace("spiritborn-starter-board", "spiritborn-starting-board") + ) diff --git a/src/gui/importer_window.py b/src/gui/importer_window.py index 996160f4..671ba70e 100644 --- a/src/gui/importer_window.py +++ b/src/gui/importer_window.py @@ -107,6 +107,13 @@ def __init__(self, parent=None): "false", ) + self.export_paragon_checkbox = self._generate_checkbox( + "Export Paragon JSON", + "export_paragon", + "Export paragon boards to a JSON file (D4Companion/d4.py compatible). Output: /paragon", + "false", + ) + # GA dependency logic def disable_require_if_import_disabled(): if not self.import_gas_checkbox.isChecked(): @@ -135,6 +142,7 @@ def disable_require_if_import_disabled(): checkbox_hbox = QHBoxLayout() checkbox_hbox.addWidget(self.import_gas_checkbox) checkbox_hbox.addWidget(self.require_all_gas_checkbox) + checkbox_hbox.addWidget(self.export_paragon_checkbox) layout.addLayout(checkbox_hbox) # Generate button @@ -223,6 +231,7 @@ def _generate_button_click(self): self.add_to_profiles_checkbox.isChecked(), self.import_gas_checkbox.isChecked(), self.require_all_gas_checkbox.isChecked(), + self.export_paragon_checkbox.isChecked(), custom_filename, ) diff --git a/src/gui/unified_window.py b/src/gui/unified_window.py index ab17af37..3e5bb517 100644 --- a/src/gui/unified_window.py +++ b/src/gui/unified_window.py @@ -1,6 +1,7 @@ import logging import re import sys +import subprocess import time from contextlib import suppress from pathlib import Path @@ -9,6 +10,7 @@ from PyQt6.QtGui import QIcon, QTextCursor from PyQt6.QtWidgets import ( QApplication, + QFileDialog, QMainWindow, QMessageBox, QPlainTextEdit, @@ -179,14 +181,22 @@ def __init__(self): theme_name = getattr(config.general, "theme", None) or "dark" stylesheet = DARK_THEME if theme_name == "dark" else LIGHT_THEME QApplication.instance().setStyleSheet(stylesheet) - # --- Logging setup --- running_from_source = not getattr(sys, "frozen", False) - setup_logging(enable_stdout=running_from_source) root_logger = logging.getLogger() - # Remove existing handlers, but keep stdout handler if running from source + # Ensure file logging stays enabled. unified_window previously removed all handlers (including the file handler), + # which stopped live log writing to d4lf/logs. + if not any(getattr(h, "name", "") == "D4LF_FILE" for h in root_logger.handlers): + setup_logging( + log_level=config.advanced_options.log_lvl.value, + enable_stdout=running_from_source, + ) + + # Remove existing handlers, but keep file handler and (optionally) stdout when running from source for h in list(root_logger.handlers): + if getattr(h, "name", "") == "D4LF_FILE": + continue # Keep file logging if running_from_source and isinstance(h, logging.StreamHandler) and h.stream == sys.stdout: continue # Keep stdout handler for IDE terminal root_logger.removeHandler(h) @@ -326,7 +336,43 @@ def open_profile_editor(self): except Exception as e: LOGGER.error(f"Failed to open profile editor: {e}") + def open_paragon_overlay(self): + """Select the folder that contains Paragon JSON files for the overlay. + + This does NOT start the overlay. Use the Paragon hotkey (Advanced Options → Toggle Paragon Overlay) + to open/close it. + """ + try: + config = IniConfigLoader() + # Use last saved folder if present, otherwise default to ~/.d4lf/paragon + saved = getattr(config.advanced_options, "paragon_overlay_source_dir", "") or "" + default_dir = Path(saved).expanduser() if str(saved).strip() else (Path(config.user_dir) / "paragon") + default_dir.mkdir(parents=True, exist_ok=True) + + folder = QFileDialog.getExistingDirectory( + self, + "Select Paragon folder (source for Paragon overlay JSON files)", + str(default_dir), + ) + if not folder: + return + + # Persist selection + config.save_value("advanced_options", "paragon_overlay_source_dir", folder) + + hotkey = getattr(config.advanced_options, "toggle_paragon_overlay", "f10").upper() + LOGGER.info(f"Paragon folder set to: {folder}. Use {hotkey} to toggle the overlay.") + QMessageBox.information( + self, + "Paragon Folder Set", + f"Paragon folder saved:\n{folder}\n\nUse {hotkey} to open/close the Paragon overlay.", + ) + except Exception as e: + LOGGER.error(f"Failed to set Paragon folder: {e}") + QMessageBox.critical(self, "Paragon Folder Error", str(e)) + def restore_geometry(self): + settings = QSettings("d4lf", "mainwindow") size = settings.value("size", QSize(1000, 800)) @@ -388,4 +434,4 @@ def emit_startup_direct_to_console(self): def apply_theme(self): theme_name = IniConfigLoader().general.theme stylesheet = DARK_THEME if theme_name == "dark" else LIGHT_THEME - QApplication.instance().setStyleSheet(stylesheet) + QApplication.instance().setStyleSheet(stylesheet) \ No newline at end of file diff --git a/src/main.py b/src/main.py index 7df347a2..16de14b8 100644 --- a/src/main.py +++ b/src/main.py @@ -54,6 +54,7 @@ def main(): table = BeautifulTable() table.set_style(BeautifulTable.STYLE_BOX_ROUNDED) table.rows.append([IniConfigLoader().advanced_options.run_vision_mode, "Run/Stop Vision Mode"]) + table.rows.append([IniConfigLoader().advanced_options.toggle_paragon_overlay, "Toggle Paragon Overlay"]) if not IniConfigLoader().advanced_options.vision_mode_only: table.rows.append([IniConfigLoader().advanced_options.run_filter, "Run/Stop Auto Filter"]) @@ -187,6 +188,32 @@ def hide_console(): src.logger.setup(log_level=IniConfigLoader().advanced_options.log_lvl.value, enable_stdout=True) start_auto_update(postprocess=True) + elif len(sys.argv) > 1 and sys.argv[1] == "--paragon-overlay": + # Run integrated Win32 Paragon overlay (separate mode). + running_from_source = not getattr(sys, "frozen", False) + if not running_from_source: + hide_console() + # Minimal logger setup (keeps behavior consistent when run from source) + src.logger.setup(log_level=IniConfigLoader().advanced_options.log_lvl.value, enable_stdout=running_from_source) + preset_path = sys.argv[2] if len(sys.argv) > 2 else None + try: + from src.paragon_overlay import run_paragon_overlay + run_paragon_overlay(preset_path) + except Exception as e: + import logging + + logging.getLogger(__name__).exception("Paragon overlay crashed") + if sys.platform == "win32": + try: + ctypes.windll.user32.MessageBoxW( + None, + f"Paragon overlay ist abgestürzt.\n\nQuelle: {preset_path}\n\nFehler: {e}", + "D4LF Paragon Overlay", + 0, + ) + except Exception: + pass + elif len(sys.argv) > 1 and sys.argv[1] == "--consoleonly": # Console-only mode: keep console visible src.logger.setup(log_level=IniConfigLoader().advanced_options.log_lvl.value, enable_stdout=True) @@ -208,4 +235,4 @@ def hide_console(): app.setWindowIcon(QIcon(str(ICON_PATH))) window = UnifiedMainWindow() window.show() - sys.exit(app.exec()) + sys.exit(app.exec()) \ No newline at end of file diff --git a/src/paragon_overlay.py b/src/paragon_overlay.py new file mode 100644 index 00000000..696f0787 --- /dev/null +++ b/src/paragon_overlay.py @@ -0,0 +1,639 @@ +# Integrated into D4LF as src.paragon_overlay +# Original file: d4.py (Win32 layered window Paragon overlay) +# Entry: run_paragon_overlay(preset_path) + +# d4_paragon_overlay_v14_fix_button.py +# +# Features: +# - FIX: Start/Stop Button is now ALWAYS visible (drawn on top of header) +# - EXIT BUTTON (Right side of hint bar) +# - MENU POSITION: Top-Left (0,0) +# - THICK GOLD FRAME +# - ALWAYS ON TOP +# - 64-Bit Safe +# +# Controls: +# [Top-Left Button]: Toggle Start/Stop +# [Red 'EXIT' Button]: Close App +# [Build Header]: Switch Build +# [Scroll Wheel]: Zoom +# [Drag]: Move + +import ctypes +import ctypes.wintypes as wt +import json +import re +import os +import sys +import logging +from pathlib import Path +from PIL import Image, ImageDraw, ImageFont + +# --- HARDENED WIN32 DEFINITIONS (64-BIT SAFE) --- +user32 = ctypes.WinDLL("user32", use_last_error=True) +gdi32 = ctypes.WinDLL("gdi32", use_last_error=True) +kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + +# Explicit types +HANDLE = ctypes.c_void_p +HWND = ctypes.c_void_p +HDC = ctypes.c_void_p +HBITMAP = ctypes.c_void_p +HGDIOBJ = ctypes.c_void_p +HICON = ctypes.c_void_p +HCURSOR = ctypes.c_void_p +HBRUSH = ctypes.c_void_p +HMENU = ctypes.c_void_p +HINSTANCE = ctypes.c_void_p +LPVOID = ctypes.c_void_p +LPARAM = ctypes.c_longlong +WPARAM = ctypes.c_ulonglong +LRESULT = ctypes.c_longlong + +WNDPROCTYPE = ctypes.WINFUNCTYPE(LRESULT, HWND, wt.UINT, WPARAM, LPARAM) + +class WNDCLASSW(ctypes.Structure): + _fields_ = [("style", wt.UINT), ("lpfnWndProc", WNDPROCTYPE), ("cbClsExtra", ctypes.c_int), + ("cbWndExtra", ctypes.c_int), ("hInstance", HINSTANCE), ("hIcon", HICON), + ("hCursor", HCURSOR), ("hbrBackground", HBRUSH), ("lpszMenuName", wt.LPCWSTR), + ("lpszClassName", wt.LPCWSTR)] + +class BLENDFUNCTION(ctypes.Structure): + _fields_ = [("BlendOp", wt.BYTE), ("BlendFlags", wt.BYTE), ("SourceConstantAlpha", wt.BYTE), ("AlphaFormat", wt.BYTE)] + +class BITMAPINFOHEADER(ctypes.Structure): + _fields_ = [("biSize", wt.DWORD), ("biWidth", wt.LONG), ("biHeight", wt.LONG), ("biPlanes", wt.WORD), + ("biBitCount", wt.WORD), ("biCompression", wt.DWORD), ("biSizeImage", wt.DWORD), + ("biXPelsPerMeter", wt.LONG), ("biYPelsPerMeter", wt.LONG), ("biClrUsed", wt.DWORD), ("biClrImportant", wt.DWORD)] + +class BITMAPINFO(ctypes.Structure): + _fields_ = [("bmiHeader", BITMAPINFOHEADER), ("bmiColors", ctypes.c_ulong * 3)] + +# --- API Signatures --- +kernel32.GetModuleHandleW.argtypes = [wt.LPCWSTR]; kernel32.GetModuleHandleW.restype = HINSTANCE +user32.RegisterClassW.argtypes = [ctypes.POINTER(WNDCLASSW)]; user32.RegisterClassW.restype = wt.ATOM +user32.CreateWindowExW.argtypes = [wt.DWORD, wt.LPCWSTR, wt.LPCWSTR, wt.DWORD, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, HWND, HMENU, HINSTANCE, LPVOID]; user32.CreateWindowExW.restype = HWND +user32.DefWindowProcW.argtypes = [HWND, wt.UINT, WPARAM, LPARAM]; user32.DefWindowProcW.restype = LRESULT +user32.UpdateLayeredWindow.argtypes = [HWND, HDC, ctypes.POINTER(wt.POINT), ctypes.POINTER(wt.SIZE), HDC, ctypes.POINTER(wt.POINT), wt.COLORREF, ctypes.POINTER(BLENDFUNCTION), wt.DWORD]; user32.UpdateLayeredWindow.restype = wt.BOOL +user32.GetDC.argtypes = [HWND]; user32.GetDC.restype = HDC +user32.ReleaseDC.argtypes = [HWND, HDC]; user32.ReleaseDC.restype = ctypes.c_int +user32.PostQuitMessage.argtypes = [ctypes.c_int]; user32.PostQuitMessage.restype = None +user32.SetFocus.argtypes = [HWND]; user32.SetFocus.restype = HWND +user32.GetKeyState.argtypes = [ctypes.c_int]; user32.GetKeyState.restype = wt.SHORT +user32.GetSystemMetrics.argtypes = [ctypes.c_int]; user32.GetSystemMetrics.restype = ctypes.c_int +user32.LoadCursorW.argtypes = [HINSTANCE, wt.LPCWSTR]; user32.LoadCursorW.restype = HCURSOR +user32.GetMessageW.argtypes = [ctypes.POINTER(wt.MSG), HWND, wt.UINT, wt.UINT]; user32.GetMessageW.restype = wt.BOOL +user32.TranslateMessage.argtypes = [ctypes.POINTER(wt.MSG)]; user32.TranslateMessage.restype = wt.BOOL +user32.DispatchMessageW.argtypes = [ctypes.POINTER(wt.MSG)]; user32.DispatchMessageW.restype = LRESULT +user32.SetCapture.argtypes = [HWND]; user32.SetCapture.restype = HWND +user32.ReleaseCapture.argtypes = []; user32.ReleaseCapture.restype = wt.BOOL +user32.GetCursorPos.argtypes = [ctypes.POINTER(wt.POINT)]; user32.GetCursorPos.restype = wt.BOOL +user32.SetCursor.argtypes = [HCURSOR]; user32.SetCursor.restype = HCURSOR +user32.SetWindowPos.argtypes = [HWND, HWND, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, wt.UINT]; user32.SetWindowPos.restype = wt.BOOL + +gdi32.CreateCompatibleDC.argtypes = [HDC]; gdi32.CreateCompatibleDC.restype = HDC +gdi32.SelectObject.argtypes = [HDC, HGDIOBJ]; gdi32.SelectObject.restype = HGDIOBJ +gdi32.DeleteDC.argtypes = [HDC]; gdi32.DeleteDC.restype = wt.BOOL +gdi32.DeleteObject.argtypes = [HGDIOBJ]; gdi32.DeleteObject.restype = wt.BOOL +gdi32.CreateDIBSection.argtypes = [HDC, ctypes.POINTER(BITMAPINFO), wt.UINT, ctypes.POINTER(ctypes.c_void_p), HANDLE, wt.DWORD]; gdi32.CreateDIBSection.restype = HBITMAP + +# --- Constants --- +WS_POPUP = 0x80000000 +WS_VISIBLE = 0x10000000 +WS_EX_TOPMOST = 0x00000008 +WS_EX_LAYERED = 0x00080000 +WS_EX_TOOLWINDOW = 0x00000080 +ULW_ALPHA = 0x00000002 +AC_SRC_OVER = 0x00 +AC_SRC_ALPHA = 0x01 +BI_RGB = 0 +WM_DESTROY = 0x0002 +WM_LBUTTONDOWN = 0x0201 +WM_LBUTTONUP = 0x0202 +WM_MOUSEMOVE = 0x0200 +WM_MOUSEWHEEL = 0x020A +WM_KEYDOWN = 0x0100 +WM_NCHITTEST = 0x0084 +HTCLIENT = 1 +VK_LEFT, VK_UP, VK_RIGHT, VK_DOWN = 0x25, 0x26, 0x27, 0x28 +VK_SHIFT = 0x10 +IDC_ARROW = 32512 +IDC_SIZEALL = 32646 + +HWND_TOPMOST = ctypes.c_void_p(-1) +SWP_NOMOVE = 0x0002 +SWP_NOSIZE = 0x0001 +SWP_NOACTIVATE = 0x0010 + +# --- Logic & Data --- +GRID = 21 +PANEL_W = 600 +ITEM_H = 34 +HEADER_H = 80 + +# Colors +C_GRID_LINE = (80, 80, 80, 60) +C_GRID_FRAME = (217, 143, 57, 240) +C_GRID_FRAME_BG = (0, 0, 0, 150) +C_NODE_ACTIVE = (0, 255, 60, 220) +C_NODE_PATH = (0, 200, 50, 160) +C_ACTION_BG = (50, 60, 80, 220) +C_ITEM_BG = (30, 30, 30, 200) +C_ITEM_BORDER = (80, 80, 80, 255) +C_TEXT = (240, 240, 240, 255) +C_TEXT_DIM = (180, 180, 180, 255) +C_GOLD = (217, 143, 57, 255) + +LOGGER = logging.getLogger(__name__) + + +def _msgbox(title: str, text: str) -> None: + """Show a Windows message box. Safe no-op on non-Windows.""" + try: + if sys.platform == "win32": + user32.MessageBoxW(None, str(text), str(title), 0) + except Exception: + pass + +def get_xy(lparam): + return (lparam & 0xFFFF), ((lparam >> 16) & 0xFFFF) + +def parse_rotation(rot_str: str) -> int: + m = re.search(r"(\d+)", rot_str or "") + deg = int(m.group(1)) if m else 0 + return deg % 360 if deg % 360 in (0, 90, 180, 270) else 0 + +def nodes_to_grid(nodes_441): + grid = [] + for y in range(GRID): + row = [] + for x in range(GRID): + row.append(bool(nodes_441[y * GRID + x])) + grid.append(row) + return grid + +def rotate_grid(grid, deg: int): + if deg == 90: return [list(reversed(col)) for col in zip(*grid)] + if deg == 180: return [list(reversed(r)) for r in reversed(grid)] + if deg == 270: return [list(col) for col in reversed(list(zip(*grid)))] + return grid + +def _iter_entries(data): + """Yield build-like dicts from JSON that can be either a list[dict] or a dict.""" + if isinstance(data, dict): + yield data + elif isinstance(data, list): + for it in data: + if isinstance(it, dict): + yield it + + +def _normalize_steps(raw_list): + """Normalize ParagonBoardsList to a list of steps, each step being a list[board].""" + if not isinstance(raw_list, list) or not raw_list: + return [] + # If first element is a list, assume list-of-steps. + if isinstance(raw_list[0], list): + return [step for step in raw_list if isinstance(step, list) and step] + # Otherwise assume a single step list-of-boards. + return [raw_list] + + +def _load_builds_from_file(preset_file: str, name_tag: str | None = None): + """Load one JSON file and return a list of builds in overlay format: {name, boards}. + + Supports: + - D4LF paragon exports (JSON list with single entry) + - AffixPresets-v2 style (JSON list with many entries) + - A single dict payload + Also expands multi-step ParagonBoardsList into multiple selectable builds. + """ + with open(preset_file, "r", encoding="utf-8") as f: + data = json.load(f) + + builds = [] + for entry in _iter_entries(data): + base_name = entry.get("Name") or entry.get("name") or "Unknown Build" + steps = _normalize_steps(entry.get("ParagonBoardsList", [])) + if not steps: + continue + + # If there are multiple steps, expose them as separate selectable builds. + # For planners that provide many incremental steps (e.g., Maxroll), it's more useful to start on the FINAL step. + for idx in range(len(steps) - 1, -1, -1): + boards = steps[idx] + step_no = idx + 1 + step_name = base_name + if len(steps) > 1: + step_name = f"{base_name} - Step {step_no}" + if name_tag: + step_name = f"{step_name} [{name_tag}]" + builds.append({"name": step_name, "boards": boards}) + + if not builds: + raise ValueError(f"No valid builds in {preset_file}") + + return builds + + +def load_builds_from_path(preset_path: str): + """Load builds from a JSON file OR from a folder containing multiple *.json files.""" + p = Path(preset_path) + if p.is_dir(): + files = sorted(p.glob("*.json"), key=lambda fp: fp.stat().st_mtime, reverse=True) + if not files: + raise ValueError("Folder contains no .json files") + multi = len(files) > 1 + builds = [] + for fp in files: + try: + builds.extend(_load_builds_from_file(str(fp), name_tag=(fp.stem if multi else None))) + except Exception: + # Ignore JSONs that don't match expected structure + continue + if not builds: + raise ValueError("No valid builds found in folder") + return builds + + if not p.exists(): + raise ValueError("Preset file not found") + + return _load_builds_from_file(str(p)) +def get_font(size=14, bold=False): + try: return ImageFont.truetype("arialbd.ttf" if bold else "arial.ttf", size) + except: return ImageFont.load_default() + +FONT_HEADER = get_font(16, bold=True) +FONT_ITEM = get_font(13, bold=True) +FONT_SMALL = get_font(11, bold=False) + +def render_grid_window(board, cell_size): + rot_deg = parse_rotation(board.get("Rotation", "0°")) + grid = rotate_grid(nodes_to_grid(board["Nodes"]), rot_deg) + grid_px = GRID * cell_size + + img = Image.new("RGBA", (grid_px + 30, grid_px + 30), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + gx0, gy0 = 15, 15 + half_cell = cell_size // 2 + + # 1. Background Grid + for i in range(GRID + 1): + p = i * cell_size + d.line([(gx0, gy0 + p), (gx0 + grid_px, gy0 + p)], fill=C_GRID_LINE, width=1) + d.line([(gx0 + p, gy0), (gx0 + p, gy0 + grid_px)], fill=C_GRID_LINE, width=1) + + # 2. Draw Paths + path_width = max(2, cell_size // 6) + for y in range(GRID): + for x in range(GRID): + if grid[y][x]: + cx, cy = gx0 + x * cell_size + half_cell, gy0 + y * cell_size + half_cell + if x + 1 < GRID and grid[y][x+1]: + nx, ny = gx0 + (x+1) * cell_size + half_cell, cy + d.line([(cx, cy), (nx, ny)], fill=C_NODE_PATH, width=path_width) + if y + 1 < GRID and grid[y+1][x]: + nx, ny = cx, gy0 + (y+1) * cell_size + half_cell + d.line([(cx, cy), (nx, ny)], fill=C_NODE_PATH, width=path_width) + + # 3. Draw Nodes + inset = max(3, cell_size // 4) + for y in range(GRID): + for x in range(GRID): + if grid[y][x]: + px, py = gx0 + x * cell_size, gy0 + y * cell_size + d.rectangle((px + inset, py + inset, px + cell_size - inset, py + cell_size - inset), + fill=C_NODE_ACTIVE, outline=None) + d.rectangle((px + inset, py + inset, px + cell_size - inset, py + cell_size - inset), + outline=(200, 255, 200, 100), width=1) + + # 4. THICK Outer Frame + frame_thick = 5 + d.rectangle((gx0 - 1, gy0 - 1, gx0 + grid_px + 1, gy0 + grid_px + 1), + outline=C_GRID_FRAME_BG, width=frame_thick + 2) + d.rectangle((gx0, gy0, gx0 + grid_px, gy0 + grid_px), + outline=C_GRID_FRAME, width=frame_thick) + + return img + +def render_list_window(state): + minimized = state.minimized + selecting = state.selecting_build + + # 1. PREPARE TOGGLE BUTTON + btn_rect = [2, 2, 26, 26] + if minimized: + fill_col = (200, 50, 50) # Red + symbol = "\u2716" # X + txt_offset = (6, 5) + else: + fill_col = (50, 200, 50) # Green + symbol = "\u2714" # Check + txt_offset = (6, 5) + + # 2. IF MINIMIZED -> DRAW ONLY BUTTON AND RETURN + if minimized: + img = Image.new("RGBA", (PANEL_W, 30), (0, 0, 0, 1)) + d = ImageDraw.Draw(img) + d.rectangle(btn_rect, fill=fill_col, outline=(200,200,200)) + d.text(txt_offset, symbol, fill=(255, 255, 255, 255), font=FONT_ITEM) + return img + + # 3. IF OPEN -> DRAW CONTENT + if selecting: + data, title = state.builds, "Select Build (Click to cancel)" + active_idx = state.current_build_idx + else: + data, title = state.boards, state.build_name + " \u25BC" + active_idx = state.selected + + rows = len(data) + total_h = HEADER_H + (rows * (ITEM_H + 4)) + 10 + + img = Image.new("RGBA", (PANEL_W, total_h), (0, 0, 0, 1)) + d = ImageDraw.Draw(img) + + # Header Background + d.rectangle((0, 0, PANEL_W, HEADER_H), fill=C_ACTION_BG if selecting else C_ITEM_BG) + d.text((35, 10), title, fill=C_TEXT, font=FONT_HEADER) + + # Hint Box + d.rectangle((0, 50, PANEL_W - 5, 85), fill=C_ITEM_BG, outline=C_ITEM_BORDER, width=1) + hint = f"Found {len(data)} builds" if selecting else "click on golden frame= Zoom: Mousewheel | Move: Drag Grid" + d.text((12, 58), hint, fill=C_GOLD, font=FONT_SMALL) + + # EXIT BUTTON (Right side) + exit_rect = [PANEL_W - 35, 55, PANEL_W - 10, 80] + d.rectangle(exit_rect, fill=(180, 0, 0), outline=(200, 200, 200)) + d.text((PANEL_W - 30, 59), "EXIT", fill=(255,255,255), font=get_font(9, True)) + + d.line([(0, HEADER_H), (PANEL_W, HEADER_H)], fill=C_GOLD, width=1) + + # List Items + y_start = HEADER_H + 10 + for i, item in enumerate(data): + label = item["name"] if selecting else f"{item.get('Name','?')} ({item.get('Rotation','0')})" + if not selecting and item.get("Glyph"): label += f" ({item.get('Glyph')})" + + y = y_start + i * (ITEM_H + 4) + bg, border, txt = C_ITEM_BG, C_ITEM_BORDER, C_TEXT_DIM + + if i == active_idx: bg, border, txt = (40, 35, 20, 220), C_GOLD, C_TEXT + elif i == state.hover: border, txt = (150, 150, 150, 255), C_TEXT + + d.rectangle((0, y, PANEL_W - 5, y + ITEM_H), fill=bg, outline=border, width=1) + d.text((10, y + (ITEM_H - 13) // 2 - 2), label, fill=txt, font=FONT_ITEM) + + # 4. DRAW TOGGLE BUTTON LAST (So it sits ON TOP of Header) + d.rectangle(btn_rect, fill=fill_col, outline=(200,200,200)) + d.text(txt_offset, symbol, fill=(255, 255, 255, 255), font=FONT_ITEM) + + return img + +def pil_to_hbitmap(pil_img): + img = pil_img.convert("RGBA") + w, h = img.size + bmi = BITMAPINFO() + bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER) + bmi.bmiHeader.biWidth, bmi.bmiHeader.biHeight = w, -h + bmi.bmiHeader.biPlanes, bmi.bmiHeader.biBitCount = 1, 32 + bmi.bmiHeader.biCompression = BI_RGB + bits = ctypes.c_void_p() + hdc_screen = user32.GetDC(None) + hbmp = gdi32.CreateDIBSection(hdc_screen, ctypes.byref(bmi), 0, ctypes.byref(bits), None, 0) + user32.ReleaseDC(None, hdc_screen) + ctypes.memmove(bits, img.tobytes("raw", "BGRA"), w * h * 4) + return hbmp + +def update_window(hwnd, img, x, y): + hbmp = pil_to_hbitmap(img) + hdc_screen = user32.GetDC(None) + hdc_mem = gdi32.CreateCompatibleDC(hdc_screen) + old = gdi32.SelectObject(hdc_mem, hbmp) + pt_dst, sz, pt_src = wt.POINT(x, y), wt.SIZE(img.width, img.height), wt.POINT(0, 0) + blend = BLENDFUNCTION(AC_SRC_OVER, 0, 255, AC_SRC_ALPHA) + user32.UpdateLayeredWindow(hwnd, hdc_screen, ctypes.byref(pt_dst), ctypes.byref(sz), + hdc_mem, ctypes.byref(pt_src), 0, ctypes.byref(blend), ULW_ALPHA) + + # Always On Top + user32.SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE) + + gdi32.SelectObject(hdc_mem, old); gdi32.DeleteObject(hbmp); gdi32.DeleteDC(hdc_mem); user32.ReleaseDC(None, hdc_screen) + +class AppState: + def __init__(self, builds): + self.builds = builds + self.current_build_idx = 0 + self.update_current_build_data() + self.selected = 0 + self.hover = -1 + self.selecting_build = False + self.hwnd_grid = None + self.hwnd_list = None + self.grid_pos = (450, 60) + self.list_pos = (0, 0) + self.cell_size = 28 + self.minimized = False + self.grid_cache = {} + self.dragging = False + self.drag_start = (0, 0) + self.drag_offset = (0, 0) + self.load_config() + + def update_current_build_data(self): + cur = self.builds[self.current_build_idx] + self.build_name, self.boards = cur["name"], cur["boards"] + self.grid_cache = {} + + def load_config(self): + if os.path.exists(CONFIG_FILE): + try: + with open(CONFIG_FILE, "r") as f: + cfg = json.load(f) + self.list_pos = (0, int(cfg.get("list_pos", (0, 60))[1])) + gp = cfg.get("grid_pos") + if gp: self.grid_pos = tuple(gp) + self.cell_size = cfg.get("cell_size", 28) + self.minimized = cfg.get("minimized", False) + # Clamp positions to visible screen area (prevents 'overlay opened but not visible') + try: + sw = user32.GetSystemMetrics(0) + sh = user32.GetSystemMetrics(1) + # list window: x is always 0 + ly = max(0, min(int(self.list_pos[1]), max(0, sh - 80))) + self.list_pos = (0, ly) + gx, gy = self.grid_pos + gx = max(0, min(int(gx), max(0, sw - 80))) + gy = max(0, min(int(gy), max(0, sh - 80))) + self.grid_pos = (gx, gy) + except Exception: + pass + + except: pass + + def save_config(self): + cfg = {"list_pos": self.list_pos, "grid_pos": self.grid_pos, "cell_size": self.cell_size, "minimized": self.minimized} + try: + with open(CONFIG_FILE, "w") as f: json.dump(cfg, f) + except: pass + +state = None +CONFIG_FILE = "d4_overlay_config.json" + +def redraw_all(force_grid=False): + l_img = render_list_window(state) + update_window(state.hwnd_list, l_img, 0, state.list_pos[1]) + + if state.minimized or state.selecting_build: + g_img = Image.new("RGBA", (1, 1), (0,0,0,0)) + else: + key = (state.selected, state.cell_size) + if force_grid or key not in state.grid_cache: + state.grid_cache[key] = render_grid_window(state.boards[state.selected], state.cell_size) + g_img = state.grid_cache[key] + update_window(state.hwnd_grid, g_img, state.grid_pos[0], state.grid_pos[1]) + +@WNDPROCTYPE +def WndProcList(hwnd, msg, w, l): + if msg == WM_DESTROY: user32.PostQuitMessage(0); return 0 + if msg == WM_NCHITTEST: return HTCLIENT + + if msg == WM_MOUSEMOVE: + if state.minimized: return 0 + _, y = get_xy(l) + if y > HEADER_H: + lst = state.builds if state.selecting_build else state.boards + idx = (y - HEADER_H - 10) // (ITEM_H + 4) + nh = idx if 0 <= idx < len(lst) else -1 + if nh != state.hover: state.hover = nh; redraw_all() + elif state.hover != -1: state.hover = -1; redraw_all() + return 0 + + if msg == WM_LBUTTONDOWN: + user32.SetFocus(hwnd) + x, y = get_xy(l) + # Check START/STOP Button + if x < 28 and y < 28: + state.minimized = not state.minimized; state.save_config(); redraw_all(); return 0 + + if state.minimized: return 0 + + # Check EXIT Button + # Rect: [PANEL_W - 35, 55, PANEL_W - 10, 80] + if PANEL_W - 35 <= x <= PANEL_W - 10 and 55 <= y <= 80: + user32.PostQuitMessage(0) + return 0 + + if y < HEADER_H: + if x > 30: + state.selecting_build = not state.selecting_build + state.hover = -1; redraw_all() + return 0 + + lst = state.builds if state.selecting_build else state.boards + idx = (y - HEADER_H - 10) // (ITEM_H + 4) + if 0 <= idx < len(lst): + if state.selecting_build: + state.current_build_idx = idx + state.update_current_build_data() + state.selected = 0 + state.selecting_build = False + else: + state.selected = idx + redraw_all() + return 0 + return user32.DefWindowProcW(hwnd, msg, w, l) + +@WNDPROCTYPE +def WndProcGrid(hwnd, msg, w, l): + if msg == WM_NCHITTEST: return HTCLIENT + + if msg == WM_MOUSEWHEEL: + delta = ctypes.c_short(w >> 16).value + change = 2 if delta > 0 else -2 + new_size = max(10, min(150, state.cell_size + change)) + if new_size != state.cell_size: + state.cell_size = new_size + state.save_config() + redraw_all(True) + return 0 + + if msg == WM_LBUTTONDOWN: + user32.SetFocus(hwnd); user32.SetCapture(hwnd) + state.dragging = True + pt = wt.POINT(); user32.GetCursorPos(ctypes.byref(pt)) + state.drag_start = (pt.x, pt.y); state.drag_offset = state.grid_pos + user32.SetCursor(user32.LoadCursorW(state.h_inst, wt.LPCWSTR(IDC_SIZEALL))) + return 0 + + if msg == WM_MOUSEMOVE: + if state.dragging: + pt = wt.POINT(); user32.GetCursorPos(ctypes.byref(pt)) + dx = pt.x - state.drag_start[0]; dy = pt.y - state.drag_start[1] + state.grid_pos = (state.drag_offset[0] + dx, state.drag_offset[1] + dy) + redraw_all(False) + return 0 + + if msg == WM_LBUTTONUP: + if state.dragging: + state.dragging = False; user32.ReleaseCapture(); state.save_config() + user32.SetCursor(user32.LoadCursorW(state.h_inst, wt.LPCWSTR(IDC_ARROW))) + return 0 + + if msg == WM_KEYDOWN: + gx, gy = state.grid_pos + step = 10 if (user32.GetKeyState(VK_SHIFT) & 0x8000) else 1 + if w == VK_LEFT: state.grid_pos = (gx - step, gy) + elif w == VK_RIGHT: state.grid_pos = (gx + step, gy) + elif w == VK_UP: state.grid_pos = (gx, gy - step) + elif w == VK_DOWN: state.grid_pos = (gx, gy + step) + if w in (VK_LEFT, VK_RIGHT, VK_UP, VK_DOWN): state.save_config(); redraw_all() + + return user32.DefWindowProcW(hwnd, msg, w, l) + + +def run_paragon_overlay(preset_path: str | None = None) -> None: + global state + preset = preset_path or (sys.argv[1] if len(sys.argv) > 1 else "AffixPresets-v2.json") + try: + builds = load_builds_from_path(preset) + except Exception as e: + # In packaged mode we often suppress stdout/stderr; show a visible error. + LOGGER.exception("Failed to load Paragon preset(s): %s", preset) + _msgbox( + "D4LF Paragon Overlay", + f"Konnte Paragon JSON nicht laden.\n\nQuelle: {preset}\n\nFehler: {e}", + ) + return + + state = AppState(builds) + state.h_inst = kernel32.GetModuleHandleW(None) + + wc = WNDCLASSW(style=0, lpfnWndProc=WndProcList, hInstance=state.h_inst, + hCursor=user32.LoadCursorW(state.h_inst, wt.LPCWSTR(IDC_ARROW)), lpszClassName="D4ListCls") + user32.RegisterClassW(ctypes.byref(wc)) + + wc.lpfnWndProc = WndProcGrid + wc.lpszClassName = "D4GridCls" + user32.RegisterClassW(ctypes.byref(wc)) + + ex_style = WS_EX_TOPMOST | WS_EX_LAYERED | WS_EX_TOOLWINDOW + + # Init default to Top-Left if config not loaded + if state.list_pos == (0,0) and state.grid_pos == (450, 60): + state.list_pos = (0, 0) + state.grid_pos = (600, 50) + + state.hwnd_list = user32.CreateWindowExW(ex_style, "D4ListCls", "List", WS_POPUP|WS_VISIBLE, + 0, state.list_pos[1], 400, 600, None, None, state.h_inst, None) + state.hwnd_grid = user32.CreateWindowExW(ex_style, "D4GridCls", "Grid", WS_POPUP|WS_VISIBLE, + state.grid_pos[0], state.grid_pos[1], 800, 800, None, None, state.h_inst, None) + + redraw_all(True) + msg = wt.MSG() + while user32.GetMessageW(ctypes.byref(msg), 0, 0, 0): + user32.TranslateMessage(ctypes.byref(msg)) + user32.DispatchMessageW(ctypes.byref(msg)) + +if __name__ == "__main__": + run_paragon_overlay() diff --git a/src/scripts/handler.py b/src/scripts/handler.py index 22a4df3d..bed74c71 100644 --- a/src/scripts/handler.py +++ b/src/scripts/handler.py @@ -3,6 +3,9 @@ import threading import time import typing +import subprocess +from contextlib import suppress +from pathlib import Path if sys.platform != "darwin": import keyboard @@ -29,6 +32,8 @@ class ScriptHandler: def __init__(self): self.loot_interaction_thread = None + self.paragon_overlay_proc = None + self._paragon_overlay_log = None if IniConfigLoader().general.vision_mode_type == VisionModeType.fast: self.vision_mode = src.scripts.vision_mode_fast.VisionModeFast() else: @@ -41,9 +46,88 @@ def __init__(self): def _graceful_exit(self): safe_exit() + + def toggle_paragon_overlay(self): + """Toggle the Paragon overlay process (start if not running, stop if running).""" + try: + # If already running -> stop it + if self.paragon_overlay_proc is not None and self.paragon_overlay_proc.poll() is None: + LOGGER.info("Closing Paragon overlay") + with suppress(Exception): + self.paragon_overlay_proc.terminate() + with suppress(Exception): + self.paragon_overlay_proc.wait(timeout=2) + self.paragon_overlay_proc = None + with suppress(Exception): + if self._paragon_overlay_log is not None: + self._paragon_overlay_log.close() + self._paragon_overlay_log = None + return + + config = IniConfigLoader() + overlay_dir_str = getattr(config.advanced_options, "paragon_overlay_source_dir", "") or "" + overlay_dir = Path(overlay_dir_str).expanduser() if str(overlay_dir_str).strip() else (config.user_dir / "paragon") + overlay_dir.mkdir(parents=True, exist_ok=True) + + json_files = list(Path(overlay_dir).glob("*.json")) + if not json_files: + LOGGER.warning( + f"No Paragon JSON files found in {overlay_dir}. Import a build first or place *.json files there." + ) + + # Build command to launch overlay mode + if getattr(sys, "frozen", False): + cmd = [sys.executable, "--paragon-overlay", str(overlay_dir)] + cwd = str(Path(sys.executable).parent) + else: + # From source: ensure project root is cwd so `-m src.main` works reliably + project_root = Path(__file__).resolve().parents[2] + cmd = [sys.executable, "-m", "src.main", "--paragon-overlay", str(overlay_dir)] + cwd = str(project_root) + + creationflags = 0 + startupinfo = None + if sys.platform == "win32": + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) + try: + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + except Exception: + startupinfo = None + + LOGGER.info(f"Opening Paragon overlay (source: {overlay_dir})") + # Capture any overlay errors in a log file (important when console is hidden) + log_path = overlay_dir / "paragon_overlay.log" + try: + self._paragon_overlay_log = open(log_path, "a", encoding="utf-8", errors="ignore") + except Exception: + self._paragon_overlay_log = None + + self.paragon_overlay_proc = subprocess.Popen( + cmd, + cwd=cwd, + stdout=self._paragon_overlay_log or subprocess.DEVNULL, + stderr=self._paragon_overlay_log or subprocess.DEVNULL, + creationflags=creationflags, + startupinfo=startupinfo, + ) + + # If it exits immediately, surface the issue in the D4LF log. + time.sleep(0.2) + if self.paragon_overlay_proc.poll() is not None: + LOGGER.error( + "Paragon overlay exited immediately (code=%s). See log: %s", + self.paragon_overlay_proc.returncode, + log_path, + ) + + except Exception: + LOGGER.exception("Failed to toggle Paragon overlay") + def setup_key_binds(self): keyboard.add_hotkey(IniConfigLoader().advanced_options.run_vision_mode, lambda: self.run_vision_mode()) keyboard.add_hotkey(IniConfigLoader().advanced_options.exit_key, lambda: self._graceful_exit()) + keyboard.add_hotkey(IniConfigLoader().advanced_options.toggle_paragon_overlay, lambda: self.toggle_paragon_overlay()) if not IniConfigLoader().advanced_options.vision_mode_only: keyboard.add_hotkey(IniConfigLoader().advanced_options.run_filter, lambda: self.filter_items()) keyboard.add_hotkey( From 75f736721823c0139b6cafb36ca4f380693efe0d Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sun, 1 Feb 2026 17:16:40 +0100 Subject: [PATCH 02/16] Update importer_config.py Fix ImportConfig defaults (CI) --- src/gui/importer/importer_config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gui/importer/importer_config.py b/src/gui/importer/importer_config.py index 5c6f4385..3684bfcb 100644 --- a/src/gui/importer/importer_config.py +++ b/src/gui/importer/importer_config.py @@ -9,5 +9,5 @@ class ImportConfig: add_to_profiles: bool import_greater_affixes: bool require_greater_affixes: bool - export_paragon: bool - custom_file_name: str | None + export_paragon: bool = False + custom_file_name: str | None = None From c5fb45b42d66250755418fbed9bc829ed52ee3c5 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sun, 1 Feb 2026 18:55:52 +0100 Subject: [PATCH 03/16] Apply review fixes (ordering/imports/readme) --- README.md | 70 ++++++++---------------------- src/config/models.py | 20 ++++----- src/gui/importer/d4builds.py | 9 +--- src/gui/importer/maxroll.py | 13 +----- src/gui/importer/mobalytics.py | 9 +--- src/gui/importer/paragon_export.py | 3 ++ 6 files changed, 33 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index c40e9e2c..174c54f5 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,7 @@ feature request or issue reports join the [discord](https://discord.gg/YyzaPhAN6 - Automatically marks all common, magic, and optionally rare gear as junk - Quickly move items from your stash or inventory - Supported resolutions are all aspect ratios between 16:10 and 21:9 -- Export Paragon boards to overlay-compatible JSON from supported build planners (optional) -- Integrated Paragon Overlay (toggle via hotkey; supports loading a single JSON file or a folder) -- Multi-build Paragon overlay: load multiple JSONs and switch builds inside the overlay +- Paragon Overlay with optional import from supported build planners (Mobalytics, Maxroll, D4Builds) ## How to Setup @@ -50,61 +48,21 @@ feature request or issue reports join the [discord](https://discord.gg/YyzaPhAN6 - Use the hotkeys listed in d4lf.exe to run filtering. By default, F11 will run the loot filter and filter your items. - For most common issues, if something is wrong, you will see an error or warning when you start d4lf.exe. Join our [discord](https://discord.gg/YyzaPhAN6T) for more help. -#### Paragon Import/Export + Integrated Paragon Overlay (this fork) +#### Paragon overlay -This fork adds Paragon board export during build import and an integrated Win32 overlay to display Paragon boards in-game. +D4LF can import Paragon boards from supported build planners and show them in-game using the Paragon overlay. -This is **best-effort** scraping: planner websites can change their HTML and break an importer. If an import suddenly fails, it usually needs a scraper update. +**How to use** +1. Import your build from a supported planner (Mobalytics / Maxroll / D4Builds). +2. Enable **Export Paragon JSON** in the importer (optional) and choose a Paragon folder (or leave the default). +3. Toggle the Paragon overlay using the hotkey (default **F10**, configurable in *Advanced options*). -Exported JSON uses **friendly slugs** (Mobalytics-style, class-prefixed where possible) for board and glyph identifiers to keep names consistent across sources. - -**Export Paragon JSON while importing a build** - -- Open **Import** in the GUI. -- Paste a build link (Mobalytics / Maxroll / D4Builds). -- Enable **"Export Paragon JSON"**. -- Click **Generate**. -- The JSON is written to **`/paragon`** (default: `C:/Users//.d4lf/paragon`). - -**Select your Paragon JSON folder** - -- In the main GUI, click **"Paragon Folder"**. -- Choose the folder that contains your `*.json` Paragon files. -- This sets `advanced_options.paragon_overlay_source_dir` in `params.ini`. - -**Toggle the overlay** - -- Press **F10** (config key: `advanced_options.toggle_paragon_overlay`). - - First press: starts the overlay - - Second press: closes the overlay - -**Run overlay directly (optional CLI mode)** - -- `d4lf.exe --paragon-overlay "C:\\path\\to\\file.json"` -- `d4lf.exe --paragon-overlay "C:\\path\\to\\folder_with_jsons"` - -Notes: -- Borderless windowed is recommended; exclusive fullscreen overlays may not show. -- If the overlay opens off-screen, delete `d4_overlay_config.json` next to `d4lf.exe` to reset window position. - -### Updating an existing installation - -All configurations are stored in a separate location so all you need to do is download the newest version and delete your old version. This can be done manually by downloading from the [releases page](https://github.com/d4lfteam/d4lf/releases) or by running autoupdater.bat. - -Your profiles and configuration should continue to work. The only exception to this is if the major version of the release changes. In that case, a change was made that will make previous profiles no longer work. - -Example 1: You're on version 5.1.14 and updating to 5.2.0. Your profiles will continue to work fine. - -Example 2: You're on version 5.1.14 and updating to 6.0.0. Your profiles will no longer work and you'll need to update or re-import them on the newest version. +**Tips** +- Overlays may not work in exclusive fullscreen; use **borderless windowed** if the overlay does not appear. +- Planner websites can change over time. If an import/export stops working, it may need an importer update. ### Common problems -- Paragon overlay does not appear / does nothing - - Ensure Diablo IV is running in **borderless windowed** (exclusive fullscreen may block overlays). - - Ensure your Paragon folder contains `*.json` files (default: `C:/Users//.d4lf/paragon`). - - Check/adjust `advanced_options.toggle_paragon_overlay` (default `f10`) and ensure it is not conflicting with other hotkeys. - - If the overlay is off-screen, delete `d4_overlay_config.json` next to `d4lf.exe` to reset its position. - - The GUI crashes immediately upon opening, with no error message given - This almost always means there is an issue in your params.ini. Delete the file and then open the GUI and configure your params.ini through the config tab. Using the GUI for configuration will ensure the file is always accurate. @@ -199,6 +157,12 @@ Current functionality: Each window gives further instructions on how to use it and what kind of input it expects. +- Paragon overlay does not appear / does nothing + - Ensure Diablo IV is running in **borderless windowed** (exclusive fullscreen may block overlays). + - Ensure your Paragon folder contains `*.json` files (default: `C:/Users//.d4lf/paragon`). + - Check/adjust `advanced_options.toggle_paragon_overlay` (default `f10`) and ensure it is not conflicting with other hotkeys. + - If the overlay is off-screen, delete `d4_overlay_config.json` next to `d4lf.exe` to reset its position. + ## How to filter / Profiles All profiles define whitelist filters. If no filter included in your profiles matches the item, it will be discarded. @@ -749,4 +713,4 @@ prek run -a - Icon based of: [CarbotAnimations](https://www.youtube.com/carbotanimations/about) - Some of the OCR code is originally from [@gleed](https://github.com/aliig). Good guy - Names and textures for matching from [Blizzard](https://www.blizzard.com) -- Thanks to NekrosStratia for the initial idea and help with TTS mode +- Thanks to NekrosStratia for the initial idea and help with TTS mode \ No newline at end of file diff --git a/src/config/models.py b/src/config/models.py index ee5653ed..fa0af967 100644 --- a/src/config/models.py +++ b/src/config/models.py @@ -180,16 +180,6 @@ class AdvancedOptionsModel(_IniBaseModel): description="If TTS is working for you but you are still receiving the warning, check this box to disable it.", ) exit_key: str = Field(default="f12", description="Hotkey to exit d4lf", json_schema_extra={IS_HOTKEY_KEY: "True"}) - toggle_paragon_overlay: str = Field( - default="f10", - description="Hotkey to open/close the Paragon overlay", - json_schema_extra={IS_HOTKEY_KEY: "True"}, - ) - paragon_overlay_source_dir: str = Field( - default="", - description="Folder containing Paragon JSON files for the Paragon overlay. Leave blank to use the default: ~/.d4lf/paragon", - json_schema_extra={HIDE_FROM_GUI_KEY: "True"}, - ) fast_vision_mode_coordinates: tuple[int, int] | None = Field( default=None, description="The top left coordinates of the desired location of the fast vision mode overlay in pixels. For example: (300, 500). Set to blank for default behavior.", @@ -210,6 +200,11 @@ class AdvancedOptionsModel(_IniBaseModel): description="Hotkey to move configured items from stash to inventory", json_schema_extra={IS_HOTKEY_KEY: "True"}, ) + paragon_overlay_source_dir: str = Field( + default="", + description="Folder containing Paragon JSON files for the Paragon overlay. Leave blank to use the default: ~/.d4lf/paragon", + json_schema_extra={HIDE_FROM_GUI_KEY: "True"}, + ) process_name: str = Field( default="Diablo IV.exe", description="The process that is running Diablo 4. Could help usage when playing through a streaming service like GeForce Now", @@ -225,6 +220,11 @@ class AdvancedOptionsModel(_IniBaseModel): run_vision_mode: str = Field( default="f9", description="Hotkey to enable/disable the vision mode", json_schema_extra={IS_HOTKEY_KEY: "True"} ) + toggle_paragon_overlay: str = Field( + default="f10", + description="Hotkey to open/close the Paragon overlay", + json_schema_extra={IS_HOTKEY_KEY: "True"}, + ) vision_mode_only: bool = Field( default=False, description="Only allow vision mode to run. All hotkeys and actions that click will be disabled." ) diff --git a/src/gui/importer/d4builds.py b/src/gui/importer/d4builds.py index b59c2827..eef3cbf4 100644 --- a/src/gui/importer/d4builds.py +++ b/src/gui/importer/d4builds.py @@ -30,6 +30,7 @@ update_mingreateraffixcount, ) from src.gui.importer.importer_config import ImportConfig +from src.gui.importer.paragon_export import extract_d4builds_paragon_steps, export_paragon_build_json from src.item.data.affix import Affix, AffixType from src.item.data.item_type import WEAPON_TYPES, ItemType from src.item.descr.text import clean_str, closest_match @@ -38,7 +39,6 @@ if TYPE_CHECKING: from selenium.webdriver.chromium.webdriver import ChromiumDriver - LOGGER = logging.getLogger(__name__) BASE_URL = "https://d4builds.gg/builds" @@ -59,11 +59,9 @@ SANCTIFIED_ICON_XPATH = ".//*[contains(@src, 'sanctified_icon.png')]" UNIQUE_ICON_XPATH = ".//*[contains(@src, '/Uniques/')]" - class D4BuildsException(Exception): pass - @retry_importer(inject_webdriver=True) def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): url = config.url.strip().replace("\n", "") @@ -214,7 +212,6 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): add_to_profiles(corrected_file_name) if config.export_paragon: - from src.gui.importer.paragon_export import extract_d4builds_paragon_steps, export_paragon_build_json steps = extract_d4builds_paragon_steps(driver, class_name=class_name) if steps: @@ -229,7 +226,6 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): LOGGER.info("Finished") - def _corrections(input_str: str) -> str: input_str = input_str.lower() match input_str: @@ -241,7 +237,6 @@ def _corrections(input_str: str) -> str: return input_str.replace("ranks to", "to").replace("ranks of", "to").replace("ranks", "to") return input_str - def _get_item_slots(data: lxml.html.HtmlElement) -> dict[str, str]: result = {} if not (paperdoll := data.xpath(PAPERDOLL_XPATH)): @@ -259,7 +254,6 @@ def _get_item_slots(data: lxml.html.HtmlElement) -> dict[str, str]: result[slot] = unique_name[0].text if unique_name else "" return result - def _get_legendary_aspects(data: lxml.html.HtmlElement) -> list[str]: result = [] if not (paperdoll := data.xpath(PAPERDOLL_XPATH)): @@ -279,7 +273,6 @@ def _get_legendary_aspects(data: lxml.html.HtmlElement) -> list[str]: return result - if __name__ == "__main__": src.logger.setup() URLS = ["https://d4builds.gg/builds/e3aab60e-15a0-47ee-99ec-648788901104/?var=1"] diff --git a/src/gui/importer/maxroll.py b/src/gui/importer/maxroll.py index 05304997..828adb33 100644 --- a/src/gui/importer/maxroll.py +++ b/src/gui/importer/maxroll.py @@ -23,6 +23,7 @@ update_mingreateraffixcount, ) from src.gui.importer.importer_config import ImportConfig +from src.gui.importer.paragon_export import extract_maxroll_paragon_steps, export_paragon_build_json from src.item.data.affix import Affix, AffixType from src.item.data.item_type import ItemType from src.item.descr.text import clean_str, closest_match @@ -36,11 +37,9 @@ PLANNER_API_DATA_URL = "https://assets-ng.maxroll.gg/d4-tools/game/data.min.json?7659ec67" PLANNER_BASE_URL = "https://maxroll.gg/d4/planner/" - class MaxrollException(Exception): pass - @retry_importer def import_maxroll(config: ImportConfig): url = config.url.strip().replace("\n", "") @@ -185,7 +184,6 @@ def import_maxroll(config: ImportConfig): add_to_profiles(corrected_file_name) if config.export_paragon: - from src.gui.importer.paragon_export import extract_maxroll_paragon_steps, export_paragon_build_json steps = extract_maxroll_paragon_steps(active_profile) if steps: @@ -200,7 +198,6 @@ def import_maxroll(config: ImportConfig): LOGGER.info("Finished") - def _corrections(input_str: str) -> str: match input_str: case "On_Hit_Vulnerable_Proc_Chance": @@ -209,7 +206,6 @@ def _corrections(input_str: str) -> str: return "Movement_Speed_Bonus_On_Elite_Kill" return input_str - def _find_item_affixes(mapping_data: dict, item_affixes: dict, import_greater_affixes=False) -> list[Affix]: res = [] for affix_id in item_affixes: @@ -293,7 +289,6 @@ def _find_item_affixes(mapping_data: dict, item_affixes: dict, import_greater_af break return res - def _find_legendary_aspect(mapping_data: dict, legendary_aspect: dict) -> str | None: if not legendary_aspect: return None @@ -313,7 +308,6 @@ def _find_legendary_aspect(mapping_data: dict, legendary_aspect: dict) -> str | return None - def _attr_desc_special_handling(affix_id: str) -> str: match affix_id: case 1014505 | 2051010: @@ -335,7 +329,6 @@ def _attr_desc_special_handling(affix_id: str) -> str: case _: return "" - def _unique_name_special_handling(unique_name: str) -> str: match unique_name: case "[PH] Season 7 Necro Pants": @@ -345,7 +338,6 @@ def _unique_name_special_handling(unique_name: str) -> str: case _: return unique_name.replace("\xa0", " ") - def _find_item_type(mapping_data: dict, value: str) -> ItemType | None: for d_key, d_value in mapping_data.items(): if d_key == value: @@ -355,7 +347,6 @@ def _find_item_type(mapping_data: dict, value: str) -> ItemType | None: return res return None - def _extract_planner_url_and_id_from_planner(url: str) -> tuple[str, int]: planner_suffix = url.split(PLANNER_BASE_URL) if len(planner_suffix) != 2: @@ -375,7 +366,6 @@ def _extract_planner_url_and_id_from_planner(url: str) -> tuple[str, int]: data_id = json.loads(r.json()["data"])["activeProfile"] return PLANNER_API_BASE_URL + planner_id, data_id - def _extract_planner_url_and_id_from_guide(url: str) -> tuple[str, int]: try: r = get_with_retry(url=url) @@ -398,7 +388,6 @@ def _extract_planner_url_and_id_from_guide(url: str) -> tuple[str, int]: raise MaxrollException(msg) from ex return PLANNER_API_BASE_URL + planner_id, data_id - if __name__ == "__main__": src.logger.setup() URLS = ["https://maxroll.gg/d4/planner/19390ugy#1"] diff --git a/src/gui/importer/mobalytics.py b/src/gui/importer/mobalytics.py index 881608b5..0566a7d5 100644 --- a/src/gui/importer/mobalytics.py +++ b/src/gui/importer/mobalytics.py @@ -27,6 +27,7 @@ update_mingreateraffixcount, ) from src.gui.importer.importer_config import ImportConfig +from src.gui.importer.paragon_export import extract_mobalytics_paragon_steps, export_paragon_build_json from src.item.data.affix import Affix, AffixType from src.item.data.item_type import WEAPON_TYPES, ItemType from src.item.descr.text import clean_str, closest_match @@ -38,11 +39,9 @@ SCRIPT_XPATH = "//script" BUILD_SCRIPT_PREFIX = "window.__PRELOADED_STATE__=" - class MobalyticsException(Exception): pass - @retry_importer def import_mobalytics(config: ImportConfig): url = config.url.strip().replace("\n", "") @@ -240,7 +239,6 @@ def import_mobalytics(config: ImportConfig): add_to_profiles(corrected_file_name) if config.export_paragon: - from src.gui.importer.paragon_export import extract_mobalytics_paragon_steps, export_paragon_build_json steps = extract_mobalytics_paragon_steps(variant if isinstance(variant, dict) else {}) if steps: @@ -255,18 +253,15 @@ def import_mobalytics(config: ImportConfig): LOGGER.info("Finished") - def _corrections(input_str: str) -> str: match input_str.lower(): case "max life": return "maximum life" return input_str - def _fix_input_url(url: str) -> str: return unquote(url) - def _get_legendary_aspect(name: str) -> str: if "aspect" in name.lower(): aspect_name = correct_name(name.lower().replace("aspect", "").strip()) @@ -279,7 +274,6 @@ def _get_legendary_aspect(name: str) -> str: return aspect_name return "" - def _convert_raw_to_affixes(raw_stats: list[dict], import_greater_affixes=False) -> list[Affix]: result = [] for stat in raw_stats: @@ -292,7 +286,6 @@ def _convert_raw_to_affixes(raw_stats: list[dict], import_greater_affixes=False) result.append(affix_obj) return result - if __name__ == "__main__": src.logger.setup() URLS = [ diff --git a/src/gui/importer/paragon_export.py b/src/gui/importer/paragon_export.py index f2e038cf..1e00e3b3 100644 --- a/src/gui/importer/paragon_export.py +++ b/src/gui/importer/paragon_export.py @@ -44,6 +44,9 @@ def _prefix_with_class_slug(slug: str, class_slug: str) -> str: # Used to export Paragon JSON with readable identifiers (similar to Mobalytics). # --------------------------------------------------------------------------- +# NOTE: These IDs come from Maxroll. If they change, we fall back to using the raw ID. +# Consider resolving board/glyph metadata via d4data in the future. + _MAXROLL_BOARD_ID_TO_NAME = { "Paragon_Barb_00": "Start", "Paragon_Barb_01": "Hemorrhage", From d4429d30b4ff96f81c6dbb994cd2df82b50f3d6e Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sun, 1 Feb 2026 20:25:25 +0100 Subject: [PATCH 04/16] fixed version / Run prek (format/lint) Ran prek locally (uv run prek run -a) and pushed the resulting formatting/lint fixes. CI should now be clean. --- README.md | 12 +- src/config/models.py | 4 +- src/gui/activity_log_widget.py | 2 +- src/gui/importer/d4builds.py | 9 +- src/gui/importer/maxroll.py | 16 +- src/gui/importer/mobalytics.py | 16 +- src/gui/importer/paragon_export.py | 131 +++--- src/gui/unified_window.py | 12 +- src/main.py | 12 +- src/paragon_overlay.py | 613 +++++++++++++++++++---------- src/scripts/handler.py | 45 ++- 11 files changed, 546 insertions(+), 326 deletions(-) diff --git a/README.md b/README.md index 174c54f5..bba19275 100644 --- a/README.md +++ b/README.md @@ -53,11 +53,13 @@ feature request or issue reports join the [discord](https://discord.gg/YyzaPhAN6 D4LF can import Paragon boards from supported build planners and show them in-game using the Paragon overlay. **How to use** + 1. Import your build from a supported planner (Mobalytics / Maxroll / D4Builds). -2. Enable **Export Paragon JSON** in the importer (optional) and choose a Paragon folder (or leave the default). -3. Toggle the Paragon overlay using the hotkey (default **F10**, configurable in *Advanced options*). +1. Enable **Export Paragon JSON** in the importer (optional) and choose a Paragon folder (or leave the default). +1. Toggle the Paragon overlay using the hotkey (default **F10**, configurable in *Advanced options*). **Tips** + - Overlays may not work in exclusive fullscreen; use **borderless windowed** if the overlay does not appear. - Planner websites can change over time. If an import/export stops working, it may need an importer update. @@ -131,8 +133,8 @@ The config folder in `C:/Users//.d4lf` contains: | run_vision_mode | Hotkey to start/stop vision mode | | force_refresh_only | Hotkey to reset all item statuses without running a filter after | | exit_key | Hotkey to exit d4lf.exe | -| toggle_paragon_overlay | Hotkey to open/close the Paragon overlay (default: f10) | -| paragon_overlay_source_dir | Folder containing Paragon JSON files for the overlay. Leave blank to use the default: `~/.d4lf/paragon` | +| toggle_paragon_overlay | Hotkey to open/close the Paragon overlay (default: f10) | +| paragon_overlay_source_dir | Folder containing Paragon JSON files for the overlay. Leave blank to use the default: `~/.d4lf/paragon` | | log_lvl | Logging level. Can be any of [debug, info, warning, error, critical] | | process_name | Process name of the D4 app. Defaults to "Diablo IV.exe". In case of using some remote play this might need to be adapted | | vision_mode_only | If set to true, only the vision mode will be available. All functionality that clicks the screen is disabled. | @@ -713,4 +715,4 @@ prek run -a - Icon based of: [CarbotAnimations](https://www.youtube.com/carbotanimations/about) - Some of the OCR code is originally from [@gleed](https://github.com/aliig). Good guy - Names and textures for matching from [Blizzard](https://www.blizzard.com) -- Thanks to NekrosStratia for the initial idea and help with TTS mode \ No newline at end of file +- Thanks to NekrosStratia for the initial idea and help with TTS mode diff --git a/src/config/models.py b/src/config/models.py index fa0af967..02203dbf 100644 --- a/src/config/models.py +++ b/src/config/models.py @@ -221,9 +221,7 @@ class AdvancedOptionsModel(_IniBaseModel): default="f9", description="Hotkey to enable/disable the vision mode", json_schema_extra={IS_HOTKEY_KEY: "True"} ) toggle_paragon_overlay: str = Field( - default="f10", - description="Hotkey to open/close the Paragon overlay", - json_schema_extra={IS_HOTKEY_KEY: "True"}, + default="f10", description="Hotkey to open/close the Paragon overlay", json_schema_extra={IS_HOTKEY_KEY: "True"} ) vision_mode_only: bool = Field( default=False, description="Only allow vision mode to run. All hotkeys and actions that click will be disabled." diff --git a/src/gui/activity_log_widget.py b/src/gui/activity_log_widget.py index ed834ebc..18888edb 100644 --- a/src/gui/activity_log_widget.py +++ b/src/gui/activity_log_widget.py @@ -89,4 +89,4 @@ def __init__(self, parent=None): self.editor_btn.clicked.connect(self.parent().open_profile_editor) self.paragon_overlay_btn.clicked.connect(self.parent().open_paragon_overlay) - self.main_layout.addLayout(button_layout) \ No newline at end of file + self.main_layout.addLayout(button_layout) diff --git a/src/gui/importer/d4builds.py b/src/gui/importer/d4builds.py index eef3cbf4..e8305372 100644 --- a/src/gui/importer/d4builds.py +++ b/src/gui/importer/d4builds.py @@ -30,7 +30,7 @@ update_mingreateraffixcount, ) from src.gui.importer.importer_config import ImportConfig -from src.gui.importer.paragon_export import extract_d4builds_paragon_steps, export_paragon_build_json +from src.gui.importer.paragon_export import export_paragon_build_json, extract_d4builds_paragon_steps from src.item.data.affix import Affix, AffixType from src.item.data.item_type import WEAPON_TYPES, ItemType from src.item.descr.text import clean_str, closest_match @@ -59,9 +59,11 @@ SANCTIFIED_ICON_XPATH = ".//*[contains(@src, 'sanctified_icon.png')]" UNIQUE_ICON_XPATH = ".//*[contains(@src, '/Uniques/')]" + class D4BuildsException(Exception): pass + @retry_importer(inject_webdriver=True) def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): url = config.url.strip().replace("\n", "") @@ -212,7 +214,6 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): add_to_profiles(corrected_file_name) if config.export_paragon: - steps = extract_d4builds_paragon_steps(driver, class_name=class_name) if steps: export_paragon_build_json( @@ -226,6 +227,7 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): LOGGER.info("Finished") + def _corrections(input_str: str) -> str: input_str = input_str.lower() match input_str: @@ -237,6 +239,7 @@ def _corrections(input_str: str) -> str: return input_str.replace("ranks to", "to").replace("ranks of", "to").replace("ranks", "to") return input_str + def _get_item_slots(data: lxml.html.HtmlElement) -> dict[str, str]: result = {} if not (paperdoll := data.xpath(PAPERDOLL_XPATH)): @@ -254,6 +257,7 @@ def _get_item_slots(data: lxml.html.HtmlElement) -> dict[str, str]: result[slot] = unique_name[0].text if unique_name else "" return result + def _get_legendary_aspects(data: lxml.html.HtmlElement) -> list[str]: result = [] if not (paperdoll := data.xpath(PAPERDOLL_XPATH)): @@ -273,6 +277,7 @@ def _get_legendary_aspects(data: lxml.html.HtmlElement) -> list[str]: return result + if __name__ == "__main__": src.logger.setup() URLS = ["https://d4builds.gg/builds/e3aab60e-15a0-47ee-99ec-648788901104/?var=1"] diff --git a/src/gui/importer/maxroll.py b/src/gui/importer/maxroll.py index 828adb33..3325f476 100644 --- a/src/gui/importer/maxroll.py +++ b/src/gui/importer/maxroll.py @@ -23,7 +23,7 @@ update_mingreateraffixcount, ) from src.gui.importer.importer_config import ImportConfig -from src.gui.importer.paragon_export import extract_maxroll_paragon_steps, export_paragon_build_json +from src.gui.importer.paragon_export import export_paragon_build_json, extract_maxroll_paragon_steps from src.item.data.affix import Affix, AffixType from src.item.data.item_type import ItemType from src.item.descr.text import clean_str, closest_match @@ -37,9 +37,11 @@ PLANNER_API_DATA_URL = "https://assets-ng.maxroll.gg/d4-tools/game/data.min.json?7659ec67" PLANNER_BASE_URL = "https://maxroll.gg/d4/planner/" + class MaxrollException(Exception): pass + @retry_importer def import_maxroll(config: ImportConfig): url = config.url.strip().replace("\n", "") @@ -184,7 +186,6 @@ def import_maxroll(config: ImportConfig): add_to_profiles(corrected_file_name) if config.export_paragon: - steps = extract_maxroll_paragon_steps(active_profile) if steps: export_paragon_build_json( @@ -198,6 +199,7 @@ def import_maxroll(config: ImportConfig): LOGGER.info("Finished") + def _corrections(input_str: str) -> str: match input_str: case "On_Hit_Vulnerable_Proc_Chance": @@ -206,6 +208,7 @@ def _corrections(input_str: str) -> str: return "Movement_Speed_Bonus_On_Elite_Kill" return input_str + def _find_item_affixes(mapping_data: dict, item_affixes: dict, import_greater_affixes=False) -> list[Affix]: res = [] for affix_id in item_affixes: @@ -289,6 +292,7 @@ def _find_item_affixes(mapping_data: dict, item_affixes: dict, import_greater_af break return res + def _find_legendary_aspect(mapping_data: dict, legendary_aspect: dict) -> str | None: if not legendary_aspect: return None @@ -308,6 +312,7 @@ def _find_legendary_aspect(mapping_data: dict, legendary_aspect: dict) -> str | return None + def _attr_desc_special_handling(affix_id: str) -> str: match affix_id: case 1014505 | 2051010: @@ -329,6 +334,7 @@ def _attr_desc_special_handling(affix_id: str) -> str: case _: return "" + def _unique_name_special_handling(unique_name: str) -> str: match unique_name: case "[PH] Season 7 Necro Pants": @@ -338,6 +344,7 @@ def _unique_name_special_handling(unique_name: str) -> str: case _: return unique_name.replace("\xa0", " ") + def _find_item_type(mapping_data: dict, value: str) -> ItemType | None: for d_key, d_value in mapping_data.items(): if d_key == value: @@ -347,6 +354,7 @@ def _find_item_type(mapping_data: dict, value: str) -> ItemType | None: return res return None + def _extract_planner_url_and_id_from_planner(url: str) -> tuple[str, int]: planner_suffix = url.split(PLANNER_BASE_URL) if len(planner_suffix) != 2: @@ -366,6 +374,7 @@ def _extract_planner_url_and_id_from_planner(url: str) -> tuple[str, int]: data_id = json.loads(r.json()["data"])["activeProfile"] return PLANNER_API_BASE_URL + planner_id, data_id + def _extract_planner_url_and_id_from_guide(url: str) -> tuple[str, int]: try: r = get_with_retry(url=url) @@ -388,6 +397,7 @@ def _extract_planner_url_and_id_from_guide(url: str) -> tuple[str, int]: raise MaxrollException(msg) from ex return PLANNER_API_BASE_URL + planner_id, data_id + if __name__ == "__main__": src.logger.setup() URLS = ["https://maxroll.gg/d4/planner/19390ugy#1"] @@ -402,4 +412,4 @@ def _extract_planner_url_and_id_from_guide(url: str) -> tuple[str, int]: export_paragon=False, custom_file_name=None, ) - import_maxroll(config) \ No newline at end of file + import_maxroll(config) diff --git a/src/gui/importer/mobalytics.py b/src/gui/importer/mobalytics.py index 0566a7d5..e05acd1c 100644 --- a/src/gui/importer/mobalytics.py +++ b/src/gui/importer/mobalytics.py @@ -27,7 +27,7 @@ update_mingreateraffixcount, ) from src.gui.importer.importer_config import ImportConfig -from src.gui.importer.paragon_export import extract_mobalytics_paragon_steps, export_paragon_build_json +from src.gui.importer.paragon_export import export_paragon_build_json, extract_mobalytics_paragon_steps from src.item.data.affix import Affix, AffixType from src.item.data.item_type import WEAPON_TYPES, ItemType from src.item.descr.text import clean_str, closest_match @@ -39,9 +39,11 @@ SCRIPT_XPATH = "//script" BUILD_SCRIPT_PREFIX = "window.__PRELOADED_STATE__=" + class MobalyticsException(Exception): pass + @retry_importer def import_mobalytics(config: ImportConfig): url = config.url.strip().replace("\n", "") @@ -101,13 +103,11 @@ def import_mobalytics(config: ImportConfig): try: if variant_id: variant = jsonpath.findall( - f"$..['{root_document_name}'].data.buildVariants.values[?@.id=='{variant_id}']", - full_script_data_json, + f"$..['{root_document_name}'].data.buildVariants.values[?@.id=='{variant_id}']", full_script_data_json )[0] else: variant = jsonpath.findall( - f"$..['{root_document_name}'].data.buildVariants.values[0]", - full_script_data_json, + f"$..['{root_document_name}'].data.buildVariants.values[0]", full_script_data_json )[0] except Exception: variant = {} @@ -239,7 +239,6 @@ def import_mobalytics(config: ImportConfig): add_to_profiles(corrected_file_name) if config.export_paragon: - steps = extract_mobalytics_paragon_steps(variant if isinstance(variant, dict) else {}) if steps: export_paragon_build_json( @@ -253,15 +252,18 @@ def import_mobalytics(config: ImportConfig): LOGGER.info("Finished") + def _corrections(input_str: str) -> str: match input_str.lower(): case "max life": return "maximum life" return input_str + def _fix_input_url(url: str) -> str: return unquote(url) + def _get_legendary_aspect(name: str) -> str: if "aspect" in name.lower(): aspect_name = correct_name(name.lower().replace("aspect", "").strip()) @@ -274,6 +276,7 @@ def _get_legendary_aspect(name: str) -> str: return aspect_name return "" + def _convert_raw_to_affixes(raw_stats: list[dict], import_greater_affixes=False) -> list[Affix]: result = [] for stat in raw_stats: @@ -286,6 +289,7 @@ def _convert_raw_to_affixes(raw_stats: list[dict], import_greater_affixes=False) result.append(affix_obj) return result + if __name__ == "__main__": src.logger.setup() URLS = [ diff --git a/src/gui/importer/paragon_export.py b/src/gui/importer/paragon_export.py index 1e00e3b3..e4b6e3f1 100644 --- a/src/gui/importer/paragon_export.py +++ b/src/gui/importer/paragon_export.py @@ -5,13 +5,21 @@ import logging import re import time -from pathlib import Path -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from src import __version__ from src.config.loader import IniConfigLoader +try: + from selenium.webdriver.common.by import By + from selenium.webdriver.support.ui import WebDriverWait +except ImportError: # pragma: no cover + By = None # type: ignore[assignment] + WebDriverWait = None # type: ignore[assignment] + if TYPE_CHECKING: + from pathlib import Path + from selenium.webdriver.remote.webdriver import WebDriver @@ -20,9 +28,7 @@ def _class_slug_from_name(class_name: str) -> str: if not class_name or class_name == "unknown": return "" # normalize spaces/underscores - class_name = re.sub(r"[\s_]+", "-", class_name) - class_name = re.sub(r"[^a-z0-9\-]", "", class_name) - return class_name + return re.sub(r"[^a-z0-9\-]", "", re.sub(r"[\s_]+", "-", class_name)) def _prefix_with_class_slug(slug: str, class_slug: str) -> str: @@ -33,6 +39,8 @@ def _prefix_with_class_slug(slug: str, class_slug: str) -> str: if slug.startswith(class_slug + "-"): return slug return f"{class_slug}-{slug}" + + LOGGER = logging.getLogger(__name__) GRID = 21 @@ -44,9 +52,6 @@ def _prefix_with_class_slug(slug: str, class_slug: str) -> str: # Used to export Paragon JSON with readable identifiers (similar to Mobalytics). # --------------------------------------------------------------------------- -# NOTE: These IDs come from Maxroll. If they change, we fall back to using the raw ID. -# Consider resolving board/glyph metadata via d4data in the future. - _MAXROLL_BOARD_ID_TO_NAME = { "Paragon_Barb_00": "Start", "Paragon_Barb_01": "Hemorrhage", @@ -287,12 +292,8 @@ def _maxroll_glyph_slug(glyph_id: str, board_id: str) -> str: return f"{cls}-{name_slug}" if cls and name_slug else _slugify(glyph_id) - def export_paragon_build_json( - file_stem: str, - build_name: str, - source_url: str, - paragon_boards_list: list[list[dict[str, Any]]], + file_stem: str, build_name: str, source_url: str, paragon_boards_list: list[list[dict[str, Any]]] ) -> Path: """Write a D4Companion-compatible JSON containing Name + ParagonBoardsList. @@ -338,25 +339,25 @@ def extract_maxroll_paragon_steps(active_profile: dict[str, Any]) -> list[list[d nodes_bool = [False] * NODES_LEN nodes_dict = (bd or {}).get("nodes") or {} - for loc_key in nodes_dict.keys(): + for loc_key in nodes_dict: try: loc = int(loc_key) - except Exception: + except TypeError, ValueError: + loc = None + if loc is None: continue idx = _transform_maxroll_location(loc=loc, rotation=rotation) if 0 <= idx < NODES_LEN: nodes_bool[idx] = True - boards_out.append( - { - "Name": _maxroll_board_slug(board_id), - "Glyph": _maxroll_glyph_slug(glyph_id, board_id) if glyph_id else "", - "Rotation": _rotation_info_maxroll(rotation), - "Nodes": nodes_bool, - "BoardId": board_id, - "GlyphId": glyph_id, - } - ) + boards_out.append({ + "Name": _maxroll_board_slug(board_id), + "Glyph": _maxroll_glyph_slug(glyph_id, board_id) if glyph_id else "", + "Rotation": _rotation_info_maxroll(rotation), + "Nodes": nodes_bool, + "BoardId": board_id, + "GlyphId": glyph_id, + }) if boards_out: steps_out.append(boards_out) @@ -386,9 +387,7 @@ def extract_mobalytics_paragon_steps(variant: dict[str, Any]) -> list[list[dict[ board_nodes = [ n for n in nodes_data - if isinstance(n, dict) - and isinstance(n.get("slug"), str) - and n["slug"].startswith(board_slug) + if isinstance(n, dict) and isinstance(n.get("slug"), str) and n["slug"].startswith(board_slug) ] for n in board_nodes: @@ -398,21 +397,22 @@ def extract_mobalytics_paragon_steps(variant: dict[str, Any]) -> list[list[dict[ x_part, y_part = node_position.split("-", 1) x = int(x_part.lstrip("x")) y = int(y_part.lstrip("y")) - except Exception: + except ValueError, IndexError: + x = None + y = None + if x is None or y is None: continue idx = _transform_xy_common(x=x, y=y, rotation_deg=rotation, base="mobalytics") if 0 <= idx < NODES_LEN: nodes_bool[idx] = True - boards_out.append( - { - "Name": board_slug, - "Glyph": glyph_slug, - "Rotation": _rotation_info_degrees(rotation), - "Nodes": nodes_bool, - } - ) + boards_out.append({ + "Name": board_slug, + "Glyph": glyph_slug, + "Rotation": _rotation_info_degrees(rotation), + "Nodes": nodes_bool, + }) return [boards_out] if boards_out else [] @@ -427,12 +427,9 @@ def extract_d4builds_paragon_steps(driver: WebDriver, class_name: str = "") -> l """ class_slug = _class_slug_from_name(class_name) - try: - from selenium.webdriver.common.by import By - from selenium.webdriver.support.ui import WebDriverWait - except Exception as exc: # pragma: no cover - LOGGER.error("Selenium not available, cannot export D4Builds paragon") - raise exc + if By is None or WebDriverWait is None: # pragma: no cover + msg = "Selenium not available, cannot export D4Builds paragon" + raise RuntimeError(msg) # Wait until build is loaded (renameBuild has a non-empty value) try: @@ -447,7 +444,7 @@ def _has_build_name(drv): wait.until(_has_build_name) except Exception: - pass + LOGGER.debug("Unable to confirm D4Builds build name (continuing).", exc_info=True) # Switch to Paragon tab (D4Builds uses left navigation links) try: @@ -461,14 +458,13 @@ def _has_build_name(drv): time.sleep(0.25) except Exception: # Not fatal: sometimes paragon is already visible or site changed - pass - + LOGGER.debug("Could not click Paragon tab (continuing).", exc_info=True) # Wait for paragon boards to appear (best effort) try: wait = WebDriverWait(driver, 10) wait.until(lambda d: len(d.find_elements(By.CLASS_NAME, "paragon__board")) > 0) except Exception: - pass + LOGGER.debug("Timed out waiting for D4Builds paragon boards (continuing).", exc_info=True) boards_out: list[dict[str, Any]] = [] try: @@ -509,7 +505,7 @@ def _has_build_name(drv): board_id = vv break except Exception: - pass + LOGGER.debug("Failed to infer board id (continuing).", exc_info=True) name_slug = _slugify(board_id or name_display) name_slug = _prefix_with_class_slug(name_slug, class_slug) @@ -522,7 +518,7 @@ def _has_build_name(drv): if glyph_elems: glyph_raw = (glyph_elems[0].get_attribute("innerText") or "").strip() except Exception: - pass + LOGGER.debug("Failed to read glyph name (continuing).", exc_info=True) glyph_display = (glyph_raw or "").replace("(", "").replace(")", "").strip() glyph_slug = _slugify(glyph_display) @@ -546,18 +542,15 @@ def _has_build_name(drv): tile_elems = [] for tile in tile_elems: - try: - cls = tile.get_attribute("class") or "" - if "active" not in cls: - continue - parts = [pp for pp in cls.split() if pp] - # Example: "paragon__board__tile r2 c10 active enabled" - r_part = next((x for x in parts if x.startswith("r")), "r0") - c_part = next((x for x in parts if x.startswith("c")), "c0") - r = int("".join(ch for ch in r_part if ch.isdigit()) or "0") - c = int("".join(ch for ch in c_part if ch.isdigit()) or "0") - except Exception: + cls = tile.get_attribute("class") or "" + if "active" not in cls: continue + parts = [pp for pp in cls.split() if pp] + # Example: "paragon__board__tile r2 c10 active enabled" + r_part = next((x for x in parts if x.startswith("r")), "r0") + c_part = next((x for x in parts if x.startswith("c")), "c0") + r = int("".join(ch for ch in r_part if ch.isdigit()) or "0") + c = int("".join(ch for ch in c_part if ch.isdigit()) or "0") # Transform coordinates based on rotation (matching Diablo4Companion) x = c @@ -578,20 +571,19 @@ def _has_build_name(drv): if 0 <= x < 21 and 0 <= y < 21: nodes[y * 21 + x] = True - boards_out.append( - { - "Name": name_slug or "paragon-board", - "Glyph": glyph_slug, - "Rotation": f"{rotate_int}°" if rotate_int in (0, 90, 180, 270) else "0°", - "Nodes": nodes, - } - ) + boards_out.append({ + "Name": name_slug or "paragon-board", + "Glyph": glyph_slug, + "Rotation": f"{rotate_int}°" if rotate_int in (0, 90, 180, 270) else "0°", + "Nodes": nodes, + }) return [boards_out] # --- Helper functions (ported from Diablo4Companion) --- + def _rotation_info_maxroll(rot: int) -> str: return {0: "0°", 1: "90°", 2: "180°", 3: "270°"}.get(rot, "?°") @@ -671,7 +663,8 @@ def _transform_xy_common(x: int, y: int, rotation_deg: int, base: str) -> int: def _fix_mobalytics_starting_board_slug(board_slug: str) -> str: # Fix naming inconsistency (ported from Diablo4Companion) return ( - board_slug.replace("barbarian-starter-board", "barbarian-starting-board") + board_slug + .replace("barbarian-starter-board", "barbarian-starting-board") .replace("druid-starter-board", "druid-starting-board") .replace("necromancer-starter-board", "necromancer-starting-board") .replace("paladin-starter-board", "paladin-starting-board") diff --git a/src/gui/unified_window.py b/src/gui/unified_window.py index 3e5bb517..400527d7 100644 --- a/src/gui/unified_window.py +++ b/src/gui/unified_window.py @@ -1,7 +1,6 @@ import logging import re import sys -import subprocess import time from contextlib import suppress from pathlib import Path @@ -188,10 +187,7 @@ def __init__(self): # Ensure file logging stays enabled. unified_window previously removed all handlers (including the file handler), # which stopped live log writing to d4lf/logs. if not any(getattr(h, "name", "") == "D4LF_FILE" for h in root_logger.handlers): - setup_logging( - log_level=config.advanced_options.log_lvl.value, - enable_stdout=running_from_source, - ) + setup_logging(log_level=config.advanced_options.log_lvl.value, enable_stdout=running_from_source) # Remove existing handlers, but keep file handler and (optionally) stdout when running from source for h in list(root_logger.handlers): @@ -350,9 +346,7 @@ def open_paragon_overlay(self): default_dir.mkdir(parents=True, exist_ok=True) folder = QFileDialog.getExistingDirectory( - self, - "Select Paragon folder (source for Paragon overlay JSON files)", - str(default_dir), + self, "Select Paragon folder (source for Paragon overlay JSON files)", str(default_dir) ) if not folder: return @@ -434,4 +428,4 @@ def emit_startup_direct_to_console(self): def apply_theme(self): theme_name = IniConfigLoader().general.theme stylesheet = DARK_THEME if theme_name == "dark" else LIGHT_THEME - QApplication.instance().setStyleSheet(stylesheet) \ No newline at end of file + QApplication.instance().setStyleSheet(stylesheet) diff --git a/src/main.py b/src/main.py index 16de14b8..fdd7b30c 100644 --- a/src/main.py +++ b/src/main.py @@ -1,3 +1,4 @@ +import contextlib import ctypes import logging import os @@ -198,21 +199,18 @@ def hide_console(): preset_path = sys.argv[2] if len(sys.argv) > 2 else None try: from src.paragon_overlay import run_paragon_overlay + run_paragon_overlay(preset_path) except Exception as e: - import logging - - logging.getLogger(__name__).exception("Paragon overlay crashed") + LOGGER.exception("Paragon overlay crashed") if sys.platform == "win32": - try: + with contextlib.suppress(Exception): ctypes.windll.user32.MessageBoxW( None, f"Paragon overlay ist abgestürzt.\n\nQuelle: {preset_path}\n\nFehler: {e}", "D4LF Paragon Overlay", 0, ) - except Exception: - pass elif len(sys.argv) > 1 and sys.argv[1] == "--consoleonly": # Console-only mode: keep console visible @@ -235,4 +233,4 @@ def hide_console(): app.setWindowIcon(QIcon(str(ICON_PATH))) window = UnifiedMainWindow() window.show() - sys.exit(app.exec()) \ No newline at end of file + sys.exit(app.exec()) diff --git a/src/paragon_overlay.py b/src/paragon_overlay.py index 696f0787..9abe09c0 100644 --- a/src/paragon_overlay.py +++ b/src/paragon_overlay.py @@ -19,14 +19,15 @@ # [Scroll Wheel]: Zoom # [Drag]: Move +import contextlib import ctypes import ctypes.wintypes as wt import json +import logging import re -import os import sys -import logging from pathlib import Path + from PIL import Image, ImageDraw, ImageFont # --- HARDENED WIN32 DEFINITIONS (64-BIT SAFE) --- @@ -35,67 +36,150 @@ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) # Explicit types -HANDLE = ctypes.c_void_p -HWND = ctypes.c_void_p -HDC = ctypes.c_void_p -HBITMAP = ctypes.c_void_p -HGDIOBJ = ctypes.c_void_p -HICON = ctypes.c_void_p -HCURSOR = ctypes.c_void_p -HBRUSH = ctypes.c_void_p -HMENU = ctypes.c_void_p -HINSTANCE = ctypes.c_void_p -LPVOID = ctypes.c_void_p -LPARAM = ctypes.c_longlong -WPARAM = ctypes.c_ulonglong -LRESULT = ctypes.c_longlong +HANDLE = ctypes.c_void_p +HWND = ctypes.c_void_p +HDC = ctypes.c_void_p +HBITMAP = ctypes.c_void_p +HGDIOBJ = ctypes.c_void_p +HICON = ctypes.c_void_p +HCURSOR = ctypes.c_void_p +HBRUSH = ctypes.c_void_p +HMENU = ctypes.c_void_p +HINSTANCE = ctypes.c_void_p +LPVOID = ctypes.c_void_p +LPARAM = ctypes.c_longlong +WPARAM = ctypes.c_ulonglong +LRESULT = ctypes.c_longlong WNDPROCTYPE = ctypes.WINFUNCTYPE(LRESULT, HWND, wt.UINT, WPARAM, LPARAM) + class WNDCLASSW(ctypes.Structure): - _fields_ = [("style", wt.UINT), ("lpfnWndProc", WNDPROCTYPE), ("cbClsExtra", ctypes.c_int), - ("cbWndExtra", ctypes.c_int), ("hInstance", HINSTANCE), ("hIcon", HICON), - ("hCursor", HCURSOR), ("hbrBackground", HBRUSH), ("lpszMenuName", wt.LPCWSTR), - ("lpszClassName", wt.LPCWSTR)] + _fields_ = [ + ("style", wt.UINT), + ("lpfnWndProc", WNDPROCTYPE), + ("cbClsExtra", ctypes.c_int), + ("cbWndExtra", ctypes.c_int), + ("hInstance", HINSTANCE), + ("hIcon", HICON), + ("hCursor", HCURSOR), + ("hbrBackground", HBRUSH), + ("lpszMenuName", wt.LPCWSTR), + ("lpszClassName", wt.LPCWSTR), + ] + class BLENDFUNCTION(ctypes.Structure): - _fields_ = [("BlendOp", wt.BYTE), ("BlendFlags", wt.BYTE), ("SourceConstantAlpha", wt.BYTE), ("AlphaFormat", wt.BYTE)] + _fields_ = [ + ("BlendOp", wt.BYTE), + ("BlendFlags", wt.BYTE), + ("SourceConstantAlpha", wt.BYTE), + ("AlphaFormat", wt.BYTE), + ] + class BITMAPINFOHEADER(ctypes.Structure): - _fields_ = [("biSize", wt.DWORD), ("biWidth", wt.LONG), ("biHeight", wt.LONG), ("biPlanes", wt.WORD), - ("biBitCount", wt.WORD), ("biCompression", wt.DWORD), ("biSizeImage", wt.DWORD), - ("biXPelsPerMeter", wt.LONG), ("biYPelsPerMeter", wt.LONG), ("biClrUsed", wt.DWORD), ("biClrImportant", wt.DWORD)] + _fields_ = [ + ("biSize", wt.DWORD), + ("biWidth", wt.LONG), + ("biHeight", wt.LONG), + ("biPlanes", wt.WORD), + ("biBitCount", wt.WORD), + ("biCompression", wt.DWORD), + ("biSizeImage", wt.DWORD), + ("biXPelsPerMeter", wt.LONG), + ("biYPelsPerMeter", wt.LONG), + ("biClrUsed", wt.DWORD), + ("biClrImportant", wt.DWORD), + ] + class BITMAPINFO(ctypes.Structure): _fields_ = [("bmiHeader", BITMAPINFOHEADER), ("bmiColors", ctypes.c_ulong * 3)] + # --- API Signatures --- -kernel32.GetModuleHandleW.argtypes = [wt.LPCWSTR]; kernel32.GetModuleHandleW.restype = HINSTANCE -user32.RegisterClassW.argtypes = [ctypes.POINTER(WNDCLASSW)]; user32.RegisterClassW.restype = wt.ATOM -user32.CreateWindowExW.argtypes = [wt.DWORD, wt.LPCWSTR, wt.LPCWSTR, wt.DWORD, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, HWND, HMENU, HINSTANCE, LPVOID]; user32.CreateWindowExW.restype = HWND -user32.DefWindowProcW.argtypes = [HWND, wt.UINT, WPARAM, LPARAM]; user32.DefWindowProcW.restype = LRESULT -user32.UpdateLayeredWindow.argtypes = [HWND, HDC, ctypes.POINTER(wt.POINT), ctypes.POINTER(wt.SIZE), HDC, ctypes.POINTER(wt.POINT), wt.COLORREF, ctypes.POINTER(BLENDFUNCTION), wt.DWORD]; user32.UpdateLayeredWindow.restype = wt.BOOL -user32.GetDC.argtypes = [HWND]; user32.GetDC.restype = HDC -user32.ReleaseDC.argtypes = [HWND, HDC]; user32.ReleaseDC.restype = ctypes.c_int -user32.PostQuitMessage.argtypes = [ctypes.c_int]; user32.PostQuitMessage.restype = None -user32.SetFocus.argtypes = [HWND]; user32.SetFocus.restype = HWND -user32.GetKeyState.argtypes = [ctypes.c_int]; user32.GetKeyState.restype = wt.SHORT -user32.GetSystemMetrics.argtypes = [ctypes.c_int]; user32.GetSystemMetrics.restype = ctypes.c_int -user32.LoadCursorW.argtypes = [HINSTANCE, wt.LPCWSTR]; user32.LoadCursorW.restype = HCURSOR -user32.GetMessageW.argtypes = [ctypes.POINTER(wt.MSG), HWND, wt.UINT, wt.UINT]; user32.GetMessageW.restype = wt.BOOL -user32.TranslateMessage.argtypes = [ctypes.POINTER(wt.MSG)]; user32.TranslateMessage.restype = wt.BOOL -user32.DispatchMessageW.argtypes = [ctypes.POINTER(wt.MSG)]; user32.DispatchMessageW.restype = LRESULT -user32.SetCapture.argtypes = [HWND]; user32.SetCapture.restype = HWND -user32.ReleaseCapture.argtypes = []; user32.ReleaseCapture.restype = wt.BOOL -user32.GetCursorPos.argtypes = [ctypes.POINTER(wt.POINT)]; user32.GetCursorPos.restype = wt.BOOL -user32.SetCursor.argtypes = [HCURSOR]; user32.SetCursor.restype = HCURSOR -user32.SetWindowPos.argtypes = [HWND, HWND, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, wt.UINT]; user32.SetWindowPos.restype = wt.BOOL - -gdi32.CreateCompatibleDC.argtypes = [HDC]; gdi32.CreateCompatibleDC.restype = HDC -gdi32.SelectObject.argtypes = [HDC, HGDIOBJ]; gdi32.SelectObject.restype = HGDIOBJ -gdi32.DeleteDC.argtypes = [HDC]; gdi32.DeleteDC.restype = wt.BOOL -gdi32.DeleteObject.argtypes = [HGDIOBJ]; gdi32.DeleteObject.restype = wt.BOOL -gdi32.CreateDIBSection.argtypes = [HDC, ctypes.POINTER(BITMAPINFO), wt.UINT, ctypes.POINTER(ctypes.c_void_p), HANDLE, wt.DWORD]; gdi32.CreateDIBSection.restype = HBITMAP +kernel32.GetModuleHandleW.argtypes = [wt.LPCWSTR] +kernel32.GetModuleHandleW.restype = HINSTANCE +user32.RegisterClassW.argtypes = [ctypes.POINTER(WNDCLASSW)] +user32.RegisterClassW.restype = wt.ATOM +user32.CreateWindowExW.argtypes = [ + wt.DWORD, + wt.LPCWSTR, + wt.LPCWSTR, + wt.DWORD, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + HWND, + HMENU, + HINSTANCE, + LPVOID, +] +user32.CreateWindowExW.restype = HWND +user32.DefWindowProcW.argtypes = [HWND, wt.UINT, WPARAM, LPARAM] +user32.DefWindowProcW.restype = LRESULT +user32.UpdateLayeredWindow.argtypes = [ + HWND, + HDC, + ctypes.POINTER(wt.POINT), + ctypes.POINTER(wt.SIZE), + HDC, + ctypes.POINTER(wt.POINT), + wt.COLORREF, + ctypes.POINTER(BLENDFUNCTION), + wt.DWORD, +] +user32.UpdateLayeredWindow.restype = wt.BOOL +user32.GetDC.argtypes = [HWND] +user32.GetDC.restype = HDC +user32.ReleaseDC.argtypes = [HWND, HDC] +user32.ReleaseDC.restype = ctypes.c_int +user32.PostQuitMessage.argtypes = [ctypes.c_int] +user32.PostQuitMessage.restype = None +user32.SetFocus.argtypes = [HWND] +user32.SetFocus.restype = HWND +user32.GetKeyState.argtypes = [ctypes.c_int] +user32.GetKeyState.restype = wt.SHORT +user32.GetSystemMetrics.argtypes = [ctypes.c_int] +user32.GetSystemMetrics.restype = ctypes.c_int +user32.LoadCursorW.argtypes = [HINSTANCE, wt.LPCWSTR] +user32.LoadCursorW.restype = HCURSOR +user32.GetMessageW.argtypes = [ctypes.POINTER(wt.MSG), HWND, wt.UINT, wt.UINT] +user32.GetMessageW.restype = wt.BOOL +user32.TranslateMessage.argtypes = [ctypes.POINTER(wt.MSG)] +user32.TranslateMessage.restype = wt.BOOL +user32.DispatchMessageW.argtypes = [ctypes.POINTER(wt.MSG)] +user32.DispatchMessageW.restype = LRESULT +user32.SetCapture.argtypes = [HWND] +user32.SetCapture.restype = HWND +user32.ReleaseCapture.argtypes = [] +user32.ReleaseCapture.restype = wt.BOOL +user32.GetCursorPos.argtypes = [ctypes.POINTER(wt.POINT)] +user32.GetCursorPos.restype = wt.BOOL +user32.SetCursor.argtypes = [HCURSOR] +user32.SetCursor.restype = HCURSOR +user32.SetWindowPos.argtypes = [HWND, HWND, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, wt.UINT] +user32.SetWindowPos.restype = wt.BOOL + +gdi32.CreateCompatibleDC.argtypes = [HDC] +gdi32.CreateCompatibleDC.restype = HDC +gdi32.SelectObject.argtypes = [HDC, HGDIOBJ] +gdi32.SelectObject.restype = HGDIOBJ +gdi32.DeleteDC.argtypes = [HDC] +gdi32.DeleteDC.restype = wt.BOOL +gdi32.DeleteObject.argtypes = [HGDIOBJ] +gdi32.DeleteObject.restype = wt.BOOL +gdi32.CreateDIBSection.argtypes = [ + HDC, + ctypes.POINTER(BITMAPINFO), + wt.UINT, + ctypes.POINTER(ctypes.c_void_p), + HANDLE, + wt.DWORD, +] +gdi32.CreateDIBSection.restype = HBITMAP # --- Constants --- WS_POPUP = 0x80000000 @@ -132,52 +216,52 @@ class BITMAPINFO(ctypes.Structure): HEADER_H = 80 # Colors -C_GRID_LINE = (80, 80, 80, 60) -C_GRID_FRAME = (217, 143, 57, 240) -C_GRID_FRAME_BG = (0, 0, 0, 150) -C_NODE_ACTIVE = (0, 255, 60, 220) -C_NODE_PATH = (0, 200, 50, 160) -C_ACTION_BG = (50, 60, 80, 220) -C_ITEM_BG = (30, 30, 30, 200) -C_ITEM_BORDER = (80, 80, 80, 255) -C_TEXT = (240, 240, 240, 255) -C_TEXT_DIM = (180, 180, 180, 255) -C_GOLD = (217, 143, 57, 255) +C_GRID_LINE = (80, 80, 80, 60) +C_GRID_FRAME = (217, 143, 57, 240) +C_GRID_FRAME_BG = (0, 0, 0, 150) +C_NODE_ACTIVE = (0, 255, 60, 220) +C_NODE_PATH = (0, 200, 50, 160) +C_ACTION_BG = (50, 60, 80, 220) +C_ITEM_BG = (30, 30, 30, 200) +C_ITEM_BORDER = (80, 80, 80, 255) +C_TEXT = (240, 240, 240, 255) +C_TEXT_DIM = (180, 180, 180, 255) +C_GOLD = (217, 143, 57, 255) LOGGER = logging.getLogger(__name__) def _msgbox(title: str, text: str) -> None: """Show a Windows message box. Safe no-op on non-Windows.""" - try: + with contextlib.suppress(Exception): if sys.platform == "win32": user32.MessageBoxW(None, str(text), str(title), 0) - except Exception: - pass + def get_xy(lparam): return (lparam & 0xFFFF), ((lparam >> 16) & 0xFFFF) + def parse_rotation(rot_str: str) -> int: m = re.search(r"(\d+)", rot_str or "") deg = int(m.group(1)) if m else 0 return deg % 360 if deg % 360 in (0, 90, 180, 270) else 0 + def nodes_to_grid(nodes_441): - grid = [] - for y in range(GRID): - row = [] - for x in range(GRID): - row.append(bool(nodes_441[y * GRID + x])) - grid.append(row) - return grid + return [[bool(nodes_441[y * GRID + x]) for x in range(GRID)] for y in range(GRID)] + def rotate_grid(grid, deg: int): - if deg == 90: return [list(reversed(col)) for col in zip(*grid)] - if deg == 180: return [list(reversed(r)) for r in reversed(grid)] - if deg == 270: return [list(col) for col in reversed(list(zip(*grid)))] + if deg == 90: + return [list(reversed(col)) for col in zip(*grid, strict=True)] + if deg == 180: + return [list(reversed(r)) for r in reversed(grid)] + if deg == 270: + return [list(col) for col in reversed(list(zip(*grid, strict=True)))] return grid + def _iter_entries(data): """Yield build-like dicts from JSON that can be either a list[dict] or a dict.""" if isinstance(data, dict): @@ -208,7 +292,7 @@ def _load_builds_from_file(preset_file: str, name_tag: str | None = None): - A single dict payload Also expands multi-step ParagonBoardsList into multiple selectable builds. """ - with open(preset_file, "r", encoding="utf-8") as f: + with Path(preset_file).open(encoding="utf-8") as f: data = json.load(f) builds = [] @@ -231,7 +315,8 @@ def _load_builds_from_file(preset_file: str, name_tag: str | None = None): builds.append({"name": step_name, "boards": boards}) if not builds: - raise ValueError(f"No valid builds in {preset_file}") + msg = f"No valid builds in {preset_file}" + raise ValueError(msg) return builds @@ -242,36 +327,45 @@ def load_builds_from_path(preset_path: str): if p.is_dir(): files = sorted(p.glob("*.json"), key=lambda fp: fp.stat().st_mtime, reverse=True) if not files: - raise ValueError("Folder contains no .json files") + msg = "Folder contains no .json files" + raise ValueError(msg) multi = len(files) > 1 builds = [] for fp in files: try: builds.extend(_load_builds_from_file(str(fp), name_tag=(fp.stem if multi else None))) - except Exception: - # Ignore JSONs that don't match expected structure - continue + except json.JSONDecodeError, OSError, KeyError, TypeError, ValueError: + LOGGER.debug("Skipping invalid paragon preset JSON: %s", fp, exc_info=True) if not builds: - raise ValueError("No valid builds found in folder") + msg = "No valid builds found in folder" + raise ValueError(msg) return builds if not p.exists(): - raise ValueError("Preset file not found") + msg = "Preset file not found" + raise ValueError(msg) return _load_builds_from_file(str(p)) -def get_font(size=14, bold=False): - try: return ImageFont.truetype("arialbd.ttf" if bold else "arial.ttf", size) - except: return ImageFont.load_default() + + +def get_font(size: int = 14, bold: bool = False): + font_name = "arialbd.ttf" if bold else "arial.ttf" + try: + return ImageFont.truetype(font_name, size) + except OSError: + return ImageFont.load_default() + FONT_HEADER = get_font(16, bold=True) -FONT_ITEM = get_font(13, bold=True) -FONT_SMALL = get_font(11, bold=False) +FONT_ITEM = get_font(13, bold=True) +FONT_SMALL = get_font(11, bold=False) + def render_grid_window(board, cell_size): rot_deg = parse_rotation(board.get("Rotation", "0°")) grid = rotate_grid(nodes_to_grid(board["Nodes"]), rot_deg) grid_px = GRID * cell_size - + img = Image.new("RGBA", (grid_px + 30, grid_px + 30), (0, 0, 0, 0)) d = ImageDraw.Draw(img) gx0, gy0 = 15, 15 @@ -289,11 +383,11 @@ def render_grid_window(board, cell_size): for x in range(GRID): if grid[y][x]: cx, cy = gx0 + x * cell_size + half_cell, gy0 + y * cell_size + half_cell - if x + 1 < GRID and grid[y][x+1]: - nx, ny = gx0 + (x+1) * cell_size + half_cell, cy + if x + 1 < GRID and grid[y][x + 1]: + nx, ny = gx0 + (x + 1) * cell_size + half_cell, cy d.line([(cx, cy), (nx, ny)], fill=C_NODE_PATH, width=path_width) - if y + 1 < GRID and grid[y+1][x]: - nx, ny = cx, gy0 + (y+1) * cell_size + half_cell + if y + 1 < GRID and grid[y + 1][x]: + nx, ny = cx, gy0 + (y + 1) * cell_size + half_cell d.line([(cx, cy), (nx, ny)], fill=C_NODE_PATH, width=path_width) # 3. Draw Nodes @@ -302,40 +396,47 @@ def render_grid_window(board, cell_size): for x in range(GRID): if grid[y][x]: px, py = gx0 + x * cell_size, gy0 + y * cell_size - d.rectangle((px + inset, py + inset, px + cell_size - inset, py + cell_size - inset), - fill=C_NODE_ACTIVE, outline=None) - d.rectangle((px + inset, py + inset, px + cell_size - inset, py + cell_size - inset), - outline=(200, 255, 200, 100), width=1) + d.rectangle( + (px + inset, py + inset, px + cell_size - inset, py + cell_size - inset), + fill=C_NODE_ACTIVE, + outline=None, + ) + d.rectangle( + (px + inset, py + inset, px + cell_size - inset, py + cell_size - inset), + outline=(200, 255, 200, 100), + width=1, + ) # 4. THICK Outer Frame frame_thick = 5 - d.rectangle((gx0 - 1, gy0 - 1, gx0 + grid_px + 1, gy0 + grid_px + 1), - outline=C_GRID_FRAME_BG, width=frame_thick + 2) - d.rectangle((gx0, gy0, gx0 + grid_px, gy0 + grid_px), - outline=C_GRID_FRAME, width=frame_thick) - + d.rectangle( + (gx0 - 1, gy0 - 1, gx0 + grid_px + 1, gy0 + grid_px + 1), outline=C_GRID_FRAME_BG, width=frame_thick + 2 + ) + d.rectangle((gx0, gy0, gx0 + grid_px, gy0 + grid_px), outline=C_GRID_FRAME, width=frame_thick) + return img + def render_list_window(state): minimized = state.minimized selecting = state.selecting_build - + # 1. PREPARE TOGGLE BUTTON btn_rect = [2, 2, 26, 26] if minimized: - fill_col = (200, 50, 50) # Red - symbol = "\u2716" # X + fill_col = (200, 50, 50) # Red + symbol = "\u2716" # X txt_offset = (6, 5) else: - fill_col = (50, 200, 50) # Green - symbol = "\u2714" # Check + fill_col = (50, 200, 50) # Green + symbol = "\u2714" # Check txt_offset = (6, 5) # 2. IF MINIMIZED -> DRAW ONLY BUTTON AND RETURN if minimized: img = Image.new("RGBA", (PANEL_W, 30), (0, 0, 0, 1)) d = ImageDraw.Draw(img) - d.rectangle(btn_rect, fill=fill_col, outline=(200,200,200)) + d.rectangle(btn_rect, fill=fill_col, outline=(200, 200, 200)) d.text(txt_offset, symbol, fill=(255, 255, 255, 255), font=FONT_ITEM) return img @@ -344,52 +445,56 @@ def render_list_window(state): data, title = state.builds, "Select Build (Click to cancel)" active_idx = state.current_build_idx else: - data, title = state.boards, state.build_name + " \u25BC" + data, title = state.boards, state.build_name + " \u25bc" active_idx = state.selected - + rows = len(data) total_h = HEADER_H + (rows * (ITEM_H + 4)) + 10 - + img = Image.new("RGBA", (PANEL_W, total_h), (0, 0, 0, 1)) d = ImageDraw.Draw(img) # Header Background d.rectangle((0, 0, PANEL_W, HEADER_H), fill=C_ACTION_BG if selecting else C_ITEM_BG) d.text((35, 10), title, fill=C_TEXT, font=FONT_HEADER) - + # Hint Box d.rectangle((0, 50, PANEL_W - 5, 85), fill=C_ITEM_BG, outline=C_ITEM_BORDER, width=1) hint = f"Found {len(data)} builds" if selecting else "click on golden frame= Zoom: Mousewheel | Move: Drag Grid" d.text((12, 58), hint, fill=C_GOLD, font=FONT_SMALL) - + # EXIT BUTTON (Right side) - exit_rect = [PANEL_W - 35, 55, PANEL_W - 10, 80] + exit_rect = [PANEL_W - 35, 55, PANEL_W - 10, 80] d.rectangle(exit_rect, fill=(180, 0, 0), outline=(200, 200, 200)) - d.text((PANEL_W - 30, 59), "EXIT", fill=(255,255,255), font=get_font(9, True)) - + d.text((PANEL_W - 30, 59), "EXIT", fill=(255, 255, 255), font=get_font(9, True)) + d.line([(0, HEADER_H), (PANEL_W, HEADER_H)], fill=C_GOLD, width=1) # List Items y_start = HEADER_H + 10 for i, item in enumerate(data): - label = item["name"] if selecting else f"{item.get('Name','?')} ({item.get('Rotation','0')})" - if not selecting and item.get("Glyph"): label += f" ({item.get('Glyph')})" - + label = item["name"] if selecting else f"{item.get('Name', '?')} ({item.get('Rotation', '0')})" + if not selecting and item.get("Glyph"): + label += f" ({item.get('Glyph')})" + y = y_start + i * (ITEM_H + 4) bg, border, txt = C_ITEM_BG, C_ITEM_BORDER, C_TEXT_DIM - - if i == active_idx: bg, border, txt = (40, 35, 20, 220), C_GOLD, C_TEXT - elif i == state.hover: border, txt = (150, 150, 150, 255), C_TEXT - + + if i == active_idx: + bg, border, txt = (40, 35, 20, 220), C_GOLD, C_TEXT + elif i == state.hover: + border, txt = (150, 150, 150, 255), C_TEXT + d.rectangle((0, y, PANEL_W - 5, y + ITEM_H), fill=bg, outline=border, width=1) d.text((10, y + (ITEM_H - 13) // 2 - 2), label, fill=txt, font=FONT_ITEM) # 4. DRAW TOGGLE BUTTON LAST (So it sits ON TOP of Header) - d.rectangle(btn_rect, fill=fill_col, outline=(200,200,200)) + d.rectangle(btn_rect, fill=fill_col, outline=(200, 200, 200)) d.text(txt_offset, symbol, fill=(255, 255, 255, 255), font=FONT_ITEM) return img + def pil_to_hbitmap(pil_img): img = pil_img.convert("RGBA") w, h = img.size @@ -405,6 +510,7 @@ def pil_to_hbitmap(pil_img): ctypes.memmove(bits, img.tobytes("raw", "BGRA"), w * h * 4) return hbmp + def update_window(hwnd, img, x, y): hbmp = pil_to_hbitmap(img) hdc_screen = user32.GetDC(None) @@ -412,13 +518,26 @@ def update_window(hwnd, img, x, y): old = gdi32.SelectObject(hdc_mem, hbmp) pt_dst, sz, pt_src = wt.POINT(x, y), wt.SIZE(img.width, img.height), wt.POINT(0, 0) blend = BLENDFUNCTION(AC_SRC_OVER, 0, 255, AC_SRC_ALPHA) - user32.UpdateLayeredWindow(hwnd, hdc_screen, ctypes.byref(pt_dst), ctypes.byref(sz), - hdc_mem, ctypes.byref(pt_src), 0, ctypes.byref(blend), ULW_ALPHA) - + user32.UpdateLayeredWindow( + hwnd, + hdc_screen, + ctypes.byref(pt_dst), + ctypes.byref(sz), + hdc_mem, + ctypes.byref(pt_src), + 0, + ctypes.byref(blend), + ULW_ALPHA, + ) + # Always On Top user32.SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE) - - gdi32.SelectObject(hdc_mem, old); gdi32.DeleteObject(hbmp); gdi32.DeleteDC(hdc_mem); user32.ReleaseDC(None, hdc_screen) + + gdi32.SelectObject(hdc_mem, old) + gdi32.DeleteObject(hbmp) + gdi32.DeleteDC(hdc_mem) + user32.ReleaseDC(None, hdc_screen) + class AppState: def __init__(self, builds): @@ -446,46 +565,60 @@ def update_current_build_data(self): self.grid_cache = {} def load_config(self): - if os.path.exists(CONFIG_FILE): - try: - with open(CONFIG_FILE, "r") as f: - cfg = json.load(f) - self.list_pos = (0, int(cfg.get("list_pos", (0, 60))[1])) - gp = cfg.get("grid_pos") - if gp: self.grid_pos = tuple(gp) - self.cell_size = cfg.get("cell_size", 28) - self.minimized = cfg.get("minimized", False) - # Clamp positions to visible screen area (prevents 'overlay opened but not visible') - try: - sw = user32.GetSystemMetrics(0) - sh = user32.GetSystemMetrics(1) - # list window: x is always 0 - ly = max(0, min(int(self.list_pos[1]), max(0, sh - 80))) - self.list_pos = (0, ly) - gx, gy = self.grid_pos - gx = max(0, min(int(gx), max(0, sw - 80))) - gy = max(0, min(int(gy), max(0, sh - 80))) - self.grid_pos = (gx, gy) - except Exception: - pass - - except: pass + cfg_path = Path(CONFIG_FILE) + if not cfg_path.exists(): + return + + try: + with cfg_path.open("r", encoding="utf-8") as f: + cfg = json.load(f) + except OSError, json.JSONDecodeError, ValueError: + return + + self.list_pos = (0, int(cfg.get("list_pos", (0, 60))[1])) + gp = cfg.get("grid_pos") + if gp: + self.grid_pos = tuple(gp) + self.cell_size = cfg.get("cell_size", 28) + self.minimized = cfg.get("minimized", False) + + # Clamp positions to visible screen area (prevents 'overlay opened but not visible') + with contextlib.suppress(Exception): + sw = user32.GetSystemMetrics(0) + sh = user32.GetSystemMetrics(1) + # list window: x is always 0 + ly = max(0, min(int(self.list_pos[1]), max(0, sh - 80))) + self.list_pos = (0, ly) + gx, gy = self.grid_pos + gx = max(0, min(int(gx), max(0, sw - 80))) + gy = max(0, min(int(gy), max(0, sh - 80))) + self.grid_pos = (gx, gy) def save_config(self): - cfg = {"list_pos": self.list_pos, "grid_pos": self.grid_pos, "cell_size": self.cell_size, "minimized": self.minimized} + cfg = { + "list_pos": self.list_pos, + "grid_pos": self.grid_pos, + "cell_size": self.cell_size, + "minimized": self.minimized, + } + cfg_path = Path(CONFIG_FILE) try: - with open(CONFIG_FILE, "w") as f: json.dump(cfg, f) - except: pass + with cfg_path.open("w", encoding="utf-8") as f: + json.dump(cfg, f) + except OSError: + LOGGER.debug("Failed to save overlay config", exc_info=True) + state = None CONFIG_FILE = "d4_overlay_config.json" + def redraw_all(force_grid=False): l_img = render_list_window(state) update_window(state.hwnd_list, l_img, 0, state.list_pos[1]) - + if state.minimized or state.selecting_build: - g_img = Image.new("RGBA", (1, 1), (0,0,0,0)) + g_img = Image.new("RGBA", (1, 1), (0, 0, 0, 0)) else: key = (state.selected, state.cell_size) if force_grid or key not in state.grid_cache: @@ -493,43 +626,57 @@ def redraw_all(force_grid=False): g_img = state.grid_cache[key] update_window(state.hwnd_grid, g_img, state.grid_pos[0], state.grid_pos[1]) + @WNDPROCTYPE -def WndProcList(hwnd, msg, w, l): - if msg == WM_DESTROY: user32.PostQuitMessage(0); return 0 - if msg == WM_NCHITTEST: return HTCLIENT - +def WndProcList(hwnd, msg, wparam, lparam): + if msg == WM_DESTROY: + user32.PostQuitMessage(0) + return 0 + if msg == WM_NCHITTEST: + return HTCLIENT + if msg == WM_MOUSEMOVE: - if state.minimized: return 0 - _, y = get_xy(l) + if state.minimized: + return 0 + _, y = get_xy(lparam) if y > HEADER_H: lst = state.builds if state.selecting_build else state.boards idx = (y - HEADER_H - 10) // (ITEM_H + 4) nh = idx if 0 <= idx < len(lst) else -1 - if nh != state.hover: state.hover = nh; redraw_all() - elif state.hover != -1: state.hover = -1; redraw_all() + if nh != state.hover: + state.hover = nh + redraw_all() + elif state.hover != -1: + state.hover = -1 + redraw_all() return 0 if msg == WM_LBUTTONDOWN: user32.SetFocus(hwnd) - x, y = get_xy(l) + x, y = get_xy(lparam) # Check START/STOP Button - if x < 28 and y < 28: - state.minimized = not state.minimized; state.save_config(); redraw_all(); return 0 - - if state.minimized: return 0 - + if x < 28 and y < 28: + state.minimized = not state.minimized + state.save_config() + redraw_all() + return 0 + + if state.minimized: + return 0 + # Check EXIT Button # Rect: [PANEL_W - 35, 55, PANEL_W - 10, 80] if PANEL_W - 35 <= x <= PANEL_W - 10 and 55 <= y <= 80: user32.PostQuitMessage(0) return 0 - - if y < HEADER_H: - if x > 30: - state.selecting_build = not state.selecting_build - state.hover = -1; redraw_all() - return 0 - + + if y < HEADER_H: + if x > 30: + state.selecting_build = not state.selecting_build + state.hover = -1 + redraw_all() + return 0 + lst = state.builds if state.selecting_build else state.boards idx = (y - HEADER_H - 10) // (ITEM_H + 4) if 0 <= idx < len(lst): @@ -542,14 +689,16 @@ def WndProcList(hwnd, msg, w, l): state.selected = idx redraw_all() return 0 - return user32.DefWindowProcW(hwnd, msg, w, l) + return user32.DefWindowProcW(hwnd, msg, wparam, lparam) + @WNDPROCTYPE -def WndProcGrid(hwnd, msg, w, l): - if msg == WM_NCHITTEST: return HTCLIENT - +def WndProcGrid(hwnd, msg, wparam, lparam): + if msg == WM_NCHITTEST: + return HTCLIENT + if msg == WM_MOUSEWHEEL: - delta = ctypes.c_short(w >> 16).value + delta = ctypes.c_short(wparam >> 16).value change = 2 if delta > 0 else -2 new_size = max(10, min(150, state.cell_size + change)) if new_size != state.cell_size: @@ -559,37 +708,50 @@ def WndProcGrid(hwnd, msg, w, l): return 0 if msg == WM_LBUTTONDOWN: - user32.SetFocus(hwnd); user32.SetCapture(hwnd) + user32.SetFocus(hwnd) + user32.SetCapture(hwnd) state.dragging = True - pt = wt.POINT(); user32.GetCursorPos(ctypes.byref(pt)) - state.drag_start = (pt.x, pt.y); state.drag_offset = state.grid_pos + pt = wt.POINT() + user32.GetCursorPos(ctypes.byref(pt)) + state.drag_start = (pt.x, pt.y) + state.drag_offset = state.grid_pos user32.SetCursor(user32.LoadCursorW(state.h_inst, wt.LPCWSTR(IDC_SIZEALL))) return 0 if msg == WM_MOUSEMOVE: if state.dragging: - pt = wt.POINT(); user32.GetCursorPos(ctypes.byref(pt)) - dx = pt.x - state.drag_start[0]; dy = pt.y - state.drag_start[1] + pt = wt.POINT() + user32.GetCursorPos(ctypes.byref(pt)) + dx = pt.x - state.drag_start[0] + dy = pt.y - state.drag_start[1] state.grid_pos = (state.drag_offset[0] + dx, state.drag_offset[1] + dy) redraw_all(False) return 0 if msg == WM_LBUTTONUP: if state.dragging: - state.dragging = False; user32.ReleaseCapture(); state.save_config() + state.dragging = False + user32.ReleaseCapture() + state.save_config() user32.SetCursor(user32.LoadCursorW(state.h_inst, wt.LPCWSTR(IDC_ARROW))) return 0 - + if msg == WM_KEYDOWN: gx, gy = state.grid_pos step = 10 if (user32.GetKeyState(VK_SHIFT) & 0x8000) else 1 - if w == VK_LEFT: state.grid_pos = (gx - step, gy) - elif w == VK_RIGHT: state.grid_pos = (gx + step, gy) - elif w == VK_UP: state.grid_pos = (gx, gy - step) - elif w == VK_DOWN: state.grid_pos = (gx, gy + step) - if w in (VK_LEFT, VK_RIGHT, VK_UP, VK_DOWN): state.save_config(); redraw_all() + if wparam == VK_LEFT: + state.grid_pos = (gx - step, gy) + elif wparam == VK_RIGHT: + state.grid_pos = (gx + step, gy) + elif wparam == VK_UP: + state.grid_pos = (gx, gy - step) + elif wparam == VK_DOWN: + state.grid_pos = (gx, gy + step) + if wparam in (VK_LEFT, VK_RIGHT, VK_UP, VK_DOWN): + state.save_config() + redraw_all() - return user32.DefWindowProcW(hwnd, msg, w, l) + return user32.DefWindowProcW(hwnd, msg, wparam, lparam) def run_paragon_overlay(preset_path: str | None = None) -> None: @@ -600,34 +762,60 @@ def run_paragon_overlay(preset_path: str | None = None) -> None: except Exception as e: # In packaged mode we often suppress stdout/stderr; show a visible error. LOGGER.exception("Failed to load Paragon preset(s): %s", preset) - _msgbox( - "D4LF Paragon Overlay", - f"Konnte Paragon JSON nicht laden.\n\nQuelle: {preset}\n\nFehler: {e}", - ) + _msgbox("D4LF Paragon Overlay", f"Konnte Paragon JSON nicht laden.\n\nQuelle: {preset}\n\nFehler: {e}") return state = AppState(builds) state.h_inst = kernel32.GetModuleHandleW(None) - wc = WNDCLASSW(style=0, lpfnWndProc=WndProcList, hInstance=state.h_inst, - hCursor=user32.LoadCursorW(state.h_inst, wt.LPCWSTR(IDC_ARROW)), lpszClassName="D4ListCls") + wc = WNDCLASSW( + style=0, + lpfnWndProc=WndProcList, + hInstance=state.h_inst, + hCursor=user32.LoadCursorW(state.h_inst, wt.LPCWSTR(IDC_ARROW)), + lpszClassName="D4ListCls", + ) user32.RegisterClassW(ctypes.byref(wc)) - + wc.lpfnWndProc = WndProcGrid wc.lpszClassName = "D4GridCls" user32.RegisterClassW(ctypes.byref(wc)) ex_style = WS_EX_TOPMOST | WS_EX_LAYERED | WS_EX_TOOLWINDOW - - # Init default to Top-Left if config not loaded - if state.list_pos == (0,0) and state.grid_pos == (450, 60): - state.list_pos = (0, 0) - state.grid_pos = (600, 50) - state.hwnd_list = user32.CreateWindowExW(ex_style, "D4ListCls", "List", WS_POPUP|WS_VISIBLE, - 0, state.list_pos[1], 400, 600, None, None, state.h_inst, None) - state.hwnd_grid = user32.CreateWindowExW(ex_style, "D4GridCls", "Grid", WS_POPUP|WS_VISIBLE, - state.grid_pos[0], state.grid_pos[1], 800, 800, None, None, state.h_inst, None) + # Init default to Top-Left if config not loaded + if state.list_pos == (0, 0) and state.grid_pos == (450, 60): + state.list_pos = (0, 0) + state.grid_pos = (600, 50) + + state.hwnd_list = user32.CreateWindowExW( + ex_style, + "D4ListCls", + "List", + WS_POPUP | WS_VISIBLE, + 0, + state.list_pos[1], + 400, + 600, + None, + None, + state.h_inst, + None, + ) + state.hwnd_grid = user32.CreateWindowExW( + ex_style, + "D4GridCls", + "Grid", + WS_POPUP | WS_VISIBLE, + state.grid_pos[0], + state.grid_pos[1], + 800, + 800, + None, + None, + state.h_inst, + None, + ) redraw_all(True) msg = wt.MSG() @@ -635,5 +823,6 @@ def run_paragon_overlay(preset_path: str | None = None) -> None: user32.TranslateMessage(ctypes.byref(msg)) user32.DispatchMessageW(ctypes.byref(msg)) + if __name__ == "__main__": run_paragon_overlay() diff --git a/src/scripts/handler.py b/src/scripts/handler.py index bed74c71..0fa0339d 100644 --- a/src/scripts/handler.py +++ b/src/scripts/handler.py @@ -1,10 +1,10 @@ import logging +import subprocess import sys import threading import time import typing -import subprocess -from contextlib import suppress +from contextlib import ExitStack, contextmanager, suppress from pathlib import Path if sys.platform != "darwin": @@ -29,11 +29,26 @@ LOCK = threading.Lock() +@contextmanager +def _open_append_log(path: Path): + """Open a log file for append. + + Args: + path: Path to the log file. + + Yields: + An open text file handle in append mode. + """ + with path.open("a", encoding="utf-8", errors="ignore") as f: + yield f + + class ScriptHandler: def __init__(self): self.loot_interaction_thread = None self.paragon_overlay_proc = None self._paragon_overlay_log = None + self._paragon_overlay_log_stack: ExitStack | None = None if IniConfigLoader().general.vision_mode_type == VisionModeType.fast: self.vision_mode = src.scripts.vision_mode_fast.VisionModeFast() else: @@ -46,7 +61,6 @@ def __init__(self): def _graceful_exit(self): safe_exit() - def toggle_paragon_overlay(self): """Toggle the Paragon overlay process (start if not running, stop if running).""" try: @@ -59,14 +73,18 @@ def toggle_paragon_overlay(self): self.paragon_overlay_proc.wait(timeout=2) self.paragon_overlay_proc = None with suppress(Exception): - if self._paragon_overlay_log is not None: - self._paragon_overlay_log.close() + if self._paragon_overlay_log_stack is not None: + self._paragon_overlay_log_stack.close() + self._paragon_overlay_log_stack = None + self._paragon_overlay_log = None self._paragon_overlay_log = None return config = IniConfigLoader() overlay_dir_str = getattr(config.advanced_options, "paragon_overlay_source_dir", "") or "" - overlay_dir = Path(overlay_dir_str).expanduser() if str(overlay_dir_str).strip() else (config.user_dir / "paragon") + overlay_dir = ( + Path(overlay_dir_str).expanduser() if str(overlay_dir_str).strip() else (config.user_dir / "paragon") + ) overlay_dir.mkdir(parents=True, exist_ok=True) json_files = list(Path(overlay_dir).glob("*.json")) @@ -98,10 +116,17 @@ def toggle_paragon_overlay(self): LOGGER.info(f"Opening Paragon overlay (source: {overlay_dir})") # Capture any overlay errors in a log file (important when console is hidden) log_path = overlay_dir / "paragon_overlay.log" + with suppress(Exception): + if self._paragon_overlay_log_stack is not None: + self._paragon_overlay_log_stack.close() + self._paragon_overlay_log_stack = ExitStack() try: - self._paragon_overlay_log = open(log_path, "a", encoding="utf-8", errors="ignore") - except Exception: + self._paragon_overlay_log = self._paragon_overlay_log_stack.enter_context(_open_append_log(log_path)) + except OSError: self._paragon_overlay_log = None + with suppress(Exception): + self._paragon_overlay_log_stack.close() + self._paragon_overlay_log_stack = None self.paragon_overlay_proc = subprocess.Popen( cmd, @@ -127,7 +152,9 @@ def toggle_paragon_overlay(self): def setup_key_binds(self): keyboard.add_hotkey(IniConfigLoader().advanced_options.run_vision_mode, lambda: self.run_vision_mode()) keyboard.add_hotkey(IniConfigLoader().advanced_options.exit_key, lambda: self._graceful_exit()) - keyboard.add_hotkey(IniConfigLoader().advanced_options.toggle_paragon_overlay, lambda: self.toggle_paragon_overlay()) + keyboard.add_hotkey( + IniConfigLoader().advanced_options.toggle_paragon_overlay, lambda: self.toggle_paragon_overlay() + ) if not IniConfigLoader().advanced_options.vision_mode_only: keyboard.add_hotkey(IniConfigLoader().advanced_options.run_filter, lambda: self.filter_items()) keyboard.add_hotkey( From 209467c3fda1c049329c2741b42da10630035a19 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sun, 1 Feb 2026 21:55:36 +0100 Subject: [PATCH 05/16] information directly from d4data --- src/gui/importer/d4builds.py | 2 +- src/gui/importer/paragon_export.py | 149 ++++++++++++++++------------- 2 files changed, 82 insertions(+), 69 deletions(-) diff --git a/src/gui/importer/d4builds.py b/src/gui/importer/d4builds.py index e8305372..abe1f7dd 100644 --- a/src/gui/importer/d4builds.py +++ b/src/gui/importer/d4builds.py @@ -214,7 +214,7 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): add_to_profiles(corrected_file_name) if config.export_paragon: - steps = extract_d4builds_paragon_steps(driver, class_name=class_name) + steps = extract_d4builds_paragon_steps(driver, class_name=class_name, wait=wait) if steps: export_paragon_build_json( file_stem=f"{corrected_file_name}_paragon", diff --git a/src/gui/importer/paragon_export.py b/src/gui/importer/paragon_export.py index e4b6e3f1..cb8843fa 100644 --- a/src/gui/importer/paragon_export.py +++ b/src/gui/importer/paragon_export.py @@ -417,74 +417,30 @@ def extract_mobalytics_paragon_steps(variant: dict[str, Any]) -> list[list[dict[ return [boards_out] if boards_out else [] -def extract_d4builds_paragon_steps(driver: WebDriver, class_name: str = "") -> list[list[dict[str, Any]]]: - """Extract paragon boards from D4Builds using Selenium. - - This mimics Diablo4Companion's approach: - - wait until the build name input (renameBuild) is populated - - click the Paragon tab via the left navigation links (builder__navigation__link) - - parse .paragon__board elements and their active tiles - """ - class_slug = _class_slug_from_name(class_name) - - if By is None or WebDriverWait is None: # pragma: no cover - msg = "Selenium not available, cannot export D4Builds paragon" - raise RuntimeError(msg) - - # Wait until build is loaded (renameBuild has a non-empty value) - try: - wait = WebDriverWait(driver, 20) - - def _has_build_name(drv): - try: - el = drv.find_element(By.ID, "renameBuild") - return bool((el.get_attribute("value") or "").strip()) - except Exception: - return False - - wait.until(_has_build_name) - except Exception: - LOGGER.debug("Unable to confirm D4Builds build name (continuing).", exc_info=True) - - # Switch to Paragon tab (D4Builds uses left navigation links) - try: - nav_links = driver.find_elements(By.CLASS_NAME, "builder__navigation__link") - if len(nav_links) >= 3: - driver.execute_script("arguments[0].click();", nav_links[2]) - else: - # Fallback: click any element containing 'Paragon' - el = driver.find_element(By.XPATH, "//*[contains(normalize-space(.), 'Paragon')]") - driver.execute_script("arguments[0].click();", el) - time.sleep(0.25) - except Exception: - # Not fatal: sometimes paragon is already visible or site changed - LOGGER.debug("Could not click Paragon tab (continuing).", exc_info=True) - # Wait for paragon boards to appear (best effort) - try: - wait = WebDriverWait(driver, 10) - wait.until(lambda d: len(d.find_elements(By.CLASS_NAME, "paragon__board")) > 0) - except Exception: - LOGGER.debug("Timed out waiting for D4Builds paragon boards (continuing).", exc_info=True) - +def _parse_d4builds_paragon_boards(driver: WebDriver, class_slug: str) -> list[list[dict[str, Any]]]: + """Parse D4Builds paragon boards from the currently loaded page.""" boards_out: list[dict[str, Any]] = [] + try: board_elements = driver.find_elements(By.CLASS_NAME, "paragon__board") except Exception: + LOGGER.debug("Failed to locate D4Builds paragon boards (continuing).", exc_info=True) board_elements = [] for board_elem in board_elements: name_raw = "" - lines = [] + lines: list[str] = [] name_display = "" + try: name_raw = board_elem.find_element(By.CLASS_NAME, "paragon__board__name").get_attribute("innerText") or "" - lines = [ln.strip() for ln in (name_raw or "").splitlines() if ln.strip()] - # Prefer first line that contains letters (D4Builds sometimes shows just a numeric index on line 1) + lines = [ln.strip() for ln in name_raw.splitlines() if ln.strip()] + # Prefer a line containing letters (sometimes line 1 is a numeric index) name_display = next((ln for ln in lines if any(ch.isalpha() for ch in ln)), (lines[0] if lines else "")) except Exception: name_display = "" - # Try to detect a stable board id/slug from element attributes (best effort) + # Try to infer a stable board id/slug from element attributes (best effort) board_id = "" try: attrs = driver.execute_script( @@ -497,6 +453,7 @@ def _has_build_name(drv): if isinstance(v, str) and v.strip(): board_id = v.strip() break + if not board_id: for v in attrs.values(): if isinstance(v, str): @@ -507,8 +464,7 @@ def _has_build_name(drv): except Exception: LOGGER.debug("Failed to infer board id (continuing).", exc_info=True) - name_slug = _slugify(board_id or name_display) - name_slug = _prefix_with_class_slug(name_slug, class_slug) + name_slug = _prefix_with_class_slug(_slugify(board_id or name_display), class_slug) if not name_slug and lines and str(lines[0]).isdigit(): name_slug = f"board-{lines[0]}" @@ -520,9 +476,8 @@ def _has_build_name(drv): except Exception: LOGGER.debug("Failed to read glyph name (continuing).", exc_info=True) - glyph_display = (glyph_raw or "").replace("(", "").replace(")", "").strip() - glyph_slug = _slugify(glyph_display) - glyph_slug = _prefix_with_class_slug(glyph_slug, class_slug) + glyph_display = glyph_raw.replace("(", "").replace(")", "").strip() + glyph_slug = _prefix_with_class_slug(_slugify(glyph_display), class_slug) style_str = board_elem.get_attribute("style") or "" rotate_int = 0 @@ -534,7 +489,7 @@ def _has_build_name(drv): except Exception: rotate_int = 0 - nodes = [False] * (21 * 21) + nodes = [False] * NODES_LEN try: tile_elems = board_elem.find_elements(By.CLASS_NAME, "paragon__board__tile") @@ -545,12 +500,17 @@ def _has_build_name(drv): cls = tile.get_attribute("class") or "" if "active" not in cls: continue + parts = [pp for pp in cls.split() if pp] # Example: "paragon__board__tile r2 c10 active enabled" r_part = next((x for x in parts if x.startswith("r")), "r0") c_part = next((x for x in parts if x.startswith("c")), "c0") - r = int("".join(ch for ch in r_part if ch.isdigit()) or "0") - c = int("".join(ch for ch in c_part if ch.isdigit()) or "0") + + try: + r = int("".join(ch for ch in r_part if ch.isdigit()) or "0") + c = int("".join(ch for ch in c_part if ch.isdigit()) or "0") + except ValueError: + continue # Transform coordinates based on rotation (matching Diablo4Companion) x = c @@ -559,17 +519,17 @@ def _has_build_name(drv): x = x - 1 y = y - 1 elif rotate_int == 90: - x = 21 - r + x = GRID - r y = c - 1 elif rotate_int == 180: - x = 21 - c - y = 21 - r + x = GRID - c + y = GRID - r elif rotate_int == 270: x = r - 1 - y = 21 - c + y = GRID - c - if 0 <= x < 21 and 0 <= y < 21: - nodes[y * 21 + x] = True + if 0 <= x < GRID and 0 <= y < GRID: + nodes[y * GRID + x] = True boards_out.append({ "Name": name_slug or "paragon-board", @@ -578,7 +538,60 @@ def _has_build_name(drv): "Nodes": nodes, }) - return [boards_out] + return [boards_out] if boards_out else [] + + +def extract_d4builds_paragon_steps( + driver: WebDriver, class_name: str = "", *, wait: WebDriverWait | None = None +) -> list[list[dict[str, Any]]]: + """Extract paragon boards from D4Builds using Selenium. + + This reuses the existing Selenium session/page state created by the importer. We only + click/wait for the Paragon tab if boards are not already present in the DOM. + """ + class_slug = _class_slug_from_name(class_name) + + if By is None or WebDriverWait is None: # pragma: no cover + msg = "Selenium not available, cannot export D4Builds paragon" + raise RuntimeError(msg) + + if wait is None: + wait = WebDriverWait(driver, 10) + + # Fast path: if boards are already present, don't click/wait again. + try: + if driver.find_elements(By.CLASS_NAME, "paragon__board"): + return _parse_d4builds_paragon_boards(driver, class_slug) + except Exception: + LOGGER.debug("Could not query for existing D4Builds paragon boards (continuing).", exc_info=True) + + # Best effort: ensure the navigation is present before attempting to click Paragon. + try: + wait.until(lambda d: len(d.find_elements(By.CLASS_NAME, "builder__navigation__link")) > 0) + except Exception: + LOGGER.debug("Timed out waiting for D4Builds navigation links (continuing).", exc_info=True) + + # Switch to Paragon tab (D4Builds uses left navigation links) + try: + nav_links = driver.find_elements(By.CLASS_NAME, "builder__navigation__link") + if len(nav_links) >= 3: + driver.execute_script("arguments[0].click();", nav_links[2]) + else: + # Fallback: click any element containing 'Paragon' + el = driver.find_element(By.XPATH, "//*[contains(normalize-space(.), 'Paragon')]") + driver.execute_script("arguments[0].click();", el) + time.sleep(0.25) + except Exception: + # Not fatal: sometimes paragon is already visible or site changed + LOGGER.debug("Could not click Paragon tab (continuing).", exc_info=True) + + # Wait for paragon boards to appear (best effort) + try: + wait.until(lambda d: len(d.find_elements(By.CLASS_NAME, "paragon__board")) > 0) + except Exception: + LOGGER.debug("Timed out waiting for D4Builds paragon boards (continuing).", exc_info=True) + + return _parse_d4builds_paragon_boards(driver, class_slug) # --- Helper functions (ported from Diablo4Companion) --- From 7671fc9cc1e42c3526889ed9fc07140ae9535c14 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sun, 1 Feb 2026 21:57:48 +0100 Subject: [PATCH 06/16] original connection fix --- src/gui/importer/d4builds.py | 2 +- src/gui/importer/paragon_export.py | 149 +++++++++++++---------------- 2 files changed, 69 insertions(+), 82 deletions(-) diff --git a/src/gui/importer/d4builds.py b/src/gui/importer/d4builds.py index abe1f7dd..e8305372 100644 --- a/src/gui/importer/d4builds.py +++ b/src/gui/importer/d4builds.py @@ -214,7 +214,7 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): add_to_profiles(corrected_file_name) if config.export_paragon: - steps = extract_d4builds_paragon_steps(driver, class_name=class_name, wait=wait) + steps = extract_d4builds_paragon_steps(driver, class_name=class_name) if steps: export_paragon_build_json( file_stem=f"{corrected_file_name}_paragon", diff --git a/src/gui/importer/paragon_export.py b/src/gui/importer/paragon_export.py index cb8843fa..e4b6e3f1 100644 --- a/src/gui/importer/paragon_export.py +++ b/src/gui/importer/paragon_export.py @@ -417,30 +417,74 @@ def extract_mobalytics_paragon_steps(variant: dict[str, Any]) -> list[list[dict[ return [boards_out] if boards_out else [] -def _parse_d4builds_paragon_boards(driver: WebDriver, class_slug: str) -> list[list[dict[str, Any]]]: - """Parse D4Builds paragon boards from the currently loaded page.""" - boards_out: list[dict[str, Any]] = [] +def extract_d4builds_paragon_steps(driver: WebDriver, class_name: str = "") -> list[list[dict[str, Any]]]: + """Extract paragon boards from D4Builds using Selenium. + + This mimics Diablo4Companion's approach: + - wait until the build name input (renameBuild) is populated + - click the Paragon tab via the left navigation links (builder__navigation__link) + - parse .paragon__board elements and their active tiles + """ + class_slug = _class_slug_from_name(class_name) + if By is None or WebDriverWait is None: # pragma: no cover + msg = "Selenium not available, cannot export D4Builds paragon" + raise RuntimeError(msg) + + # Wait until build is loaded (renameBuild has a non-empty value) + try: + wait = WebDriverWait(driver, 20) + + def _has_build_name(drv): + try: + el = drv.find_element(By.ID, "renameBuild") + return bool((el.get_attribute("value") or "").strip()) + except Exception: + return False + + wait.until(_has_build_name) + except Exception: + LOGGER.debug("Unable to confirm D4Builds build name (continuing).", exc_info=True) + + # Switch to Paragon tab (D4Builds uses left navigation links) + try: + nav_links = driver.find_elements(By.CLASS_NAME, "builder__navigation__link") + if len(nav_links) >= 3: + driver.execute_script("arguments[0].click();", nav_links[2]) + else: + # Fallback: click any element containing 'Paragon' + el = driver.find_element(By.XPATH, "//*[contains(normalize-space(.), 'Paragon')]") + driver.execute_script("arguments[0].click();", el) + time.sleep(0.25) + except Exception: + # Not fatal: sometimes paragon is already visible or site changed + LOGGER.debug("Could not click Paragon tab (continuing).", exc_info=True) + # Wait for paragon boards to appear (best effort) + try: + wait = WebDriverWait(driver, 10) + wait.until(lambda d: len(d.find_elements(By.CLASS_NAME, "paragon__board")) > 0) + except Exception: + LOGGER.debug("Timed out waiting for D4Builds paragon boards (continuing).", exc_info=True) + + boards_out: list[dict[str, Any]] = [] try: board_elements = driver.find_elements(By.CLASS_NAME, "paragon__board") except Exception: - LOGGER.debug("Failed to locate D4Builds paragon boards (continuing).", exc_info=True) board_elements = [] for board_elem in board_elements: name_raw = "" - lines: list[str] = [] + lines = [] name_display = "" - try: name_raw = board_elem.find_element(By.CLASS_NAME, "paragon__board__name").get_attribute("innerText") or "" - lines = [ln.strip() for ln in name_raw.splitlines() if ln.strip()] - # Prefer a line containing letters (sometimes line 1 is a numeric index) + lines = [ln.strip() for ln in (name_raw or "").splitlines() if ln.strip()] + # Prefer first line that contains letters (D4Builds sometimes shows just a numeric index on line 1) name_display = next((ln for ln in lines if any(ch.isalpha() for ch in ln)), (lines[0] if lines else "")) except Exception: name_display = "" - # Try to infer a stable board id/slug from element attributes (best effort) + # Try to detect a stable board id/slug from element attributes (best effort) board_id = "" try: attrs = driver.execute_script( @@ -453,7 +497,6 @@ def _parse_d4builds_paragon_boards(driver: WebDriver, class_slug: str) -> list[l if isinstance(v, str) and v.strip(): board_id = v.strip() break - if not board_id: for v in attrs.values(): if isinstance(v, str): @@ -464,7 +507,8 @@ def _parse_d4builds_paragon_boards(driver: WebDriver, class_slug: str) -> list[l except Exception: LOGGER.debug("Failed to infer board id (continuing).", exc_info=True) - name_slug = _prefix_with_class_slug(_slugify(board_id or name_display), class_slug) + name_slug = _slugify(board_id or name_display) + name_slug = _prefix_with_class_slug(name_slug, class_slug) if not name_slug and lines and str(lines[0]).isdigit(): name_slug = f"board-{lines[0]}" @@ -476,8 +520,9 @@ def _parse_d4builds_paragon_boards(driver: WebDriver, class_slug: str) -> list[l except Exception: LOGGER.debug("Failed to read glyph name (continuing).", exc_info=True) - glyph_display = glyph_raw.replace("(", "").replace(")", "").strip() - glyph_slug = _prefix_with_class_slug(_slugify(glyph_display), class_slug) + glyph_display = (glyph_raw or "").replace("(", "").replace(")", "").strip() + glyph_slug = _slugify(glyph_display) + glyph_slug = _prefix_with_class_slug(glyph_slug, class_slug) style_str = board_elem.get_attribute("style") or "" rotate_int = 0 @@ -489,7 +534,7 @@ def _parse_d4builds_paragon_boards(driver: WebDriver, class_slug: str) -> list[l except Exception: rotate_int = 0 - nodes = [False] * NODES_LEN + nodes = [False] * (21 * 21) try: tile_elems = board_elem.find_elements(By.CLASS_NAME, "paragon__board__tile") @@ -500,17 +545,12 @@ def _parse_d4builds_paragon_boards(driver: WebDriver, class_slug: str) -> list[l cls = tile.get_attribute("class") or "" if "active" not in cls: continue - parts = [pp for pp in cls.split() if pp] # Example: "paragon__board__tile r2 c10 active enabled" r_part = next((x for x in parts if x.startswith("r")), "r0") c_part = next((x for x in parts if x.startswith("c")), "c0") - - try: - r = int("".join(ch for ch in r_part if ch.isdigit()) or "0") - c = int("".join(ch for ch in c_part if ch.isdigit()) or "0") - except ValueError: - continue + r = int("".join(ch for ch in r_part if ch.isdigit()) or "0") + c = int("".join(ch for ch in c_part if ch.isdigit()) or "0") # Transform coordinates based on rotation (matching Diablo4Companion) x = c @@ -519,17 +559,17 @@ def _parse_d4builds_paragon_boards(driver: WebDriver, class_slug: str) -> list[l x = x - 1 y = y - 1 elif rotate_int == 90: - x = GRID - r + x = 21 - r y = c - 1 elif rotate_int == 180: - x = GRID - c - y = GRID - r + x = 21 - c + y = 21 - r elif rotate_int == 270: x = r - 1 - y = GRID - c + y = 21 - c - if 0 <= x < GRID and 0 <= y < GRID: - nodes[y * GRID + x] = True + if 0 <= x < 21 and 0 <= y < 21: + nodes[y * 21 + x] = True boards_out.append({ "Name": name_slug or "paragon-board", @@ -538,60 +578,7 @@ def _parse_d4builds_paragon_boards(driver: WebDriver, class_slug: str) -> list[l "Nodes": nodes, }) - return [boards_out] if boards_out else [] - - -def extract_d4builds_paragon_steps( - driver: WebDriver, class_name: str = "", *, wait: WebDriverWait | None = None -) -> list[list[dict[str, Any]]]: - """Extract paragon boards from D4Builds using Selenium. - - This reuses the existing Selenium session/page state created by the importer. We only - click/wait for the Paragon tab if boards are not already present in the DOM. - """ - class_slug = _class_slug_from_name(class_name) - - if By is None or WebDriverWait is None: # pragma: no cover - msg = "Selenium not available, cannot export D4Builds paragon" - raise RuntimeError(msg) - - if wait is None: - wait = WebDriverWait(driver, 10) - - # Fast path: if boards are already present, don't click/wait again. - try: - if driver.find_elements(By.CLASS_NAME, "paragon__board"): - return _parse_d4builds_paragon_boards(driver, class_slug) - except Exception: - LOGGER.debug("Could not query for existing D4Builds paragon boards (continuing).", exc_info=True) - - # Best effort: ensure the navigation is present before attempting to click Paragon. - try: - wait.until(lambda d: len(d.find_elements(By.CLASS_NAME, "builder__navigation__link")) > 0) - except Exception: - LOGGER.debug("Timed out waiting for D4Builds navigation links (continuing).", exc_info=True) - - # Switch to Paragon tab (D4Builds uses left navigation links) - try: - nav_links = driver.find_elements(By.CLASS_NAME, "builder__navigation__link") - if len(nav_links) >= 3: - driver.execute_script("arguments[0].click();", nav_links[2]) - else: - # Fallback: click any element containing 'Paragon' - el = driver.find_element(By.XPATH, "//*[contains(normalize-space(.), 'Paragon')]") - driver.execute_script("arguments[0].click();", el) - time.sleep(0.25) - except Exception: - # Not fatal: sometimes paragon is already visible or site changed - LOGGER.debug("Could not click Paragon tab (continuing).", exc_info=True) - - # Wait for paragon boards to appear (best effort) - try: - wait.until(lambda d: len(d.find_elements(By.CLASS_NAME, "paragon__board")) > 0) - except Exception: - LOGGER.debug("Timed out waiting for D4Builds paragon boards (continuing).", exc_info=True) - - return _parse_d4builds_paragon_boards(driver, class_slug) + return [boards_out] # --- Helper functions (ported from Diablo4Companion) --- From e36207420f05f40b519e50b472914afde31ce61d Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sun, 1 Feb 2026 22:22:17 +0100 Subject: [PATCH 07/16] import this information directly from d4data --- assets/lang/enUS/paragon_maxroll_ids.json | 212 +++++++++++ src/gui/importer/paragon_export.py | 407 +++++++--------------- 2 files changed, 336 insertions(+), 283 deletions(-) create mode 100644 assets/lang/enUS/paragon_maxroll_ids.json diff --git a/assets/lang/enUS/paragon_maxroll_ids.json b/assets/lang/enUS/paragon_maxroll_ids.json new file mode 100644 index 00000000..ecb2fa13 --- /dev/null +++ b/assets/lang/enUS/paragon_maxroll_ids.json @@ -0,0 +1,212 @@ +{ + "boards": { + "Paragon_Barb_00": "Start", + "Paragon_Barb_01": "Hemorrhage", + "Paragon_Barb_02": "Blood Rage", + "Paragon_Barb_03": "Carnage", + "Paragon_Barb_04": "Decimator", + "Paragon_Barb_05": "Bone Breaker", + "Paragon_Barb_06": "Flawless Technique", + "Paragon_Barb_07": "Warbringer", + "Paragon_Barb_08": "Weapons Master", + "Paragon_Barb_10": "Force of Nature", + "Paragon_Druid_00": "Start", + "Paragon_Druid_01": "Thunderstruck", + "Paragon_Druid_02": "Earthen Devastation", + "Paragon_Druid_03": "Survival Instincts", + "Paragon_Druid_04": "Lust for Carnage", + "Paragon_Druid_05": "Heightened Malice", + "Paragon_Druid_06": "Inner Beast", + "Paragon_Druid_07": "Constricting Tendrils", + "Paragon_Druid_08": "Ancestral Guidance", + "Paragon_Druid_10": "Untamed", + "Paragon_Necro_00": "Start", + "Paragon_Necro_01": "Cult Leader", + "Paragon_Necro_02": "Hulking Monstrosity", + "Paragon_Necro_03": "Flesh-eater", + "Paragon_Necro_04": "Scent of Death", + "Paragon_Necro_05": "Bone Graft", + "Paragon_Necro_06": "Blood Begets Blood", + "Paragon_Necro_07": "Bloodbath", + "Paragon_Necro_08": "Wither", + "Paragon_Necro_10": "Frailty", + "Paragon_Paladin_00": "Start", + "Paragon_Paladin_01": "Castle", + "Paragon_Paladin_02": "Shield Bearer", + "Paragon_Paladin_03": "Fervent", + "Paragon_Paladin_04": "Preacher", + "Paragon_Paladin_05": "Divinity", + "Paragon_Paladin_06": "Relentless", + "Paragon_Paladin_07": "Sentencing", + "Paragon_Paladin_08": "Endure", + "Paragon_Paladin_09": "Beacon", + "Paragon_Rogue_00": "Start", + "Paragon_Rogue_01": "Eldritch Bounty", + "Paragon_Rogue_02": "Tricks of the Trade", + "Paragon_Rogue_03": "Cheap Shot", + "Paragon_Rogue_04": "Deadly Ambush", + "Paragon_Rogue_05": "Leyrana's Instinct", + "Paragon_Rogue_06": "No Witnesses", + "Paragon_Rogue_07": "Exploit Weakness", + "Paragon_Rogue_08": "Cunning Stratagem", + "Paragon_Rogue_10": "Danse Macabre", + "Paragon_Sorc_00": "Start", + "Paragon_Sorc_01": "Searing Heat", + "Paragon_Sorc_02": "Frigid Fate", + "Paragon_Sorc_03": "Static Surge", + "Paragon_Sorc_04": "Elemental Summoner", + "Paragon_Sorc_05": "Burning Instinct", + "Paragon_Sorc_06": "Icefall", + "Paragon_Sorc_07": "Ceaseless Conduit", + "Paragon_Sorc_08": "Enchantment Master", + "Paragon_Sorc_10": "Fundamental Release", + "Paragon_Spirit_0": "Start", + "Paragon_Spirit_01": "In-Fighter", + "Paragon_Spirit_02": "Spiney Skin", + "Paragon_Spirit_03": "Viscous Shield", + "Paragon_Spirit_04": "Bitter Medicine", + "Paragon_Spirit_05": "Revealing", + "Paragon_Spirit_06": "Drive", + "Paragon_Spirit_07": "Convergence", + "Paragon_Spirit_08": "Sapping" + }, + "glyphs": { + "Rare_001_Intelligence_Main": "Enchanter", + "Rare_002_Intelligence_Main": "Unleash", + "Rare_003_Intelligence_Main": "Elementalist", + "Rare_004_Intelligence_Main": "Adept", + "Rare_005_Intelligence_Main": "Conjurer", + "Rare_006_Intelligence_Main": "Charged", + "Rare_007_Willpower_Side": "Torch", + "Rare_008_Willpower_Side": "Pyromaniac", + "Rare_009_Willpower_Side": "Cryopathy", + "Rare_010_Dexterity_Main": "Tactician", + "Rare_011_Intelligence_Side": "Guzzler", + "Rare_011_Willpower_Side": "Imbiber", + "Rare_012_Intelligence_Side": "Protector", + "Rare_012_Willpower_Side": "Reinforced", + "Rare_013_Dexterity_Side": "Poise", + "Rare_014_Dexterity_Side": "Territorial", + "Rare_014_Strength_Main": "Turf", + "Rare_014_Strength_Side": "Turf", + "Rare_015_Dexterity_Side": "Flamefeeder", + "Rare_016_Dexterity_Side": "Exploit", + "Rare_016_Intelligence_Side": "Exploit", + "Rare_016_Strength_Side": "Exploit", + "Rare_017_Dexterity_Side": "Winter", + "Rare_018_Dexterity_Side": "Electrocute", + "Rare_019_Dexterity_Side": "Destruction", + "Rare_020_Dexterity_Side": "Control", + "Rare_020_Intelligence_Main": "Control", + "Rare_020_Intelligence_Side": "Control", + "Rare_021_Strength_Main": "Ambidextrous", + "Rare_022_Strength_Main": "Might", + "Rare_023_Strength_Main": "Cleaver", + "Rare_024_Strength_Main": "Seething", + "Rare_025_Strength_Main": "Crusher", + "Rare_026_Strength_Main": "Executioner", + "Rare_027_Strength_Main": "Ire", + "Rare_028_Strength_Main": "Marshal", + "Rare_029_Dexterity_Side": "Bloodfeeder", + "Rare_030_Dexterity_Side": "Wrath", + "Rare_031_Dexterity_Side": "Weapon Master", + "Rare_032_Dexterity_Side": "Mortal Draw", + "Rare_033_Intelligence_Side": "Revenge", + "Rare_033_Willpower_Side": "Revenge", + "Rare_033_Willpower_Side_Necro": "Revenge", + "Rare_034_Intelligence_Side": "Undaunted", + "Rare_034_Willpower_Side": "Undaunted", + "Rare_035_Intelligence_Side": "Dominate", + "Rare_035_Willpower_Side": "Dominate", + "Rare_035_Willpower_Side_Necro": "Dominate", + "Rare_036_Willpower_Side": "Disembowel", + "Rare_037_Willpower_Side": "Brawl", + "Rare_038_Intelligence_Main": "Corporeal", + "Rare_039_Willpower_Main": "Fang and Claw", + "Rare_040_Willpower_Main": "Earth and Sky", + "Rare_041_Intelligence_Side": "Wilds", + "Rare_042_Willpower_Main": "Werebear", + "Rare_043_Willpower_Main": "Werewolf", + "Rare_044_Willpower_Main": "Human", + "Rare_045_Intelligence_Side": "Bane", + "Rare_045_Strength_Side": "Bane", + "Rare_046_Dexterity_Side": "Abyssal", + "Rare_046_Intelligence_Side": "Keeper", + "Rare_047_Dexterity_Side": "Fulminate", + "Rare_047_Intelligence_Side": "Fulminate", + "Rare_048_Dexterity_Side": "Tracker", + "Rare_048_Intelligence_Side": "Tracker", + "Rare_049_Dexterity_Side": "Outmatch", + "Rare_049_Strength_Main": "Outmatch", + "Rare_049_Strength_Side": "Outmatch", + "Rare_050_Dexterity_Main": "Spirit", + "Rare_050_Dexterity_Side": "Spirit", + "Rare_050_Willpower_Side": "Spirit", + "Rare_051_Dexterity_Side": "Shapeshifter", + "Rare_052_Dexterity_Main": "Versatility", + "Rare_053_Dexterity_Main": "Closer", + "Rare_054_Dexterity_Main": "Ranger", + "Rare_055_Dexterity_Main": "Chip", + "Rare_055_Dexterity_Side": "Chip", + "Rare_055_Willpower_Side": "Chip", + "Rare_056_Dexterity_Main": "Frostfeeder", + "Rare_057_Dexterity_Main": "Fluidity", + "Rare_058_Intelligence_Side": "Infusion", + "Rare_059_Dexterity_Main": "Devious", + "Rare_060_Dexterity_Side": "Warrior", + "Rare_061_Intelligence_Side": "Combat", + "Rare_062_Dexterity_Side": "Gravekeeper", + "Rare_063_Intelligence_Side": "Canny", + "Rare_064_Intelligence_Side": "Efficacy", + "Rare_065_Intelligence_Side": "Snare", + "Rare_066_Dexterity_Side": "Essence", + "Rare_067_Strength_Side": "Pride", + "Rare_068_Strength_Side": "Ambush", + "Rare_069_Intelligence_Main": "Sacrificial", + "Rare_070_Intelligence_Main": "Blood-drinker", + "Rare_071_Intelligence_Main": "Deadraiser", + "Rare_072_Intelligence_Main": "Mage", + "Rare_073_Intelligence_Main": "Amplify", + "Rare_074_Willpower_Side": "Golem", + "Rare_075_Willpower_Side": "Scourge", + "Rare_076_Strength_Main": "Diminish", + "Rare_076_Strength_Side": "Diminish", + "Rare_077_Willpower_Side": "Warding", + "Rare_078_Willpower_Side": "Darkness", + "Rare_079_Dexterity_Side": "Exploit", + "Rare_080_Strength_Main": "Twister", + "Rare_081_Strength_Main": "Rumble", + "Rare_082_Dexterity_Main": "Explosive", + "Rare_083_Intelligence_Side": "Nightstalker", + "Rare_084_Intelligence_Main": "Stalagmite", + "Rare_085_Dexterity_Side": "Invocation", + "Rare_086_Dexterity_Side": "Tectonic", + "Rare_087_Willpower_Main": "Electrocution", + "Rare_088_Intelligence_Main": "Exhumation", + "Rare_089_Willpower_Side": "Desecration", + "Rare_090_Dexterity_Main": "Menagerist", + "Rare_091_Strength_Side": "Hone", + "Rare_092_Intelligence_Side": "Consumption", + "Rare_093_Dexterity_Main": "Fitness", + "Rare_094_Intelligence_Side": "Ritual", + "Rare_095_Dexterity_Main": "Jagged Plume", + "Rare_096_Strength_Side": "Innate", + "Rare_097_Dexterity_Main": "Wildfire", + "Rare_098_Strength_Side": "Colossal", + "Rare_100_Dexterity_Main": "Talon", + "Rare_101_Strength_Side": "Hubris", + "Rare_102_Dexterity_Main": "Fester", + "Rare_103_Strength_Main": "Sentinel", + "Rare_104_Dexterity_Side": "Honed", + "Rare_105_Strength_Main": "Law", + "Rare_106_Willpower_Side": "Arbiter ", + "Rare_107_Strength_Main": "Resplendence", + "Rare_108_Intelligence_Side": "Judicator", + "Rare_109_Dexterity_Side": "Feverous", + "Rare_110_Strength_Main": "Apostle", + "Rare_Dex_Generic": "Headhunter", + "Rare_Int_Generic": "Eliminator", + "Rare_Str_Generic": "Challenger", + "Rare_Will_Generic": "Headhunter" + } +} diff --git a/src/gui/importer/paragon_export.py b/src/gui/importer/paragon_export.py index e4b6e3f1..85fadf7d 100644 --- a/src/gui/importer/paragon_export.py +++ b/src/gui/importer/paragon_export.py @@ -5,9 +5,11 @@ import logging import re import time +from functools import lru_cache from typing import TYPE_CHECKING, Any from src import __version__ +from src.config import BASE_DIR from src.config.loader import IniConfigLoader try: @@ -48,221 +50,45 @@ def _prefix_with_class_slug(slug: str, class_slug: str) -> str: # --------------------------------------------------------------------------- -# Maxroll ID -> human friendly names (ported from Diablo4Companion data files). -# Used to export Paragon JSON with readable identifiers (similar to Mobalytics). +# Maxroll uses internal IDs for boards/glyphs. We keep the exported identifiers stable +# by resolving IDs to human-friendly names from data files (generated from d4data). +# +# Expected file (fallback to enUS): +# assets/lang//paragon_maxroll_ids.json +# Format: +# {"boards": {"": "", ...}, "glyphs": {"": "", ...}} # --------------------------------------------------------------------------- -_MAXROLL_BOARD_ID_TO_NAME = { - "Paragon_Barb_00": "Start", - "Paragon_Barb_01": "Hemorrhage", - "Paragon_Barb_02": "Blood Rage", - "Paragon_Barb_03": "Carnage", - "Paragon_Barb_04": "Decimator", - "Paragon_Barb_05": "Bone Breaker", - "Paragon_Barb_06": "Flawless Technique", - "Paragon_Barb_07": "Warbringer", - "Paragon_Barb_08": "Weapons Master", - "Paragon_Barb_10": "Force of Nature", - "Paragon_Druid_00": "Start", - "Paragon_Druid_01": "Thunderstruck", - "Paragon_Druid_02": "Earthen Devastation", - "Paragon_Druid_03": "Survival Instincts", - "Paragon_Druid_04": "Lust for Carnage", - "Paragon_Druid_05": "Heightened Malice", - "Paragon_Druid_06": "Inner Beast", - "Paragon_Druid_07": "Constricting Tendrils", - "Paragon_Druid_08": "Ancestral Guidance", - "Paragon_Druid_10": "Untamed", - "Paragon_Necro_00": "Start", - "Paragon_Necro_01": "Cult Leader", - "Paragon_Necro_02": "Hulking Monstrosity", - "Paragon_Necro_03": "Flesh-eater", - "Paragon_Necro_04": "Scent of Death", - "Paragon_Necro_05": "Bone Graft", - "Paragon_Necro_06": "Blood Begets Blood", - "Paragon_Necro_07": "Bloodbath", - "Paragon_Necro_08": "Wither", - "Paragon_Necro_10": "Frailty", - "Paragon_Paladin_00": "Start", - "Paragon_Paladin_01": "Castle", - "Paragon_Paladin_02": "Shield Bearer", - "Paragon_Paladin_03": "Fervent", - "Paragon_Paladin_04": "Preacher", - "Paragon_Paladin_05": "Divinity", - "Paragon_Paladin_06": "Relentless", - "Paragon_Paladin_07": "Sentencing", - "Paragon_Paladin_08": "Endure", - "Paragon_Paladin_09": "Beacon", - "Paragon_Rogue_00": "Start", - "Paragon_Rogue_01": "Eldritch Bounty", - "Paragon_Rogue_02": "Tricks of the Trade", - "Paragon_Rogue_03": "Cheap Shot", - "Paragon_Rogue_04": "Deadly Ambush", - "Paragon_Rogue_05": "Leyrana's Instinct", - "Paragon_Rogue_06": "No Witnesses", - "Paragon_Rogue_07": "Exploit Weakness", - "Paragon_Rogue_08": "Cunning Stratagem", - "Paragon_Rogue_10": "Danse Macabre", - "Paragon_Sorc_00": "Start", - "Paragon_Sorc_01": "Searing Heat", - "Paragon_Sorc_02": "Frigid Fate", - "Paragon_Sorc_03": "Static Surge", - "Paragon_Sorc_04": "Elemental Summoner", - "Paragon_Sorc_05": "Burning Instinct", - "Paragon_Sorc_06": "Icefall", - "Paragon_Sorc_07": "Ceaseless Conduit", - "Paragon_Sorc_08": "Enchantment Master", - "Paragon_Sorc_10": "Fundamental Release", - "Paragon_Spirit_0": "Start", - "Paragon_Spirit_01": "In-Fighter", - "Paragon_Spirit_02": "Spiney Skin", - "Paragon_Spirit_03": "Viscous Shield", - "Paragon_Spirit_04": "Bitter Medicine", - "Paragon_Spirit_05": "Revealing", - "Paragon_Spirit_06": "Drive", - "Paragon_Spirit_07": "Convergence", - "Paragon_Spirit_08": "Sapping", -} - -_MAXROLL_GLYPH_ID_TO_NAME = { - "Rare_001_Intelligence_Main": "Enchanter", - "Rare_002_Intelligence_Main": "Unleash", - "Rare_003_Intelligence_Main": "Elementalist", - "Rare_004_Intelligence_Main": "Adept", - "Rare_005_Intelligence_Main": "Conjurer", - "Rare_006_Intelligence_Main": "Charged", - "Rare_007_Willpower_Side": "Torch", - "Rare_008_Willpower_Side": "Pyromaniac", - "Rare_009_Willpower_Side": "Cryopathy", - "Rare_010_Dexterity_Main": "Tactician", - "Rare_011_Intelligence_Side": "Guzzler", - "Rare_011_Willpower_Side": "Imbiber", - "Rare_012_Intelligence_Side": "Protector", - "Rare_012_Willpower_Side": "Reinforced", - "Rare_013_Dexterity_Side": "Poise", - "Rare_014_Dexterity_Side": "Territorial", - "Rare_014_Strength_Main": "Turf", - "Rare_014_Strength_Side": "Turf", - "Rare_015_Dexterity_Side": "Flamefeeder", - "Rare_016_Dexterity_Side": "Exploit", - "Rare_016_Intelligence_Side": "Exploit", - "Rare_016_Strength_Side": "Exploit", - "Rare_017_Dexterity_Side": "Winter", - "Rare_018_Dexterity_Side": "Electrocute", - "Rare_019_Dexterity_Side": "Destruction", - "Rare_020_Dexterity_Side": "Control", - "Rare_020_Intelligence_Main": "Control", - "Rare_020_Intelligence_Side": "Control", - "Rare_021_Strength_Main": "Ambidextrous", - "Rare_022_Strength_Main": "Might", - "Rare_023_Strength_Main": "Cleaver", - "Rare_024_Strength_Main": "Seething", - "Rare_025_Strength_Main": "Crusher", - "Rare_026_Strength_Main": "Executioner", - "Rare_027_Strength_Main": "Ire", - "Rare_028_Strength_Main": "Marshal", - "Rare_029_Dexterity_Side": "Bloodfeeder", - "Rare_030_Dexterity_Side": "Wrath", - "Rare_031_Dexterity_Side": "Weapon Master", - "Rare_032_Dexterity_Side": "Mortal Draw", - "Rare_033_Intelligence_Side": "Revenge", - "Rare_033_Willpower_Side": "Revenge", - "Rare_033_Willpower_Side_Necro": "Revenge", - "Rare_034_Intelligence_Side": "Undaunted", - "Rare_034_Willpower_Side": "Undaunted", - "Rare_035_Intelligence_Side": "Dominate", - "Rare_035_Willpower_Side": "Dominate", - "Rare_035_Willpower_Side_Necro": "Dominate", - "Rare_036_Willpower_Side": "Disembowel", - "Rare_037_Willpower_Side": "Brawl", - "Rare_038_Intelligence_Main": "Corporeal", - "Rare_039_Willpower_Main": "Fang and Claw", - "Rare_040_Willpower_Main": "Earth and Sky", - "Rare_041_Intelligence_Side": "Wilds", - "Rare_042_Willpower_Main": "Werebear", - "Rare_043_Willpower_Main": "Werewolf", - "Rare_044_Willpower_Main": "Human", - "Rare_045_Intelligence_Side": "Bane", - "Rare_045_Strength_Side": "Bane", - "Rare_046_Dexterity_Side": "Abyssal", - "Rare_046_Intelligence_Side": "Keeper", - "Rare_047_Dexterity_Side": "Fulminate", - "Rare_047_Intelligence_Side": "Fulminate", - "Rare_048_Dexterity_Side": "Tracker", - "Rare_048_Intelligence_Side": "Tracker", - "Rare_049_Dexterity_Side": "Outmatch", - "Rare_049_Strength_Main": "Outmatch", - "Rare_049_Strength_Side": "Outmatch", - "Rare_050_Dexterity_Main": "Spirit", - "Rare_050_Dexterity_Side": "Spirit", - "Rare_050_Willpower_Side": "Spirit", - "Rare_051_Dexterity_Side": "Shapeshifter", - "Rare_052_Dexterity_Main": "Versatility", - "Rare_053_Dexterity_Main": "Closer", - "Rare_054_Dexterity_Main": "Ranger", - "Rare_055_Dexterity_Main": "Chip", - "Rare_055_Dexterity_Side": "Chip", - "Rare_055_Willpower_Side": "Chip", - "Rare_056_Dexterity_Main": "Frostfeeder", - "Rare_057_Dexterity_Main": "Fluidity", - "Rare_058_Intelligence_Side": "Infusion", - "Rare_059_Dexterity_Main": "Devious", - "Rare_060_Dexterity_Side": "Warrior", - "Rare_061_Intelligence_Side": "Combat", - "Rare_062_Dexterity_Side": "Gravekeeper", - "Rare_063_Intelligence_Side": "Canny", - "Rare_064_Intelligence_Side": "Efficacy", - "Rare_065_Intelligence_Side": "Snare", - "Rare_066_Dexterity_Side": "Essence", - "Rare_067_Strength_Side": "Pride", - "Rare_068_Strength_Side": "Ambush", - "Rare_069_Intelligence_Main": "Sacrificial", - "Rare_070_Intelligence_Main": "Blood-drinker", - "Rare_071_Intelligence_Main": "Deadraiser", - "Rare_072_Intelligence_Main": "Mage", - "Rare_073_Intelligence_Main": "Amplify", - "Rare_074_Willpower_Side": "Golem", - "Rare_075_Willpower_Side": "Scourge", - "Rare_076_Strength_Main": "Diminish", - "Rare_076_Strength_Side": "Diminish", - "Rare_077_Willpower_Side": "Warding", - "Rare_078_Willpower_Side": "Darkness", - "Rare_079_Dexterity_Side": "Exploit", - "Rare_080_Strength_Main": "Twister", - "Rare_081_Strength_Main": "Rumble", - "Rare_082_Dexterity_Main": "Explosive", - "Rare_083_Intelligence_Side": "Nightstalker", - "Rare_084_Intelligence_Main": "Stalagmite", - "Rare_085_Dexterity_Side": "Invocation", - "Rare_086_Dexterity_Side": "Tectonic", - "Rare_087_Willpower_Main": "Electrocution", - "Rare_088_Intelligence_Main": "Exhumation", - "Rare_089_Willpower_Side": "Desecration", - "Rare_090_Dexterity_Main": "Menagerist", - "Rare_091_Strength_Side": "Hone", - "Rare_092_Intelligence_Side": "Consumption", - "Rare_093_Dexterity_Main": "Fitness", - "Rare_094_Intelligence_Side": "Ritual", - "Rare_095_Dexterity_Main": "Jagged Plume", - "Rare_096_Strength_Side": "Innate", - "Rare_097_Dexterity_Main": "Wildfire", - "Rare_098_Strength_Side": "Colossal", - "Rare_100_Dexterity_Main": "Talon", - "Rare_101_Strength_Side": "Hubris", - "Rare_102_Dexterity_Main": "Fester", - "Rare_103_Strength_Main": "Sentinel", - "Rare_104_Dexterity_Side": "Honed", - "Rare_105_Strength_Main": "Law", - "Rare_106_Willpower_Side": "Arbiter ", - "Rare_107_Strength_Main": "Resplendence", - "Rare_108_Intelligence_Side": "Judicator", - "Rare_109_Dexterity_Side": "Feverous", - "Rare_110_Strength_Main": "Apostle", - "Rare_Dex_Generic": "Headhunter", - "Rare_Int_Generic": "Eliminator", - "Rare_Str_Generic": "Challenger", - "Rare_Will_Generic": "Headhunter", -} + +@lru_cache(maxsize=1) +def _load_maxroll_name_maps() -> tuple[dict[str, str], dict[str, str]]: + lang = IniConfigLoader().general.language + candidates = ( + BASE_DIR / f"assets/lang/{lang}/paragon_maxroll_ids.json", + BASE_DIR / "assets/lang/enUS/paragon_maxroll_ids.json", + ) + + for path in candidates: + try: + with path.open(encoding="utf-8") as f: + data = json.load(f) + except FileNotFoundError: + continue + except OSError: + LOGGER.debug("Failed to read Maxroll paragon mapping file: %s", path, exc_info=True) + continue + + if not isinstance(data, dict): + continue + + boards = data.get("boards") or {} + glyphs = data.get("glyphs") or {} + if isinstance(boards, dict) and isinstance(glyphs, dict): + boards_map = {str(k): str(v) for k, v in boards.items()} + glyphs_map = {str(k): str(v) for k, v in glyphs.items()} + return boards_map, glyphs_map + + return {}, {} def _slugify(s: str) -> str: @@ -279,7 +105,8 @@ def _maxroll_class_slug(board_id: str) -> str: def _maxroll_board_slug(board_id: str) -> str: cls = _maxroll_class_slug(board_id) - name = _MAXROLL_BOARD_ID_TO_NAME.get(board_id, board_id) + boards_map, _ = _load_maxroll_name_maps() + name = boards_map.get(board_id, board_id) name_slug = _slugify(name) return f"{cls}-{name_slug}" if cls and name_slug else _slugify(board_id) @@ -287,7 +114,8 @@ def _maxroll_board_slug(board_id: str) -> str: def _maxroll_glyph_slug(glyph_id: str, board_id: str) -> str: # We prefix with class for consistency with Mobalytics output. cls = _maxroll_class_slug(board_id) - name = _MAXROLL_GLYPH_ID_TO_NAME.get(glyph_id, glyph_id) + _, glyphs_map = _load_maxroll_name_maps() + name = glyphs_map.get(glyph_id, glyph_id) name_slug = _slugify(name) return f"{cls}-{name_slug}" if cls and name_slug else _slugify(glyph_id) @@ -417,74 +245,30 @@ def extract_mobalytics_paragon_steps(variant: dict[str, Any]) -> list[list[dict[ return [boards_out] if boards_out else [] -def extract_d4builds_paragon_steps(driver: WebDriver, class_name: str = "") -> list[list[dict[str, Any]]]: - """Extract paragon boards from D4Builds using Selenium. - - This mimics Diablo4Companion's approach: - - wait until the build name input (renameBuild) is populated - - click the Paragon tab via the left navigation links (builder__navigation__link) - - parse .paragon__board elements and their active tiles - """ - class_slug = _class_slug_from_name(class_name) - - if By is None or WebDriverWait is None: # pragma: no cover - msg = "Selenium not available, cannot export D4Builds paragon" - raise RuntimeError(msg) - - # Wait until build is loaded (renameBuild has a non-empty value) - try: - wait = WebDriverWait(driver, 20) - - def _has_build_name(drv): - try: - el = drv.find_element(By.ID, "renameBuild") - return bool((el.get_attribute("value") or "").strip()) - except Exception: - return False - - wait.until(_has_build_name) - except Exception: - LOGGER.debug("Unable to confirm D4Builds build name (continuing).", exc_info=True) - - # Switch to Paragon tab (D4Builds uses left navigation links) - try: - nav_links = driver.find_elements(By.CLASS_NAME, "builder__navigation__link") - if len(nav_links) >= 3: - driver.execute_script("arguments[0].click();", nav_links[2]) - else: - # Fallback: click any element containing 'Paragon' - el = driver.find_element(By.XPATH, "//*[contains(normalize-space(.), 'Paragon')]") - driver.execute_script("arguments[0].click();", el) - time.sleep(0.25) - except Exception: - # Not fatal: sometimes paragon is already visible or site changed - LOGGER.debug("Could not click Paragon tab (continuing).", exc_info=True) - # Wait for paragon boards to appear (best effort) - try: - wait = WebDriverWait(driver, 10) - wait.until(lambda d: len(d.find_elements(By.CLASS_NAME, "paragon__board")) > 0) - except Exception: - LOGGER.debug("Timed out waiting for D4Builds paragon boards (continuing).", exc_info=True) - +def _parse_d4builds_paragon_boards(driver: WebDriver, class_slug: str) -> list[list[dict[str, Any]]]: + """Parse D4Builds paragon boards from the currently loaded page.""" boards_out: list[dict[str, Any]] = [] + try: board_elements = driver.find_elements(By.CLASS_NAME, "paragon__board") except Exception: + LOGGER.debug("Failed to locate D4Builds paragon boards (continuing).", exc_info=True) board_elements = [] for board_elem in board_elements: name_raw = "" - lines = [] + lines: list[str] = [] name_display = "" + try: name_raw = board_elem.find_element(By.CLASS_NAME, "paragon__board__name").get_attribute("innerText") or "" - lines = [ln.strip() for ln in (name_raw or "").splitlines() if ln.strip()] - # Prefer first line that contains letters (D4Builds sometimes shows just a numeric index on line 1) + lines = [ln.strip() for ln in name_raw.splitlines() if ln.strip()] + # Prefer a line containing letters (sometimes line 1 is a numeric index) name_display = next((ln for ln in lines if any(ch.isalpha() for ch in ln)), (lines[0] if lines else "")) except Exception: name_display = "" - # Try to detect a stable board id/slug from element attributes (best effort) + # Try to infer a stable board id/slug from element attributes (best effort) board_id = "" try: attrs = driver.execute_script( @@ -497,6 +281,7 @@ def _has_build_name(drv): if isinstance(v, str) and v.strip(): board_id = v.strip() break + if not board_id: for v in attrs.values(): if isinstance(v, str): @@ -507,8 +292,7 @@ def _has_build_name(drv): except Exception: LOGGER.debug("Failed to infer board id (continuing).", exc_info=True) - name_slug = _slugify(board_id or name_display) - name_slug = _prefix_with_class_slug(name_slug, class_slug) + name_slug = _prefix_with_class_slug(_slugify(board_id or name_display), class_slug) if not name_slug and lines and str(lines[0]).isdigit(): name_slug = f"board-{lines[0]}" @@ -520,9 +304,8 @@ def _has_build_name(drv): except Exception: LOGGER.debug("Failed to read glyph name (continuing).", exc_info=True) - glyph_display = (glyph_raw or "").replace("(", "").replace(")", "").strip() - glyph_slug = _slugify(glyph_display) - glyph_slug = _prefix_with_class_slug(glyph_slug, class_slug) + glyph_display = glyph_raw.replace("(", "").replace(")", "").strip() + glyph_slug = _prefix_with_class_slug(_slugify(glyph_display), class_slug) style_str = board_elem.get_attribute("style") or "" rotate_int = 0 @@ -534,7 +317,7 @@ def _has_build_name(drv): except Exception: rotate_int = 0 - nodes = [False] * (21 * 21) + nodes = [False] * NODES_LEN try: tile_elems = board_elem.find_elements(By.CLASS_NAME, "paragon__board__tile") @@ -545,12 +328,17 @@ def _has_build_name(drv): cls = tile.get_attribute("class") or "" if "active" not in cls: continue + parts = [pp for pp in cls.split() if pp] # Example: "paragon__board__tile r2 c10 active enabled" r_part = next((x for x in parts if x.startswith("r")), "r0") c_part = next((x for x in parts if x.startswith("c")), "c0") - r = int("".join(ch for ch in r_part if ch.isdigit()) or "0") - c = int("".join(ch for ch in c_part if ch.isdigit()) or "0") + + try: + r = int("".join(ch for ch in r_part if ch.isdigit()) or "0") + c = int("".join(ch for ch in c_part if ch.isdigit()) or "0") + except ValueError: + continue # Transform coordinates based on rotation (matching Diablo4Companion) x = c @@ -559,17 +347,17 @@ def _has_build_name(drv): x = x - 1 y = y - 1 elif rotate_int == 90: - x = 21 - r + x = GRID - r y = c - 1 elif rotate_int == 180: - x = 21 - c - y = 21 - r + x = GRID - c + y = GRID - r elif rotate_int == 270: x = r - 1 - y = 21 - c + y = GRID - c - if 0 <= x < 21 and 0 <= y < 21: - nodes[y * 21 + x] = True + if 0 <= x < GRID and 0 <= y < GRID: + nodes[y * GRID + x] = True boards_out.append({ "Name": name_slug or "paragon-board", @@ -578,7 +366,60 @@ def _has_build_name(drv): "Nodes": nodes, }) - return [boards_out] + return [boards_out] if boards_out else [] + + +def extract_d4builds_paragon_steps( + driver: WebDriver, class_name: str = "", *, wait: WebDriverWait | None = None +) -> list[list[dict[str, Any]]]: + """Extract paragon boards from D4Builds using Selenium. + + This reuses the existing Selenium session/page state created by the importer. We only + click/wait for the Paragon tab if boards are not already present in the DOM. + """ + class_slug = _class_slug_from_name(class_name) + + if By is None or WebDriverWait is None: # pragma: no cover + msg = "Selenium not available, cannot export D4Builds paragon" + raise RuntimeError(msg) + + if wait is None: + wait = WebDriverWait(driver, 10) + + # Fast path: if boards are already present, don't click/wait again. + try: + if driver.find_elements(By.CLASS_NAME, "paragon__board"): + return _parse_d4builds_paragon_boards(driver, class_slug) + except Exception: + LOGGER.debug("Could not query for existing D4Builds paragon boards (continuing).", exc_info=True) + + # Best effort: ensure the navigation is present before attempting to click Paragon. + try: + wait.until(lambda d: len(d.find_elements(By.CLASS_NAME, "builder__navigation__link")) > 0) + except Exception: + LOGGER.debug("Timed out waiting for D4Builds navigation links (continuing).", exc_info=True) + + # Switch to Paragon tab (D4Builds uses left navigation links) + try: + nav_links = driver.find_elements(By.CLASS_NAME, "builder__navigation__link") + if len(nav_links) >= 3: + driver.execute_script("arguments[0].click();", nav_links[2]) + else: + # Fallback: click any element containing 'Paragon' + el = driver.find_element(By.XPATH, "//*[contains(normalize-space(.), 'Paragon')]") + driver.execute_script("arguments[0].click();", el) + time.sleep(0.25) + except Exception: + # Not fatal: sometimes paragon is already visible or site changed + LOGGER.debug("Could not click Paragon tab (continuing).", exc_info=True) + + # Wait for paragon boards to appear (best effort) + try: + wait.until(lambda d: len(d.find_elements(By.CLASS_NAME, "paragon__board")) > 0) + except Exception: + LOGGER.debug("Timed out waiting for D4Builds paragon boards (continuing).", exc_info=True) + + return _parse_d4builds_paragon_boards(driver, class_slug) # --- Helper functions (ported from Diablo4Companion) --- From 708ac9fb0cec21e6b2437f739157919fa8085b93 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sun, 1 Feb 2026 22:27:10 +0100 Subject: [PATCH 08/16] WIP: sync local changes --- assets/lang/enUS/paragon_maxroll_ids.json | 420 +++++++++++----------- 1 file changed, 210 insertions(+), 210 deletions(-) diff --git a/assets/lang/enUS/paragon_maxroll_ids.json b/assets/lang/enUS/paragon_maxroll_ids.json index ecb2fa13..26190e29 100644 --- a/assets/lang/enUS/paragon_maxroll_ids.json +++ b/assets/lang/enUS/paragon_maxroll_ids.json @@ -1,212 +1,212 @@ { - "boards": { - "Paragon_Barb_00": "Start", - "Paragon_Barb_01": "Hemorrhage", - "Paragon_Barb_02": "Blood Rage", - "Paragon_Barb_03": "Carnage", - "Paragon_Barb_04": "Decimator", - "Paragon_Barb_05": "Bone Breaker", - "Paragon_Barb_06": "Flawless Technique", - "Paragon_Barb_07": "Warbringer", - "Paragon_Barb_08": "Weapons Master", - "Paragon_Barb_10": "Force of Nature", - "Paragon_Druid_00": "Start", - "Paragon_Druid_01": "Thunderstruck", - "Paragon_Druid_02": "Earthen Devastation", - "Paragon_Druid_03": "Survival Instincts", - "Paragon_Druid_04": "Lust for Carnage", - "Paragon_Druid_05": "Heightened Malice", - "Paragon_Druid_06": "Inner Beast", - "Paragon_Druid_07": "Constricting Tendrils", - "Paragon_Druid_08": "Ancestral Guidance", - "Paragon_Druid_10": "Untamed", - "Paragon_Necro_00": "Start", - "Paragon_Necro_01": "Cult Leader", - "Paragon_Necro_02": "Hulking Monstrosity", - "Paragon_Necro_03": "Flesh-eater", - "Paragon_Necro_04": "Scent of Death", - "Paragon_Necro_05": "Bone Graft", - "Paragon_Necro_06": "Blood Begets Blood", - "Paragon_Necro_07": "Bloodbath", - "Paragon_Necro_08": "Wither", - "Paragon_Necro_10": "Frailty", - "Paragon_Paladin_00": "Start", - "Paragon_Paladin_01": "Castle", - "Paragon_Paladin_02": "Shield Bearer", - "Paragon_Paladin_03": "Fervent", - "Paragon_Paladin_04": "Preacher", - "Paragon_Paladin_05": "Divinity", - "Paragon_Paladin_06": "Relentless", - "Paragon_Paladin_07": "Sentencing", - "Paragon_Paladin_08": "Endure", - "Paragon_Paladin_09": "Beacon", - "Paragon_Rogue_00": "Start", - "Paragon_Rogue_01": "Eldritch Bounty", - "Paragon_Rogue_02": "Tricks of the Trade", - "Paragon_Rogue_03": "Cheap Shot", - "Paragon_Rogue_04": "Deadly Ambush", - "Paragon_Rogue_05": "Leyrana's Instinct", - "Paragon_Rogue_06": "No Witnesses", - "Paragon_Rogue_07": "Exploit Weakness", - "Paragon_Rogue_08": "Cunning Stratagem", - "Paragon_Rogue_10": "Danse Macabre", - "Paragon_Sorc_00": "Start", - "Paragon_Sorc_01": "Searing Heat", - "Paragon_Sorc_02": "Frigid Fate", - "Paragon_Sorc_03": "Static Surge", - "Paragon_Sorc_04": "Elemental Summoner", - "Paragon_Sorc_05": "Burning Instinct", - "Paragon_Sorc_06": "Icefall", - "Paragon_Sorc_07": "Ceaseless Conduit", - "Paragon_Sorc_08": "Enchantment Master", - "Paragon_Sorc_10": "Fundamental Release", - "Paragon_Spirit_0": "Start", - "Paragon_Spirit_01": "In-Fighter", - "Paragon_Spirit_02": "Spiney Skin", - "Paragon_Spirit_03": "Viscous Shield", - "Paragon_Spirit_04": "Bitter Medicine", - "Paragon_Spirit_05": "Revealing", - "Paragon_Spirit_06": "Drive", - "Paragon_Spirit_07": "Convergence", - "Paragon_Spirit_08": "Sapping" - }, - "glyphs": { - "Rare_001_Intelligence_Main": "Enchanter", - "Rare_002_Intelligence_Main": "Unleash", - "Rare_003_Intelligence_Main": "Elementalist", - "Rare_004_Intelligence_Main": "Adept", - "Rare_005_Intelligence_Main": "Conjurer", - "Rare_006_Intelligence_Main": "Charged", - "Rare_007_Willpower_Side": "Torch", - "Rare_008_Willpower_Side": "Pyromaniac", - "Rare_009_Willpower_Side": "Cryopathy", - "Rare_010_Dexterity_Main": "Tactician", - "Rare_011_Intelligence_Side": "Guzzler", - "Rare_011_Willpower_Side": "Imbiber", - "Rare_012_Intelligence_Side": "Protector", - "Rare_012_Willpower_Side": "Reinforced", - "Rare_013_Dexterity_Side": "Poise", - "Rare_014_Dexterity_Side": "Territorial", - "Rare_014_Strength_Main": "Turf", - "Rare_014_Strength_Side": "Turf", - "Rare_015_Dexterity_Side": "Flamefeeder", - "Rare_016_Dexterity_Side": "Exploit", - "Rare_016_Intelligence_Side": "Exploit", - "Rare_016_Strength_Side": "Exploit", - "Rare_017_Dexterity_Side": "Winter", - "Rare_018_Dexterity_Side": "Electrocute", - "Rare_019_Dexterity_Side": "Destruction", - "Rare_020_Dexterity_Side": "Control", - "Rare_020_Intelligence_Main": "Control", - "Rare_020_Intelligence_Side": "Control", - "Rare_021_Strength_Main": "Ambidextrous", - "Rare_022_Strength_Main": "Might", - "Rare_023_Strength_Main": "Cleaver", - "Rare_024_Strength_Main": "Seething", - "Rare_025_Strength_Main": "Crusher", - "Rare_026_Strength_Main": "Executioner", - "Rare_027_Strength_Main": "Ire", - "Rare_028_Strength_Main": "Marshal", - "Rare_029_Dexterity_Side": "Bloodfeeder", - "Rare_030_Dexterity_Side": "Wrath", - "Rare_031_Dexterity_Side": "Weapon Master", - "Rare_032_Dexterity_Side": "Mortal Draw", - "Rare_033_Intelligence_Side": "Revenge", - "Rare_033_Willpower_Side": "Revenge", - "Rare_033_Willpower_Side_Necro": "Revenge", - "Rare_034_Intelligence_Side": "Undaunted", - "Rare_034_Willpower_Side": "Undaunted", - "Rare_035_Intelligence_Side": "Dominate", - "Rare_035_Willpower_Side": "Dominate", - "Rare_035_Willpower_Side_Necro": "Dominate", - "Rare_036_Willpower_Side": "Disembowel", - "Rare_037_Willpower_Side": "Brawl", - "Rare_038_Intelligence_Main": "Corporeal", - "Rare_039_Willpower_Main": "Fang and Claw", - "Rare_040_Willpower_Main": "Earth and Sky", - "Rare_041_Intelligence_Side": "Wilds", - "Rare_042_Willpower_Main": "Werebear", - "Rare_043_Willpower_Main": "Werewolf", - "Rare_044_Willpower_Main": "Human", - "Rare_045_Intelligence_Side": "Bane", - "Rare_045_Strength_Side": "Bane", - "Rare_046_Dexterity_Side": "Abyssal", - "Rare_046_Intelligence_Side": "Keeper", - "Rare_047_Dexterity_Side": "Fulminate", - "Rare_047_Intelligence_Side": "Fulminate", - "Rare_048_Dexterity_Side": "Tracker", - "Rare_048_Intelligence_Side": "Tracker", - "Rare_049_Dexterity_Side": "Outmatch", - "Rare_049_Strength_Main": "Outmatch", - "Rare_049_Strength_Side": "Outmatch", - "Rare_050_Dexterity_Main": "Spirit", - "Rare_050_Dexterity_Side": "Spirit", - "Rare_050_Willpower_Side": "Spirit", - "Rare_051_Dexterity_Side": "Shapeshifter", - "Rare_052_Dexterity_Main": "Versatility", - "Rare_053_Dexterity_Main": "Closer", - "Rare_054_Dexterity_Main": "Ranger", - "Rare_055_Dexterity_Main": "Chip", - "Rare_055_Dexterity_Side": "Chip", - "Rare_055_Willpower_Side": "Chip", - "Rare_056_Dexterity_Main": "Frostfeeder", - "Rare_057_Dexterity_Main": "Fluidity", - "Rare_058_Intelligence_Side": "Infusion", - "Rare_059_Dexterity_Main": "Devious", - "Rare_060_Dexterity_Side": "Warrior", - "Rare_061_Intelligence_Side": "Combat", - "Rare_062_Dexterity_Side": "Gravekeeper", - "Rare_063_Intelligence_Side": "Canny", - "Rare_064_Intelligence_Side": "Efficacy", - "Rare_065_Intelligence_Side": "Snare", - "Rare_066_Dexterity_Side": "Essence", - "Rare_067_Strength_Side": "Pride", - "Rare_068_Strength_Side": "Ambush", - "Rare_069_Intelligence_Main": "Sacrificial", - "Rare_070_Intelligence_Main": "Blood-drinker", - "Rare_071_Intelligence_Main": "Deadraiser", - "Rare_072_Intelligence_Main": "Mage", - "Rare_073_Intelligence_Main": "Amplify", - "Rare_074_Willpower_Side": "Golem", - "Rare_075_Willpower_Side": "Scourge", - "Rare_076_Strength_Main": "Diminish", - "Rare_076_Strength_Side": "Diminish", - "Rare_077_Willpower_Side": "Warding", - "Rare_078_Willpower_Side": "Darkness", - "Rare_079_Dexterity_Side": "Exploit", - "Rare_080_Strength_Main": "Twister", - "Rare_081_Strength_Main": "Rumble", - "Rare_082_Dexterity_Main": "Explosive", - "Rare_083_Intelligence_Side": "Nightstalker", - "Rare_084_Intelligence_Main": "Stalagmite", - "Rare_085_Dexterity_Side": "Invocation", - "Rare_086_Dexterity_Side": "Tectonic", - "Rare_087_Willpower_Main": "Electrocution", - "Rare_088_Intelligence_Main": "Exhumation", - "Rare_089_Willpower_Side": "Desecration", - "Rare_090_Dexterity_Main": "Menagerist", - "Rare_091_Strength_Side": "Hone", - "Rare_092_Intelligence_Side": "Consumption", - "Rare_093_Dexterity_Main": "Fitness", - "Rare_094_Intelligence_Side": "Ritual", - "Rare_095_Dexterity_Main": "Jagged Plume", - "Rare_096_Strength_Side": "Innate", - "Rare_097_Dexterity_Main": "Wildfire", - "Rare_098_Strength_Side": "Colossal", - "Rare_100_Dexterity_Main": "Talon", - "Rare_101_Strength_Side": "Hubris", - "Rare_102_Dexterity_Main": "Fester", - "Rare_103_Strength_Main": "Sentinel", - "Rare_104_Dexterity_Side": "Honed", - "Rare_105_Strength_Main": "Law", - "Rare_106_Willpower_Side": "Arbiter ", - "Rare_107_Strength_Main": "Resplendence", - "Rare_108_Intelligence_Side": "Judicator", - "Rare_109_Dexterity_Side": "Feverous", - "Rare_110_Strength_Main": "Apostle", - "Rare_Dex_Generic": "Headhunter", - "Rare_Int_Generic": "Eliminator", - "Rare_Str_Generic": "Challenger", - "Rare_Will_Generic": "Headhunter" - } + "boards": { + "Paragon_Barb_00": "Start", + "Paragon_Barb_01": "Hemorrhage", + "Paragon_Barb_02": "Blood Rage", + "Paragon_Barb_03": "Carnage", + "Paragon_Barb_04": "Decimator", + "Paragon_Barb_05": "Bone Breaker", + "Paragon_Barb_06": "Flawless Technique", + "Paragon_Barb_07": "Warbringer", + "Paragon_Barb_08": "Weapons Master", + "Paragon_Barb_10": "Force of Nature", + "Paragon_Druid_00": "Start", + "Paragon_Druid_01": "Thunderstruck", + "Paragon_Druid_02": "Earthen Devastation", + "Paragon_Druid_03": "Survival Instincts", + "Paragon_Druid_04": "Lust for Carnage", + "Paragon_Druid_05": "Heightened Malice", + "Paragon_Druid_06": "Inner Beast", + "Paragon_Druid_07": "Constricting Tendrils", + "Paragon_Druid_08": "Ancestral Guidance", + "Paragon_Druid_10": "Untamed", + "Paragon_Necro_00": "Start", + "Paragon_Necro_01": "Cult Leader", + "Paragon_Necro_02": "Hulking Monstrosity", + "Paragon_Necro_03": "Flesh-eater", + "Paragon_Necro_04": "Scent of Death", + "Paragon_Necro_05": "Bone Graft", + "Paragon_Necro_06": "Blood Begets Blood", + "Paragon_Necro_07": "Bloodbath", + "Paragon_Necro_08": "Wither", + "Paragon_Necro_10": "Frailty", + "Paragon_Paladin_00": "Start", + "Paragon_Paladin_01": "Castle", + "Paragon_Paladin_02": "Shield Bearer", + "Paragon_Paladin_03": "Fervent", + "Paragon_Paladin_04": "Preacher", + "Paragon_Paladin_05": "Divinity", + "Paragon_Paladin_06": "Relentless", + "Paragon_Paladin_07": "Sentencing", + "Paragon_Paladin_08": "Endure", + "Paragon_Paladin_09": "Beacon", + "Paragon_Rogue_00": "Start", + "Paragon_Rogue_01": "Eldritch Bounty", + "Paragon_Rogue_02": "Tricks of the Trade", + "Paragon_Rogue_03": "Cheap Shot", + "Paragon_Rogue_04": "Deadly Ambush", + "Paragon_Rogue_05": "Leyrana's Instinct", + "Paragon_Rogue_06": "No Witnesses", + "Paragon_Rogue_07": "Exploit Weakness", + "Paragon_Rogue_08": "Cunning Stratagem", + "Paragon_Rogue_10": "Danse Macabre", + "Paragon_Sorc_00": "Start", + "Paragon_Sorc_01": "Searing Heat", + "Paragon_Sorc_02": "Frigid Fate", + "Paragon_Sorc_03": "Static Surge", + "Paragon_Sorc_04": "Elemental Summoner", + "Paragon_Sorc_05": "Burning Instinct", + "Paragon_Sorc_06": "Icefall", + "Paragon_Sorc_07": "Ceaseless Conduit", + "Paragon_Sorc_08": "Enchantment Master", + "Paragon_Sorc_10": "Fundamental Release", + "Paragon_Spirit_0": "Start", + "Paragon_Spirit_01": "In-Fighter", + "Paragon_Spirit_02": "Spiney Skin", + "Paragon_Spirit_03": "Viscous Shield", + "Paragon_Spirit_04": "Bitter Medicine", + "Paragon_Spirit_05": "Revealing", + "Paragon_Spirit_06": "Drive", + "Paragon_Spirit_07": "Convergence", + "Paragon_Spirit_08": "Sapping" + }, + "glyphs": { + "Rare_001_Intelligence_Main": "Enchanter", + "Rare_002_Intelligence_Main": "Unleash", + "Rare_003_Intelligence_Main": "Elementalist", + "Rare_004_Intelligence_Main": "Adept", + "Rare_005_Intelligence_Main": "Conjurer", + "Rare_006_Intelligence_Main": "Charged", + "Rare_007_Willpower_Side": "Torch", + "Rare_008_Willpower_Side": "Pyromaniac", + "Rare_009_Willpower_Side": "Cryopathy", + "Rare_010_Dexterity_Main": "Tactician", + "Rare_011_Intelligence_Side": "Guzzler", + "Rare_011_Willpower_Side": "Imbiber", + "Rare_012_Intelligence_Side": "Protector", + "Rare_012_Willpower_Side": "Reinforced", + "Rare_013_Dexterity_Side": "Poise", + "Rare_014_Dexterity_Side": "Territorial", + "Rare_014_Strength_Main": "Turf", + "Rare_014_Strength_Side": "Turf", + "Rare_015_Dexterity_Side": "Flamefeeder", + "Rare_016_Dexterity_Side": "Exploit", + "Rare_016_Intelligence_Side": "Exploit", + "Rare_016_Strength_Side": "Exploit", + "Rare_017_Dexterity_Side": "Winter", + "Rare_018_Dexterity_Side": "Electrocute", + "Rare_019_Dexterity_Side": "Destruction", + "Rare_020_Dexterity_Side": "Control", + "Rare_020_Intelligence_Main": "Control", + "Rare_020_Intelligence_Side": "Control", + "Rare_021_Strength_Main": "Ambidextrous", + "Rare_022_Strength_Main": "Might", + "Rare_023_Strength_Main": "Cleaver", + "Rare_024_Strength_Main": "Seething", + "Rare_025_Strength_Main": "Crusher", + "Rare_026_Strength_Main": "Executioner", + "Rare_027_Strength_Main": "Ire", + "Rare_028_Strength_Main": "Marshal", + "Rare_029_Dexterity_Side": "Bloodfeeder", + "Rare_030_Dexterity_Side": "Wrath", + "Rare_031_Dexterity_Side": "Weapon Master", + "Rare_032_Dexterity_Side": "Mortal Draw", + "Rare_033_Intelligence_Side": "Revenge", + "Rare_033_Willpower_Side": "Revenge", + "Rare_033_Willpower_Side_Necro": "Revenge", + "Rare_034_Intelligence_Side": "Undaunted", + "Rare_034_Willpower_Side": "Undaunted", + "Rare_035_Intelligence_Side": "Dominate", + "Rare_035_Willpower_Side": "Dominate", + "Rare_035_Willpower_Side_Necro": "Dominate", + "Rare_036_Willpower_Side": "Disembowel", + "Rare_037_Willpower_Side": "Brawl", + "Rare_038_Intelligence_Main": "Corporeal", + "Rare_039_Willpower_Main": "Fang and Claw", + "Rare_040_Willpower_Main": "Earth and Sky", + "Rare_041_Intelligence_Side": "Wilds", + "Rare_042_Willpower_Main": "Werebear", + "Rare_043_Willpower_Main": "Werewolf", + "Rare_044_Willpower_Main": "Human", + "Rare_045_Intelligence_Side": "Bane", + "Rare_045_Strength_Side": "Bane", + "Rare_046_Dexterity_Side": "Abyssal", + "Rare_046_Intelligence_Side": "Keeper", + "Rare_047_Dexterity_Side": "Fulminate", + "Rare_047_Intelligence_Side": "Fulminate", + "Rare_048_Dexterity_Side": "Tracker", + "Rare_048_Intelligence_Side": "Tracker", + "Rare_049_Dexterity_Side": "Outmatch", + "Rare_049_Strength_Main": "Outmatch", + "Rare_049_Strength_Side": "Outmatch", + "Rare_050_Dexterity_Main": "Spirit", + "Rare_050_Dexterity_Side": "Spirit", + "Rare_050_Willpower_Side": "Spirit", + "Rare_051_Dexterity_Side": "Shapeshifter", + "Rare_052_Dexterity_Main": "Versatility", + "Rare_053_Dexterity_Main": "Closer", + "Rare_054_Dexterity_Main": "Ranger", + "Rare_055_Dexterity_Main": "Chip", + "Rare_055_Dexterity_Side": "Chip", + "Rare_055_Willpower_Side": "Chip", + "Rare_056_Dexterity_Main": "Frostfeeder", + "Rare_057_Dexterity_Main": "Fluidity", + "Rare_058_Intelligence_Side": "Infusion", + "Rare_059_Dexterity_Main": "Devious", + "Rare_060_Dexterity_Side": "Warrior", + "Rare_061_Intelligence_Side": "Combat", + "Rare_062_Dexterity_Side": "Gravekeeper", + "Rare_063_Intelligence_Side": "Canny", + "Rare_064_Intelligence_Side": "Efficacy", + "Rare_065_Intelligence_Side": "Snare", + "Rare_066_Dexterity_Side": "Essence", + "Rare_067_Strength_Side": "Pride", + "Rare_068_Strength_Side": "Ambush", + "Rare_069_Intelligence_Main": "Sacrificial", + "Rare_070_Intelligence_Main": "Blood-drinker", + "Rare_071_Intelligence_Main": "Deadraiser", + "Rare_072_Intelligence_Main": "Mage", + "Rare_073_Intelligence_Main": "Amplify", + "Rare_074_Willpower_Side": "Golem", + "Rare_075_Willpower_Side": "Scourge", + "Rare_076_Strength_Main": "Diminish", + "Rare_076_Strength_Side": "Diminish", + "Rare_077_Willpower_Side": "Warding", + "Rare_078_Willpower_Side": "Darkness", + "Rare_079_Dexterity_Side": "Exploit", + "Rare_080_Strength_Main": "Twister", + "Rare_081_Strength_Main": "Rumble", + "Rare_082_Dexterity_Main": "Explosive", + "Rare_083_Intelligence_Side": "Nightstalker", + "Rare_084_Intelligence_Main": "Stalagmite", + "Rare_085_Dexterity_Side": "Invocation", + "Rare_086_Dexterity_Side": "Tectonic", + "Rare_087_Willpower_Main": "Electrocution", + "Rare_088_Intelligence_Main": "Exhumation", + "Rare_089_Willpower_Side": "Desecration", + "Rare_090_Dexterity_Main": "Menagerist", + "Rare_091_Strength_Side": "Hone", + "Rare_092_Intelligence_Side": "Consumption", + "Rare_093_Dexterity_Main": "Fitness", + "Rare_094_Intelligence_Side": "Ritual", + "Rare_095_Dexterity_Main": "Jagged Plume", + "Rare_096_Strength_Side": "Innate", + "Rare_097_Dexterity_Main": "Wildfire", + "Rare_098_Strength_Side": "Colossal", + "Rare_100_Dexterity_Main": "Talon", + "Rare_101_Strength_Side": "Hubris", + "Rare_102_Dexterity_Main": "Fester", + "Rare_103_Strength_Main": "Sentinel", + "Rare_104_Dexterity_Side": "Honed", + "Rare_105_Strength_Main": "Law", + "Rare_106_Willpower_Side": "Arbiter ", + "Rare_107_Strength_Main": "Resplendence", + "Rare_108_Intelligence_Side": "Judicator", + "Rare_109_Dexterity_Side": "Feverous", + "Rare_110_Strength_Main": "Apostle", + "Rare_Dex_Generic": "Headhunter", + "Rare_Int_Generic": "Eliminator", + "Rare_Str_Generic": "Challenger", + "Rare_Will_Generic": "Headhunter" + } } From 5387046386cc6a539e1237d7233162647ad5fd9d Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Sun, 1 Feb 2026 22:59:35 +0100 Subject: [PATCH 09/16] no separate execution --- src/gui/activity_log_widget.py | 17 +++-- src/gui/importer_window.py | 2 +- src/gui/unified_window.py | 34 --------- src/main.py | 24 ------- src/paragon_overlay.py | 16 +++++ src/scripts/handler.py | 123 +++++++++++---------------------- 6 files changed, 69 insertions(+), 147 deletions(-) diff --git a/src/gui/activity_log_widget.py b/src/gui/activity_log_widget.py index 18888edb..5c2970a9 100644 --- a/src/gui/activity_log_widget.py +++ b/src/gui/activity_log_widget.py @@ -1,4 +1,5 @@ -from PyQt6.QtCore import Qt +from PyQt6.QtCore import Qt, QUrl +from PyQt6.QtGui import QDesktopServices from PyQt6.QtWidgets import QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget from src.config.loader import IniConfigLoader @@ -78,15 +79,19 @@ def __init__(self, parent=None): self.editor_btn.setMinimumHeight(40) button_layout.addWidget(self.editor_btn) - self.paragon_overlay_btn = QPushButton("Paragon Folder") - self.paragon_overlay_btn.setMinimumHeight(40) - self.paragon_overlay_btn.setToolTip("Select the folder containing Paragon JSON files for the overlay") - button_layout.addWidget(self.paragon_overlay_btn) + self.user_dir_btn = QPushButton("Open User Config") + self.user_dir_btn.setMinimumHeight(40) + self.user_dir_btn.setToolTip("Open the D4LF user config directory") + button_layout.addWidget(self.user_dir_btn) # === CONNECT BUTTONS TO UnifiedMainWindow === self.import_btn.clicked.connect(self.parent().open_import_dialog) self.settings_btn.clicked.connect(self.parent().open_settings_dialog) self.editor_btn.clicked.connect(self.parent().open_profile_editor) - self.paragon_overlay_btn.clicked.connect(self.parent().open_paragon_overlay) + self.user_dir_btn.clicked.connect(self._open_user_dir) self.main_layout.addLayout(button_layout) + + def _open_user_dir(self) -> None: + user_dir = IniConfigLoader().user_dir + QDesktopServices.openUrl(QUrl.fromLocalFile(str(user_dir))) diff --git a/src/gui/importer_window.py b/src/gui/importer_window.py index 671ba70e..1a346e91 100644 --- a/src/gui/importer_window.py +++ b/src/gui/importer_window.py @@ -110,7 +110,7 @@ def __init__(self, parent=None): self.export_paragon_checkbox = self._generate_checkbox( "Export Paragon JSON", "export_paragon", - "Export paragon boards to a JSON file (D4Companion/d4.py compatible). Output: /paragon", + "Export paragon boards to a JSON file for the integrated Paragon overlay. Output: /paragon", "false", ) diff --git a/src/gui/unified_window.py b/src/gui/unified_window.py index 400527d7..e9c4b76a 100644 --- a/src/gui/unified_window.py +++ b/src/gui/unified_window.py @@ -9,7 +9,6 @@ from PyQt6.QtGui import QIcon, QTextCursor from PyQt6.QtWidgets import ( QApplication, - QFileDialog, QMainWindow, QMessageBox, QPlainTextEdit, @@ -332,39 +331,6 @@ def open_profile_editor(self): except Exception as e: LOGGER.error(f"Failed to open profile editor: {e}") - def open_paragon_overlay(self): - """Select the folder that contains Paragon JSON files for the overlay. - - This does NOT start the overlay. Use the Paragon hotkey (Advanced Options → Toggle Paragon Overlay) - to open/close it. - """ - try: - config = IniConfigLoader() - # Use last saved folder if present, otherwise default to ~/.d4lf/paragon - saved = getattr(config.advanced_options, "paragon_overlay_source_dir", "") or "" - default_dir = Path(saved).expanduser() if str(saved).strip() else (Path(config.user_dir) / "paragon") - default_dir.mkdir(parents=True, exist_ok=True) - - folder = QFileDialog.getExistingDirectory( - self, "Select Paragon folder (source for Paragon overlay JSON files)", str(default_dir) - ) - if not folder: - return - - # Persist selection - config.save_value("advanced_options", "paragon_overlay_source_dir", folder) - - hotkey = getattr(config.advanced_options, "toggle_paragon_overlay", "f10").upper() - LOGGER.info(f"Paragon folder set to: {folder}. Use {hotkey} to toggle the overlay.") - QMessageBox.information( - self, - "Paragon Folder Set", - f"Paragon folder saved:\n{folder}\n\nUse {hotkey} to open/close the Paragon overlay.", - ) - except Exception as e: - LOGGER.error(f"Failed to set Paragon folder: {e}") - QMessageBox.critical(self, "Paragon Folder Error", str(e)) - def restore_geometry(self): settings = QSettings("d4lf", "mainwindow") diff --git a/src/main.py b/src/main.py index fdd7b30c..7aaa7720 100644 --- a/src/main.py +++ b/src/main.py @@ -1,4 +1,3 @@ -import contextlib import ctypes import logging import os @@ -189,29 +188,6 @@ def hide_console(): src.logger.setup(log_level=IniConfigLoader().advanced_options.log_lvl.value, enable_stdout=True) start_auto_update(postprocess=True) - elif len(sys.argv) > 1 and sys.argv[1] == "--paragon-overlay": - # Run integrated Win32 Paragon overlay (separate mode). - running_from_source = not getattr(sys, "frozen", False) - if not running_from_source: - hide_console() - # Minimal logger setup (keeps behavior consistent when run from source) - src.logger.setup(log_level=IniConfigLoader().advanced_options.log_lvl.value, enable_stdout=running_from_source) - preset_path = sys.argv[2] if len(sys.argv) > 2 else None - try: - from src.paragon_overlay import run_paragon_overlay - - run_paragon_overlay(preset_path) - except Exception as e: - LOGGER.exception("Paragon overlay crashed") - if sys.platform == "win32": - with contextlib.suppress(Exception): - ctypes.windll.user32.MessageBoxW( - None, - f"Paragon overlay ist abgestürzt.\n\nQuelle: {preset_path}\n\nFehler: {e}", - "D4LF Paragon Overlay", - 0, - ) - elif len(sys.argv) > 1 and sys.argv[1] == "--consoleonly": # Console-only mode: keep console visible src.logger.setup(log_level=IniConfigLoader().advanced_options.log_lvl.value, enable_stdout=True) diff --git a/src/paragon_overlay.py b/src/paragon_overlay.py index 9abe09c0..2b879448 100644 --- a/src/paragon_overlay.py +++ b/src/paragon_overlay.py @@ -754,6 +754,22 @@ def WndProcGrid(hwnd, msg, wparam, lparam): return user32.DefWindowProcW(hwnd, msg, wparam, lparam) +def request_close() -> None: + """Best-effort request to close the overlay windows. + + This is used when the overlay is run in-process (e.g., from a background thread). + """ + st = globals().get("state") + if st is None: + return + + wm_close = 0x0010 + for hwnd in (getattr(st, "hwnd_list", None), getattr(st, "hwnd_grid", None)): + if hwnd: + with contextlib.suppress(Exception): + user32.PostMessageW(hwnd, wm_close, 0, 0) + + def run_paragon_overlay(preset_path: str | None = None) -> None: global state preset = preset_path or (sys.argv[1] if len(sys.argv) > 1 else "AffixPresets-v2.json") diff --git a/src/scripts/handler.py b/src/scripts/handler.py index 0fa0339d..1e2d77e5 100644 --- a/src/scripts/handler.py +++ b/src/scripts/handler.py @@ -1,10 +1,9 @@ import logging -import subprocess import sys import threading import time import typing -from contextlib import ExitStack, contextmanager, suppress +from contextlib import suppress from pathlib import Path if sys.platform != "darwin": @@ -24,31 +23,22 @@ from src.utils.process_handler import kill_thread, safe_exit from src.utils.window import screenshot +if sys.platform == "win32": + from src.paragon_overlay import request_close, run_paragon_overlay +else: + request_close = None # type: ignore[assignment] + run_paragon_overlay = None # type: ignore[assignment] + LOGGER = logging.getLogger(__name__) LOCK = threading.Lock() -@contextmanager -def _open_append_log(path: Path): - """Open a log file for append. - - Args: - path: Path to the log file. - - Yields: - An open text file handle in append mode. - """ - with path.open("a", encoding="utf-8", errors="ignore") as f: - yield f - - class ScriptHandler: def __init__(self): self.loot_interaction_thread = None - self.paragon_overlay_proc = None - self._paragon_overlay_log = None - self._paragon_overlay_log_stack: ExitStack | None = None + self.paragon_overlay_thread: threading.Thread | None = None + self._vision_mode_was_running_before_overlay = False if IniConfigLoader().general.vision_mode_type == VisionModeType.fast: self.vision_mode = src.scripts.vision_mode_fast.VisionModeFast() else: @@ -62,22 +52,15 @@ def _graceful_exit(self): safe_exit() def toggle_paragon_overlay(self): - """Toggle the Paragon overlay process (start if not running, stop if running).""" + """Toggle the Paragon overlay thread (start if not running, request close if running).""" try: - # If already running -> stop it - if self.paragon_overlay_proc is not None and self.paragon_overlay_proc.poll() is None: + if self.paragon_overlay_thread is not None and self.paragon_overlay_thread.is_alive(): LOGGER.info("Closing Paragon overlay") with suppress(Exception): - self.paragon_overlay_proc.terminate() - with suppress(Exception): - self.paragon_overlay_proc.wait(timeout=2) - self.paragon_overlay_proc = None - with suppress(Exception): - if self._paragon_overlay_log_stack is not None: - self._paragon_overlay_log_stack.close() - self._paragon_overlay_log_stack = None - self._paragon_overlay_log = None - self._paragon_overlay_log = None + if request_close is not None: + request_close() + self.paragon_overlay_thread.join(timeout=2) + # Vision mode is restored by the overlay thread cleanup. return config = IniConfigLoader() @@ -93,62 +76,38 @@ def toggle_paragon_overlay(self): f"No Paragon JSON files found in {overlay_dir}. Import a build first or place *.json files there." ) - # Build command to launch overlay mode - if getattr(sys, "frozen", False): - cmd = [sys.executable, "--paragon-overlay", str(overlay_dir)] - cwd = str(Path(sys.executable).parent) - else: - # From source: ensure project root is cwd so `-m src.main` works reliably - project_root = Path(__file__).resolve().parents[2] - cmd = [sys.executable, "-m", "src.main", "--paragon-overlay", str(overlay_dir)] - cwd = str(project_root) - - creationflags = 0 - startupinfo = None - if sys.platform == "win32": - creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) - try: - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - except Exception: - startupinfo = None - - LOGGER.info(f"Opening Paragon overlay (source: {overlay_dir})") - # Capture any overlay errors in a log file (important when console is hidden) - log_path = overlay_dir / "paragon_overlay.log" - with suppress(Exception): - if self._paragon_overlay_log_stack is not None: - self._paragon_overlay_log_stack.close() - self._paragon_overlay_log_stack = ExitStack() - try: - self._paragon_overlay_log = self._paragon_overlay_log_stack.enter_context(_open_append_log(log_path)) - except OSError: - self._paragon_overlay_log = None - with suppress(Exception): - self._paragon_overlay_log_stack.close() - self._paragon_overlay_log_stack = None + # Disable vision mode while the overlay is active; restore it when the overlay closes. + self._vision_mode_was_running_before_overlay = self.vision_mode.running() + if self._vision_mode_was_running_before_overlay: + self.vision_mode.stop() - self.paragon_overlay_proc = subprocess.Popen( - cmd, - cwd=cwd, - stdout=self._paragon_overlay_log or subprocess.DEVNULL, - stderr=self._paragon_overlay_log or subprocess.DEVNULL, - creationflags=creationflags, - startupinfo=startupinfo, + LOGGER.info("Opening Paragon overlay (source: %s)", overlay_dir) + self.paragon_overlay_thread = threading.Thread( + target=self._run_paragon_overlay, args=(str(overlay_dir),), daemon=True ) - - # If it exits immediately, surface the issue in the D4LF log. - time.sleep(0.2) - if self.paragon_overlay_proc.poll() is not None: - LOGGER.error( - "Paragon overlay exited immediately (code=%s). See log: %s", - self.paragon_overlay_proc.returncode, - log_path, - ) + self.paragon_overlay_thread.start() except Exception: LOGGER.exception("Failed to toggle Paragon overlay") + def _run_paragon_overlay(self, preset_path: str) -> None: + try: + if run_paragon_overlay is None: # pragma: no cover + LOGGER.warning("Paragon overlay is not supported on this platform") + return + run_paragon_overlay(preset_path) + except Exception: + LOGGER.exception("Paragon overlay crashed") + finally: + # Overlay has stopped (or failed to start). Restore vision mode if we stopped it. + try: + if self._vision_mode_was_running_before_overlay and not self.vision_mode.running(): + self.vision_mode.start() + except Exception: + LOGGER.exception("Failed to restore vision mode after Paragon overlay") + finally: + self.paragon_overlay_thread = None + def setup_key_binds(self): keyboard.add_hotkey(IniConfigLoader().advanced_options.run_vision_mode, lambda: self.run_vision_mode()) keyboard.add_hotkey(IniConfigLoader().advanced_options.exit_key, lambda: self._graceful_exit()) From 1c396d33033c33d9ceff2af59a133e8a1acda1f1 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Mon, 2 Feb 2026 18:16:19 +0100 Subject: [PATCH 10/16] README: reorder common issues --- README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bba19275..365bf2e5 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,11 @@ D4LF can import Paragon boards from supported build planners and show them in-ga ### Common problems +- Paragon overlay does not appear / does nothing + - Ensure Diablo IV is running in **borderless windowed** (exclusive fullscreen may block overlays). + - Ensure your Paragon folder contains `*.json` files (default: `C:/Users//.d4lf/paragon`). + - Check/adjust `advanced_options.toggle_paragon_overlay` (default `f10`) and ensure it is not conflicting with other hotkeys. + - If the overlay is off-screen, delete `d4_overlay_config.json` next to `d4lf.exe` to reset its position. - The GUI crashes immediately upon opening, with no error message given - This almost always means there is an issue in your params.ini. Delete the file and then open the GUI and configure your params.ini through the config tab. Using the GUI for configuration will ensure the file is always accurate. @@ -159,12 +164,6 @@ Current functionality: Each window gives further instructions on how to use it and what kind of input it expects. -- Paragon overlay does not appear / does nothing - - Ensure Diablo IV is running in **borderless windowed** (exclusive fullscreen may block overlays). - - Ensure your Paragon folder contains `*.json` files (default: `C:/Users//.d4lf/paragon`). - - Check/adjust `advanced_options.toggle_paragon_overlay` (default `f10`) and ensure it is not conflicting with other hotkeys. - - If the overlay is off-screen, delete `d4_overlay_config.json` next to `d4lf.exe` to reset its position. - ## How to filter / Profiles All profiles define whitelist filters. If no filter included in your profiles matches the item, it will be discarded. From 6583d1d5703fec41a211fabf369c3f96ed03bc61 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Mon, 2 Feb 2026 18:42:12 +0100 Subject: [PATCH 11/16] treated Windows as the default runtime --- src/scripts/handler.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/scripts/handler.py b/src/scripts/handler.py index 1e2d77e5..66eab05c 100644 --- a/src/scripts/handler.py +++ b/src/scripts/handler.py @@ -1,13 +1,12 @@ import logging -import sys import threading import time import typing from contextlib import suppress from pathlib import Path -if sys.platform != "darwin": - import keyboard +import keyboard + import src.scripts.loot_filter_tts import src.scripts.vision_mode_fast import src.scripts.vision_mode_with_highlighting @@ -16,6 +15,7 @@ from src.config.loader import IniConfigLoader from src.config.models import ItemRefreshType, VisionModeType from src.loot_mover import move_items_to_inventory, move_items_to_stash +from src.paragon_overlay import request_close, run_paragon_overlay from src.scripts.common import SETUP_INSTRUCTIONS_URL from src.ui.char_inventory import CharInventory from src.ui.stash import Stash @@ -23,12 +23,6 @@ from src.utils.process_handler import kill_thread, safe_exit from src.utils.window import screenshot -if sys.platform == "win32": - from src.paragon_overlay import request_close, run_paragon_overlay -else: - request_close = None # type: ignore[assignment] - run_paragon_overlay = None # type: ignore[assignment] - LOGGER = logging.getLogger(__name__) LOCK = threading.Lock() @@ -57,8 +51,7 @@ def toggle_paragon_overlay(self): if self.paragon_overlay_thread is not None and self.paragon_overlay_thread.is_alive(): LOGGER.info("Closing Paragon overlay") with suppress(Exception): - if request_close is not None: - request_close() + request_close() self.paragon_overlay_thread.join(timeout=2) # Vision mode is restored by the overlay thread cleanup. return @@ -92,9 +85,6 @@ def toggle_paragon_overlay(self): def _run_paragon_overlay(self, preset_path: str) -> None: try: - if run_paragon_overlay is None: # pragma: no cover - LOGGER.warning("Paragon overlay is not supported on this platform") - return run_paragon_overlay(preset_path) except Exception: LOGGER.exception("Paragon overlay crashed") From 4764802a1b2e9429b281d2c80f118ef56bc760af Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Mon, 2 Feb 2026 21:00:59 +0100 Subject: [PATCH 12/16] no .json, paragon is stored in .yaml file --- src/gui/importer/d4builds.py | 14 +- src/gui/importer/maxroll.py | 14 +- src/gui/importer/mobalytics.py | 14 +- src/gui/importer/paragon_export.py | 387 +++++++++++++++++++++++------ src/paragon_overlay.py | 169 +++++++++++-- 5 files changed, 488 insertions(+), 110 deletions(-) diff --git a/src/gui/importer/d4builds.py b/src/gui/importer/d4builds.py index e8305372..ccdce980 100644 --- a/src/gui/importer/d4builds.py +++ b/src/gui/importer/d4builds.py @@ -30,7 +30,11 @@ update_mingreateraffixcount, ) from src.gui.importer.importer_config import ImportConfig -from src.gui.importer.paragon_export import export_paragon_build_json, extract_d4builds_paragon_steps +from src.gui.importer.paragon_export import ( + build_paragon_profile_payload, + extract_d4builds_paragon_steps, + write_paragon_into_profile_yaml, +) from src.item.data.affix import Affix, AffixType from src.item.data.item_type import WEAPON_TYPES, ItemType from src.item.descr.text import clean_str, closest_match @@ -216,12 +220,8 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): if config.export_paragon: steps = extract_d4builds_paragon_steps(driver, class_name=class_name) if steps: - export_paragon_build_json( - file_stem=f"{corrected_file_name}_paragon", - build_name=file_name, - source_url=url, - paragon_boards_list=steps, - ) + payload = build_paragon_profile_payload(build_name=file_name, source_url=url, paragon_boards_list=steps) + write_paragon_into_profile_yaml(profile_ref=corrected_file_name, payload=payload) else: LOGGER.warning("Paragon export enabled, but no paragon data was found on this D4Builds page.") diff --git a/src/gui/importer/maxroll.py b/src/gui/importer/maxroll.py index 3325f476..c0fbf5c2 100644 --- a/src/gui/importer/maxroll.py +++ b/src/gui/importer/maxroll.py @@ -23,7 +23,11 @@ update_mingreateraffixcount, ) from src.gui.importer.importer_config import ImportConfig -from src.gui.importer.paragon_export import export_paragon_build_json, extract_maxroll_paragon_steps +from src.gui.importer.paragon_export import ( + build_paragon_profile_payload, + extract_maxroll_paragon_steps, + write_paragon_into_profile_yaml, +) from src.item.data.affix import Affix, AffixType from src.item.data.item_type import ItemType from src.item.descr.text import clean_str, closest_match @@ -188,12 +192,8 @@ def import_maxroll(config: ImportConfig): if config.export_paragon: steps = extract_maxroll_paragon_steps(active_profile) if steps: - export_paragon_build_json( - file_stem=f"{corrected_file_name}_paragon", - build_name=build_name, - source_url=url, - paragon_boards_list=steps, - ) + payload = build_paragon_profile_payload(build_name=build_name, source_url=url, paragon_boards_list=steps) + write_paragon_into_profile_yaml(profile_ref=corrected_file_name, payload=payload) else: LOGGER.warning("Paragon export enabled, but no paragon steps were found in this Maxroll profile.") diff --git a/src/gui/importer/mobalytics.py b/src/gui/importer/mobalytics.py index e05acd1c..daf3f9c7 100644 --- a/src/gui/importer/mobalytics.py +++ b/src/gui/importer/mobalytics.py @@ -27,7 +27,11 @@ update_mingreateraffixcount, ) from src.gui.importer.importer_config import ImportConfig -from src.gui.importer.paragon_export import export_paragon_build_json, extract_mobalytics_paragon_steps +from src.gui.importer.paragon_export import ( + build_paragon_profile_payload, + extract_mobalytics_paragon_steps, + write_paragon_into_profile_yaml, +) from src.item.data.affix import Affix, AffixType from src.item.data.item_type import WEAPON_TYPES, ItemType from src.item.descr.text import clean_str, closest_match @@ -241,12 +245,8 @@ def import_mobalytics(config: ImportConfig): if config.export_paragon: steps = extract_mobalytics_paragon_steps(variant if isinstance(variant, dict) else {}) if steps: - export_paragon_build_json( - file_stem=f"{corrected_file_name}_paragon", - build_name=build_name, - source_url=url, - paragon_boards_list=steps, - ) + payload = build_paragon_profile_payload(build_name=build_name, source_url=url, paragon_boards_list=steps) + write_paragon_into_profile_yaml(profile_ref=corrected_file_name, payload=payload) else: LOGGER.warning("Paragon export enabled, but no paragon data was found for this Mobalytics variant.") diff --git a/src/gui/importer/paragon_export.py b/src/gui/importer/paragon_export.py index 85fadf7d..894113f6 100644 --- a/src/gui/importer/paragon_export.py +++ b/src/gui/importer/paragon_export.py @@ -5,13 +5,23 @@ import logging import re import time -from functools import lru_cache +from pathlib import Path from typing import TYPE_CHECKING, Any from src import __version__ -from src.config import BASE_DIR from src.config.loader import IniConfigLoader +try: + from ruamel.yaml import YAML as RUAMEL_YAML +except ImportError: # pragma: no cover + RUAMEL_YAML = None + +try: + import yaml as PyYAML +except ImportError: # pragma: no cover + PyYAML = None + + try: from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait @@ -20,8 +30,6 @@ WebDriverWait = None # type: ignore[assignment] if TYPE_CHECKING: - from pathlib import Path - from selenium.webdriver.remote.webdriver import WebDriver @@ -50,45 +58,221 @@ def _prefix_with_class_slug(slug: str, class_slug: str) -> str: # --------------------------------------------------------------------------- -# Maxroll uses internal IDs for boards/glyphs. We keep the exported identifiers stable -# by resolving IDs to human-friendly names from data files (generated from d4data). -# -# Expected file (fallback to enUS): -# assets/lang//paragon_maxroll_ids.json -# Format: -# {"boards": {"": "", ...}, "glyphs": {"": "", ...}} +# Maxroll ID -> human friendly names (ported from Diablo4Companion data files). +# Used to export Paragon JSON with readable identifiers (similar to Mobalytics). # --------------------------------------------------------------------------- - -@lru_cache(maxsize=1) -def _load_maxroll_name_maps() -> tuple[dict[str, str], dict[str, str]]: - lang = IniConfigLoader().general.language - candidates = ( - BASE_DIR / f"assets/lang/{lang}/paragon_maxroll_ids.json", - BASE_DIR / "assets/lang/enUS/paragon_maxroll_ids.json", - ) - - for path in candidates: - try: - with path.open(encoding="utf-8") as f: - data = json.load(f) - except FileNotFoundError: - continue - except OSError: - LOGGER.debug("Failed to read Maxroll paragon mapping file: %s", path, exc_info=True) - continue - - if not isinstance(data, dict): - continue - - boards = data.get("boards") or {} - glyphs = data.get("glyphs") or {} - if isinstance(boards, dict) and isinstance(glyphs, dict): - boards_map = {str(k): str(v) for k, v in boards.items()} - glyphs_map = {str(k): str(v) for k, v in glyphs.items()} - return boards_map, glyphs_map - - return {}, {} +_MAXROLL_BOARD_ID_TO_NAME = { + "Paragon_Barb_00": "Start", + "Paragon_Barb_01": "Hemorrhage", + "Paragon_Barb_02": "Blood Rage", + "Paragon_Barb_03": "Carnage", + "Paragon_Barb_04": "Decimator", + "Paragon_Barb_05": "Bone Breaker", + "Paragon_Barb_06": "Flawless Technique", + "Paragon_Barb_07": "Warbringer", + "Paragon_Barb_08": "Weapons Master", + "Paragon_Barb_10": "Force of Nature", + "Paragon_Druid_00": "Start", + "Paragon_Druid_01": "Thunderstruck", + "Paragon_Druid_02": "Earthen Devastation", + "Paragon_Druid_03": "Survival Instincts", + "Paragon_Druid_04": "Lust for Carnage", + "Paragon_Druid_05": "Heightened Malice", + "Paragon_Druid_06": "Inner Beast", + "Paragon_Druid_07": "Constricting Tendrils", + "Paragon_Druid_08": "Ancestral Guidance", + "Paragon_Druid_10": "Untamed", + "Paragon_Necro_00": "Start", + "Paragon_Necro_01": "Cult Leader", + "Paragon_Necro_02": "Hulking Monstrosity", + "Paragon_Necro_03": "Flesh-eater", + "Paragon_Necro_04": "Scent of Death", + "Paragon_Necro_05": "Bone Graft", + "Paragon_Necro_06": "Blood Begets Blood", + "Paragon_Necro_07": "Bloodbath", + "Paragon_Necro_08": "Wither", + "Paragon_Necro_10": "Frailty", + "Paragon_Paladin_00": "Start", + "Paragon_Paladin_01": "Castle", + "Paragon_Paladin_02": "Shield Bearer", + "Paragon_Paladin_03": "Fervent", + "Paragon_Paladin_04": "Preacher", + "Paragon_Paladin_05": "Divinity", + "Paragon_Paladin_06": "Relentless", + "Paragon_Paladin_07": "Sentencing", + "Paragon_Paladin_08": "Endure", + "Paragon_Paladin_09": "Beacon", + "Paragon_Rogue_00": "Start", + "Paragon_Rogue_01": "Eldritch Bounty", + "Paragon_Rogue_02": "Tricks of the Trade", + "Paragon_Rogue_03": "Cheap Shot", + "Paragon_Rogue_04": "Deadly Ambush", + "Paragon_Rogue_05": "Leyrana's Instinct", + "Paragon_Rogue_06": "No Witnesses", + "Paragon_Rogue_07": "Exploit Weakness", + "Paragon_Rogue_08": "Cunning Stratagem", + "Paragon_Rogue_10": "Danse Macabre", + "Paragon_Sorc_00": "Start", + "Paragon_Sorc_01": "Searing Heat", + "Paragon_Sorc_02": "Frigid Fate", + "Paragon_Sorc_03": "Static Surge", + "Paragon_Sorc_04": "Elemental Summoner", + "Paragon_Sorc_05": "Burning Instinct", + "Paragon_Sorc_06": "Icefall", + "Paragon_Sorc_07": "Ceaseless Conduit", + "Paragon_Sorc_08": "Enchantment Master", + "Paragon_Sorc_10": "Fundamental Release", + "Paragon_Spirit_0": "Start", + "Paragon_Spirit_01": "In-Fighter", + "Paragon_Spirit_02": "Spiney Skin", + "Paragon_Spirit_03": "Viscous Shield", + "Paragon_Spirit_04": "Bitter Medicine", + "Paragon_Spirit_05": "Revealing", + "Paragon_Spirit_06": "Drive", + "Paragon_Spirit_07": "Convergence", + "Paragon_Spirit_08": "Sapping", +} + +_MAXROLL_GLYPH_ID_TO_NAME = { + "Rare_001_Intelligence_Main": "Enchanter", + "Rare_002_Intelligence_Main": "Unleash", + "Rare_003_Intelligence_Main": "Elementalist", + "Rare_004_Intelligence_Main": "Adept", + "Rare_005_Intelligence_Main": "Conjurer", + "Rare_006_Intelligence_Main": "Charged", + "Rare_007_Willpower_Side": "Torch", + "Rare_008_Willpower_Side": "Pyromaniac", + "Rare_009_Willpower_Side": "Cryopathy", + "Rare_010_Dexterity_Main": "Tactician", + "Rare_011_Intelligence_Side": "Guzzler", + "Rare_011_Willpower_Side": "Imbiber", + "Rare_012_Intelligence_Side": "Protector", + "Rare_012_Willpower_Side": "Reinforced", + "Rare_013_Dexterity_Side": "Poise", + "Rare_014_Dexterity_Side": "Territorial", + "Rare_014_Strength_Main": "Turf", + "Rare_014_Strength_Side": "Turf", + "Rare_015_Dexterity_Side": "Flamefeeder", + "Rare_016_Dexterity_Side": "Exploit", + "Rare_016_Intelligence_Side": "Exploit", + "Rare_016_Strength_Side": "Exploit", + "Rare_017_Dexterity_Side": "Winter", + "Rare_018_Dexterity_Side": "Electrocute", + "Rare_019_Dexterity_Side": "Destruction", + "Rare_020_Dexterity_Side": "Control", + "Rare_020_Intelligence_Main": "Control", + "Rare_020_Intelligence_Side": "Control", + "Rare_021_Strength_Main": "Ambidextrous", + "Rare_022_Strength_Main": "Might", + "Rare_023_Strength_Main": "Cleaver", + "Rare_024_Strength_Main": "Seething", + "Rare_025_Strength_Main": "Crusher", + "Rare_026_Strength_Main": "Executioner", + "Rare_027_Strength_Main": "Ire", + "Rare_028_Strength_Main": "Marshal", + "Rare_029_Dexterity_Side": "Bloodfeeder", + "Rare_030_Dexterity_Side": "Wrath", + "Rare_031_Dexterity_Side": "Weapon Master", + "Rare_032_Dexterity_Side": "Mortal Draw", + "Rare_033_Intelligence_Side": "Revenge", + "Rare_033_Willpower_Side": "Revenge", + "Rare_033_Willpower_Side_Necro": "Revenge", + "Rare_034_Intelligence_Side": "Undaunted", + "Rare_034_Willpower_Side": "Undaunted", + "Rare_035_Intelligence_Side": "Dominate", + "Rare_035_Willpower_Side": "Dominate", + "Rare_035_Willpower_Side_Necro": "Dominate", + "Rare_036_Willpower_Side": "Disembowel", + "Rare_037_Willpower_Side": "Brawl", + "Rare_038_Intelligence_Main": "Corporeal", + "Rare_039_Willpower_Main": "Fang and Claw", + "Rare_040_Willpower_Main": "Earth and Sky", + "Rare_041_Intelligence_Side": "Wilds", + "Rare_042_Willpower_Main": "Werebear", + "Rare_043_Willpower_Main": "Werewolf", + "Rare_044_Willpower_Main": "Human", + "Rare_045_Intelligence_Side": "Bane", + "Rare_045_Strength_Side": "Bane", + "Rare_046_Dexterity_Side": "Abyssal", + "Rare_046_Intelligence_Side": "Keeper", + "Rare_047_Dexterity_Side": "Fulminate", + "Rare_047_Intelligence_Side": "Fulminate", + "Rare_048_Dexterity_Side": "Tracker", + "Rare_048_Intelligence_Side": "Tracker", + "Rare_049_Dexterity_Side": "Outmatch", + "Rare_049_Strength_Main": "Outmatch", + "Rare_049_Strength_Side": "Outmatch", + "Rare_050_Dexterity_Main": "Spirit", + "Rare_050_Dexterity_Side": "Spirit", + "Rare_050_Willpower_Side": "Spirit", + "Rare_051_Dexterity_Side": "Shapeshifter", + "Rare_052_Dexterity_Main": "Versatility", + "Rare_053_Dexterity_Main": "Closer", + "Rare_054_Dexterity_Main": "Ranger", + "Rare_055_Dexterity_Main": "Chip", + "Rare_055_Dexterity_Side": "Chip", + "Rare_055_Willpower_Side": "Chip", + "Rare_056_Dexterity_Main": "Frostfeeder", + "Rare_057_Dexterity_Main": "Fluidity", + "Rare_058_Intelligence_Side": "Infusion", + "Rare_059_Dexterity_Main": "Devious", + "Rare_060_Dexterity_Side": "Warrior", + "Rare_061_Intelligence_Side": "Combat", + "Rare_062_Dexterity_Side": "Gravekeeper", + "Rare_063_Intelligence_Side": "Canny", + "Rare_064_Intelligence_Side": "Efficacy", + "Rare_065_Intelligence_Side": "Snare", + "Rare_066_Dexterity_Side": "Essence", + "Rare_067_Strength_Side": "Pride", + "Rare_068_Strength_Side": "Ambush", + "Rare_069_Intelligence_Main": "Sacrificial", + "Rare_070_Intelligence_Main": "Blood-drinker", + "Rare_071_Intelligence_Main": "Deadraiser", + "Rare_072_Intelligence_Main": "Mage", + "Rare_073_Intelligence_Main": "Amplify", + "Rare_074_Willpower_Side": "Golem", + "Rare_075_Willpower_Side": "Scourge", + "Rare_076_Strength_Main": "Diminish", + "Rare_076_Strength_Side": "Diminish", + "Rare_077_Willpower_Side": "Warding", + "Rare_078_Willpower_Side": "Darkness", + "Rare_079_Dexterity_Side": "Exploit", + "Rare_080_Strength_Main": "Twister", + "Rare_081_Strength_Main": "Rumble", + "Rare_082_Dexterity_Main": "Explosive", + "Rare_083_Intelligence_Side": "Nightstalker", + "Rare_084_Intelligence_Main": "Stalagmite", + "Rare_085_Dexterity_Side": "Invocation", + "Rare_086_Dexterity_Side": "Tectonic", + "Rare_087_Willpower_Main": "Electrocution", + "Rare_088_Intelligence_Main": "Exhumation", + "Rare_089_Willpower_Side": "Desecration", + "Rare_090_Dexterity_Main": "Menagerist", + "Rare_091_Strength_Side": "Hone", + "Rare_092_Intelligence_Side": "Consumption", + "Rare_093_Dexterity_Main": "Fitness", + "Rare_094_Intelligence_Side": "Ritual", + "Rare_095_Dexterity_Main": "Jagged Plume", + "Rare_096_Strength_Side": "Innate", + "Rare_097_Dexterity_Main": "Wildfire", + "Rare_098_Strength_Side": "Colossal", + "Rare_100_Dexterity_Main": "Talon", + "Rare_101_Strength_Side": "Hubris", + "Rare_102_Dexterity_Main": "Fester", + "Rare_103_Strength_Main": "Sentinel", + "Rare_104_Dexterity_Side": "Honed", + "Rare_105_Strength_Main": "Law", + "Rare_106_Willpower_Side": "Arbiter ", + "Rare_107_Strength_Main": "Resplendence", + "Rare_108_Intelligence_Side": "Judicator", + "Rare_109_Dexterity_Side": "Feverous", + "Rare_110_Strength_Main": "Apostle", + "Rare_Dex_Generic": "Headhunter", + "Rare_Int_Generic": "Eliminator", + "Rare_Str_Generic": "Challenger", + "Rare_Will_Generic": "Headhunter", +} def _slugify(s: str) -> str: @@ -105,8 +289,7 @@ def _maxroll_class_slug(board_id: str) -> str: def _maxroll_board_slug(board_id: str) -> str: cls = _maxroll_class_slug(board_id) - boards_map, _ = _load_maxroll_name_maps() - name = boards_map.get(board_id, board_id) + name = _MAXROLL_BOARD_ID_TO_NAME.get(board_id, board_id) name_slug = _slugify(name) return f"{cls}-{name_slug}" if cls and name_slug else _slugify(board_id) @@ -114,8 +297,7 @@ def _maxroll_board_slug(board_id: str) -> str: def _maxroll_glyph_slug(glyph_id: str, board_id: str) -> str: # We prefix with class for consistency with Mobalytics output. cls = _maxroll_class_slug(board_id) - _, glyphs_map = _load_maxroll_name_maps() - name = glyphs_map.get(glyph_id, glyph_id) + name = _MAXROLL_GLYPH_ID_TO_NAME.get(glyph_id, glyph_id) name_slug = _slugify(name) return f"{cls}-{name_slug}" if cls and name_slug else _slugify(glyph_id) @@ -149,6 +331,76 @@ def export_paragon_build_json( return out_path +def build_paragon_profile_payload( + build_name: str, source_url: str, paragon_boards_list: list[list[dict[str, Any]]] +) -> dict[str, Any]: + """Build the Paragon payload intended to be embedded into a profile YAML. + + The structure matches the existing JSON export payload (without the outer list wrapper). + """ + return { + "Name": build_name, + "Source": source_url, + "GeneratedAt": datetime.datetime.now(tz=datetime.UTC).strftime("%Y-%m-%d %H:%M:%S UTC"), + "Generator": f"d4lf v{__version__}", + "ParagonBoardsList": paragon_boards_list, + } + + +def write_paragon_into_profile_yaml(profile_ref: str, payload: dict[str, Any]) -> Path | None: + """Embed paragon payload into a profile YAML under a top-level ``Paragon`` key. + + Returns the updated profile path if successful; otherwise returns ``None``. + """ + profiles_dir = IniConfigLoader().user_dir / "profiles" + p = Path(profile_ref) + + # Accept absolute or relative path + if p.is_file(): + profile_path = p + else: + stem = p.stem + candidates = [ + profiles_dir / f"{stem}.yaml", + profiles_dir / f"{stem}.yml", + profiles_dir / stem, # in case save_as_profile returns full filename + ] + profile_path = next((c for c in candidates if c.exists()), None) + + if profile_path is None or not profile_path.exists(): + LOGGER.warning("Unable to locate profile YAML for Paragon embed: %s", profile_ref) + return None + + data: dict[str, Any] = {} + try: + if RUAMEL_YAML is not None: + y = RUAMEL_YAML(typ="rt") + with profile_path.open("r", encoding="utf-8") as f: + loaded = y.load(f) or {} + if isinstance(loaded, dict): + data = loaded + data["Paragon"] = payload + with profile_path.open("w", encoding="utf-8") as f: + y.dump(data, f) + elif PyYAML is not None: + with profile_path.open("r", encoding="utf-8") as f: + loaded = PyYAML.safe_load(f) or {} + if isinstance(loaded, dict): + data = loaded + data["Paragon"] = payload + with profile_path.open("w", encoding="utf-8") as f: + PyYAML.safe_dump(data, f, sort_keys=False, allow_unicode=True) + else: # pragma: no cover + LOGGER.error("No YAML library available (ruamel.yaml or PyYAML); cannot embed Paragon.") + return None + except Exception: + LOGGER.exception("Failed embedding Paragon into profile YAML: %s", profile_path) + return None + + LOGGER.info("Embedded Paragon into profile: %s", profile_path) + return profile_path + + def extract_maxroll_paragon_steps(active_profile: dict[str, Any]) -> list[list[dict[str, Any]]]: """Extract paragon steps from Maxroll planner data. @@ -248,27 +500,24 @@ def extract_mobalytics_paragon_steps(variant: dict[str, Any]) -> list[list[dict[ def _parse_d4builds_paragon_boards(driver: WebDriver, class_slug: str) -> list[list[dict[str, Any]]]: """Parse D4Builds paragon boards from the currently loaded page.""" boards_out: list[dict[str, Any]] = [] - try: board_elements = driver.find_elements(By.CLASS_NAME, "paragon__board") except Exception: - LOGGER.debug("Failed to locate D4Builds paragon boards (continuing).", exc_info=True) board_elements = [] for board_elem in board_elements: name_raw = "" - lines: list[str] = [] + lines = [] name_display = "" - try: name_raw = board_elem.find_element(By.CLASS_NAME, "paragon__board__name").get_attribute("innerText") or "" - lines = [ln.strip() for ln in name_raw.splitlines() if ln.strip()] - # Prefer a line containing letters (sometimes line 1 is a numeric index) + lines = [ln.strip() for ln in (name_raw or "").splitlines() if ln.strip()] + # Prefer first line that contains letters (D4Builds sometimes shows just a numeric index on line 1) name_display = next((ln for ln in lines if any(ch.isalpha() for ch in ln)), (lines[0] if lines else "")) except Exception: name_display = "" - # Try to infer a stable board id/slug from element attributes (best effort) + # Try to detect a stable board id/slug from element attributes (best effort) board_id = "" try: attrs = driver.execute_script( @@ -281,7 +530,6 @@ def _parse_d4builds_paragon_boards(driver: WebDriver, class_slug: str) -> list[l if isinstance(v, str) and v.strip(): board_id = v.strip() break - if not board_id: for v in attrs.values(): if isinstance(v, str): @@ -292,7 +540,8 @@ def _parse_d4builds_paragon_boards(driver: WebDriver, class_slug: str) -> list[l except Exception: LOGGER.debug("Failed to infer board id (continuing).", exc_info=True) - name_slug = _prefix_with_class_slug(_slugify(board_id or name_display), class_slug) + name_slug = _slugify(board_id or name_display) + name_slug = _prefix_with_class_slug(name_slug, class_slug) if not name_slug and lines and str(lines[0]).isdigit(): name_slug = f"board-{lines[0]}" @@ -304,8 +553,9 @@ def _parse_d4builds_paragon_boards(driver: WebDriver, class_slug: str) -> list[l except Exception: LOGGER.debug("Failed to read glyph name (continuing).", exc_info=True) - glyph_display = glyph_raw.replace("(", "").replace(")", "").strip() - glyph_slug = _prefix_with_class_slug(_slugify(glyph_display), class_slug) + glyph_display = (glyph_raw or "").replace("(", "").replace(")", "").strip() + glyph_slug = _slugify(glyph_display) + glyph_slug = _prefix_with_class_slug(glyph_slug, class_slug) style_str = board_elem.get_attribute("style") or "" rotate_int = 0 @@ -317,7 +567,7 @@ def _parse_d4builds_paragon_boards(driver: WebDriver, class_slug: str) -> list[l except Exception: rotate_int = 0 - nodes = [False] * NODES_LEN + nodes = [False] * (21 * 21) try: tile_elems = board_elem.find_elements(By.CLASS_NAME, "paragon__board__tile") @@ -328,17 +578,12 @@ def _parse_d4builds_paragon_boards(driver: WebDriver, class_slug: str) -> list[l cls = tile.get_attribute("class") or "" if "active" not in cls: continue - parts = [pp for pp in cls.split() if pp] # Example: "paragon__board__tile r2 c10 active enabled" r_part = next((x for x in parts if x.startswith("r")), "r0") c_part = next((x for x in parts if x.startswith("c")), "c0") - - try: - r = int("".join(ch for ch in r_part if ch.isdigit()) or "0") - c = int("".join(ch for ch in c_part if ch.isdigit()) or "0") - except ValueError: - continue + r = int("".join(ch for ch in r_part if ch.isdigit()) or "0") + c = int("".join(ch for ch in c_part if ch.isdigit()) or "0") # Transform coordinates based on rotation (matching Diablo4Companion) x = c @@ -347,17 +592,17 @@ def _parse_d4builds_paragon_boards(driver: WebDriver, class_slug: str) -> list[l x = x - 1 y = y - 1 elif rotate_int == 90: - x = GRID - r + x = 21 - r y = c - 1 elif rotate_int == 180: - x = GRID - c - y = GRID - r + x = 21 - c + y = 21 - r elif rotate_int == 270: x = r - 1 - y = GRID - c + y = 21 - c - if 0 <= x < GRID and 0 <= y < GRID: - nodes[y * GRID + x] = True + if 0 <= x < 21 and 0 <= y < 21: + nodes[y * 21 + x] = True boards_out.append({ "Name": name_slug or "paragon-board", @@ -366,7 +611,7 @@ def _parse_d4builds_paragon_boards(driver: WebDriver, class_slug: str) -> list[l "Nodes": nodes, }) - return [boards_out] if boards_out else [] + return [boards_out] def extract_d4builds_paragon_steps( diff --git a/src/paragon_overlay.py b/src/paragon_overlay.py index 2b879448..2dbee797 100644 --- a/src/paragon_overlay.py +++ b/src/paragon_overlay.py @@ -27,9 +27,26 @@ import re import sys from pathlib import Path +from typing import Any from PIL import Image, ImageDraw, ImageFont +try: + from ruamel.yaml import YAML as RUAMEL_YAML +except ImportError: # pragma: no cover + RUAMEL_YAML = None + +try: + import yaml as PyYAML +except ImportError: # pragma: no cover + PyYAML = None + +try: + from src.config.loader import IniConfigLoader +except ImportError: # pragma: no cover + IniConfigLoader = None + + # --- HARDENED WIN32 DEFINITIONS (64-BIT SAFE) --- user32 = ctypes.WinDLL("user32", use_last_error=True) gdi32 = ctypes.WinDLL("gdi32", use_last_error=True) @@ -283,7 +300,7 @@ def _normalize_steps(raw_list): return [raw_list] -def _load_builds_from_file(preset_file: str, name_tag: str | None = None): +def _load_builds_from_file(preset_file: str, name_tag: str | None = None, profile: str | None = None): """Load one JSON file and return a list of builds in overlay format: {name, boards}. Supports: @@ -312,7 +329,7 @@ def _load_builds_from_file(preset_file: str, name_tag: str | None = None): step_name = f"{base_name} - Step {step_no}" if name_tag: step_name = f"{step_name} [{name_tag}]" - builds.append({"name": step_name, "boards": boards}) + builds.append({"name": step_name, "boards": boards, "profile": profile}) if not builds: msg = f"No valid builds in {preset_file}" @@ -322,30 +339,133 @@ def _load_builds_from_file(preset_file: str, name_tag: str | None = None): def load_builds_from_path(preset_path: str): - """Load builds from a JSON file OR from a folder containing multiple *.json files.""" + """Load builds from a JSON preset or from a folder of profile/preset files. + + Supported inputs: + - a single JSON preset file (legacy) + - a single YAML profile file (contains a top-level `Paragon:` payload) + - a directory containing *.yaml/*.yml profiles and/or *.json presets + + If the directory is the d4lf profiles folder, we try to only load active profiles (general.profiles). + """ + + def _load_profile_paragon(fp: Path) -> dict[str, Any] | None: + try: + with fp.open("r", encoding="utf-8") as f: + if RUAMEL_YAML is not None: + y = RUAMEL_YAML(typ="safe") + data = y.load(f) or {} + elif PyYAML is not None: + data = PyYAML.safe_load(f) or {} + else: # pragma: no cover + return None + except Exception: + LOGGER.debug("Skipping invalid YAML profile: %s", fp, exc_info=True) + return None + if not isinstance(data, dict): + return None + par = data.get("Paragon") + if not isinstance(par, dict): + return None + if not par.get("ParagonBoardsList"): + return None + return par + p = Path(preset_path) + + # Determine active profile stems (best effort) + active_stems: set[str] | None = None + try: + if IniConfigLoader is not None: + cfg = IniConfigLoader() + gp = getattr(cfg, "general", None) + profs = getattr(gp, "profiles", None) if gp is not None else None + if isinstance(profs, list) and profs: + active_stems = {Path(x).stem for x in profs} + except Exception: + active_stems = None + if p.is_dir(): - files = sorted(p.glob("*.json"), key=lambda fp: fp.stat().st_mtime, reverse=True) + files: list[Path] = [] + files.extend(p.glob("*.yaml")) + files.extend(p.glob("*.yml")) + files.extend(p.glob("*.json")) + + files = sorted(files, key=lambda fp: fp.stat().st_mtime, reverse=True) + + # If we know which profiles are active, prefer those (for YAML profiles only) + if active_stems: + yaml_files = [fp for fp in files if fp.suffix.lower() in {".yaml", ".yml"}] + json_files = [fp for fp in files if fp.suffix.lower() == ".json"] + filtered_yaml = [fp for fp in yaml_files if fp.stem in active_stems] + files = (filtered_yaml or yaml_files) + json_files + if not files: - msg = "Folder contains no .json files" + msg = "Folder contains no supported preset/profile files" raise ValueError(msg) - multi = len(files) > 1 - builds = [] + + builds: list[dict[str, Any]] = [] + for fp in files: - try: - builds.extend(_load_builds_from_file(str(fp), name_tag=(fp.stem if multi else None))) - except json.JSONDecodeError, OSError, KeyError, TypeError, ValueError: - LOGGER.debug("Skipping invalid paragon preset JSON: %s", fp, exc_info=True) + if fp.suffix.lower() == ".json": + try: + builds.extend(_load_builds_from_file(str(fp), name_tag=fp.stem, profile=fp.stem)) + except json.JSONDecodeError, OSError, KeyError, TypeError, ValueError: + LOGGER.debug("Skipping invalid paragon preset JSON: %s", fp, exc_info=True) + continue + + # YAML profile + par = _load_profile_paragon(fp) + if not par: + continue + + base_name = par.get("Name") or fp.stem + steps = _normalize_steps(par.get("ParagonBoardsList", [])) + if not steps: + continue + + # Expose steps as separate selectable builds, final step first + for idx in range(len(steps) - 1, -1, -1): + boards = steps[idx] + step_no = idx + 1 + step_name = base_name + if len(steps) > 1: + step_name = f"{base_name} - Step {step_no}" + step_name = f"{step_name} [{fp.stem}]" + builds.append({"name": step_name, "boards": boards, "profile": fp.stem}) + if not builds: msg = "No valid builds found in folder" raise ValueError(msg) + return builds + # Single file if not p.exists(): msg = "Preset file not found" raise ValueError(msg) - return _load_builds_from_file(str(p)) + if p.suffix.lower() in {".yaml", ".yml"}: + par = _load_profile_paragon(p) + if not par: + msg = "No paragon data found in profile" + raise ValueError(msg) + base_name = par.get("Name") or p.stem + steps = _normalize_steps(par.get("ParagonBoardsList", [])) + if not steps: + msg = "No paragon steps found in profile" + raise ValueError(msg) + builds: list[dict[str, Any]] = [] + for idx in range(len(steps) - 1, -1, -1): + boards = steps[idx] + step_no = idx + 1 + step_name = base_name + if len(steps) > 1: + step_name = f"{base_name} - Step {step_no}" + builds.append({"name": step_name, "boards": boards, "profile": p.stem}) + return builds + + return _load_builds_from_file(str(p), profile=p.stem) def get_font(size: int = 14, bold: bool = False): @@ -570,17 +690,28 @@ def load_config(self): return try: - with cfg_path.open("r", encoding="utf-8") as f: + with cfg_path.open(encoding="utf-8") as f: cfg = json.load(f) except OSError, json.JSONDecodeError, ValueError: return self.list_pos = (0, int(cfg.get("list_pos", (0, 60))[1])) - gp = cfg.get("grid_pos") - if gp: - self.grid_pos = tuple(gp) - self.cell_size = cfg.get("cell_size", 28) - self.minimized = cfg.get("minimized", False) + self.grid_pos = tuple(cfg.get("grid_pos", (0, 60))) + self.cell_size = int(cfg.get("cell_size", 28)) + self.minimized = bool(cfg.get("minimized", False)) + + preferred_profile = cfg.get("paragon_profile") + idx = cfg.get("current_build_idx") + + if preferred_profile: + for bi, b in enumerate(self.builds): + if b.get("profile") == preferred_profile: + self.current_build_idx = bi + self.update_current_build_data() + break + elif isinstance(idx, int) and 0 <= idx < len(self.builds): + self.current_build_idx = idx + self.update_current_build_data() # Clamp positions to visible screen area (prevents 'overlay opened but not visible') with contextlib.suppress(Exception): @@ -600,6 +731,8 @@ def save_config(self): "grid_pos": self.grid_pos, "cell_size": self.cell_size, "minimized": self.minimized, + "current_build_idx": self.current_build_idx, + "paragon_profile": (self.builds[self.current_build_idx].get("profile") if self.builds else None), } cfg_path = Path(CONFIG_FILE) try: From 3ccbb5e9e2ab2ba080a53840575c3effd035cd9f Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Mon, 2 Feb 2026 21:55:13 +0100 Subject: [PATCH 13/16] Refactor paragon overlay to tkinter and align with Cam/ResManager --- src/paragon_overlay.py | 1294 ++++++++++++---------------------------- 1 file changed, 397 insertions(+), 897 deletions(-) diff --git a/src/paragon_overlay.py b/src/paragon_overlay.py index 2dbee797..821c5885 100644 --- a/src/paragon_overlay.py +++ b/src/paragon_overlay.py @@ -1,976 +1,476 @@ -# Integrated into D4LF as src.paragon_overlay -# Original file: d4.py (Win32 layered window Paragon overlay) -# Entry: run_paragon_overlay(preset_path) - -# d4_paragon_overlay_v14_fix_button.py -# -# Features: -# - FIX: Start/Stop Button is now ALWAYS visible (drawn on top of header) -# - EXIT BUTTON (Right side of hint bar) -# - MENU POSITION: Top-Left (0,0) -# - THICK GOLD FRAME -# - ALWAYS ON TOP -# - 64-Bit Safe -# -# Controls: -# [Top-Left Button]: Toggle Start/Stop -# [Red 'EXIT' Button]: Close App -# [Build Header]: Switch Build -# [Scroll Wheel]: Zoom -# [Drag]: Move - -import contextlib -import ctypes -import ctypes.wintypes as wt +"""Paragon overlay (tkinter). + +Refactor goals (per maintainer review): +- Use tkinter Canvas (no Win32/ctypes/user32/gdi32/PIL overlay). +- Route scaling/resolution through existing Cam + ResManager. +- Keep code small and consistent with the codebase style. +""" + +from __future__ import annotations + import json import logging import re import sys +import tkinter as tk +from contextlib import suppress +from dataclasses import dataclass from pathlib import Path -from typing import Any - -from PIL import Image, ImageDraw, ImageFont - -try: - from ruamel.yaml import YAML as RUAMEL_YAML -except ImportError: # pragma: no cover - RUAMEL_YAML = None - -try: - import yaml as PyYAML -except ImportError: # pragma: no cover - PyYAML = None - -try: - from src.config.loader import IniConfigLoader -except ImportError: # pragma: no cover - IniConfigLoader = None - - -# --- HARDENED WIN32 DEFINITIONS (64-BIT SAFE) --- -user32 = ctypes.WinDLL("user32", use_last_error=True) -gdi32 = ctypes.WinDLL("gdi32", use_last_error=True) -kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) - -# Explicit types -HANDLE = ctypes.c_void_p -HWND = ctypes.c_void_p -HDC = ctypes.c_void_p -HBITMAP = ctypes.c_void_p -HGDIOBJ = ctypes.c_void_p -HICON = ctypes.c_void_p -HCURSOR = ctypes.c_void_p -HBRUSH = ctypes.c_void_p -HMENU = ctypes.c_void_p -HINSTANCE = ctypes.c_void_p -LPVOID = ctypes.c_void_p -LPARAM = ctypes.c_longlong -WPARAM = ctypes.c_ulonglong -LRESULT = ctypes.c_longlong - -WNDPROCTYPE = ctypes.WINFUNCTYPE(LRESULT, HWND, wt.UINT, WPARAM, LPARAM) - - -class WNDCLASSW(ctypes.Structure): - _fields_ = [ - ("style", wt.UINT), - ("lpfnWndProc", WNDPROCTYPE), - ("cbClsExtra", ctypes.c_int), - ("cbWndExtra", ctypes.c_int), - ("hInstance", HINSTANCE), - ("hIcon", HICON), - ("hCursor", HCURSOR), - ("hbrBackground", HBRUSH), - ("lpszMenuName", wt.LPCWSTR), - ("lpszClassName", wt.LPCWSTR), - ] - - -class BLENDFUNCTION(ctypes.Structure): - _fields_ = [ - ("BlendOp", wt.BYTE), - ("BlendFlags", wt.BYTE), - ("SourceConstantAlpha", wt.BYTE), - ("AlphaFormat", wt.BYTE), - ] - - -class BITMAPINFOHEADER(ctypes.Structure): - _fields_ = [ - ("biSize", wt.DWORD), - ("biWidth", wt.LONG), - ("biHeight", wt.LONG), - ("biPlanes", wt.WORD), - ("biBitCount", wt.WORD), - ("biCompression", wt.DWORD), - ("biSizeImage", wt.DWORD), - ("biXPelsPerMeter", wt.LONG), - ("biYPelsPerMeter", wt.LONG), - ("biClrUsed", wt.DWORD), - ("biClrImportant", wt.DWORD), - ] - - -class BITMAPINFO(ctypes.Structure): - _fields_ = [("bmiHeader", BITMAPINFOHEADER), ("bmiColors", ctypes.c_ulong * 3)] - - -# --- API Signatures --- -kernel32.GetModuleHandleW.argtypes = [wt.LPCWSTR] -kernel32.GetModuleHandleW.restype = HINSTANCE -user32.RegisterClassW.argtypes = [ctypes.POINTER(WNDCLASSW)] -user32.RegisterClassW.restype = wt.ATOM -user32.CreateWindowExW.argtypes = [ - wt.DWORD, - wt.LPCWSTR, - wt.LPCWSTR, - wt.DWORD, - ctypes.c_int, - ctypes.c_int, - ctypes.c_int, - ctypes.c_int, - HWND, - HMENU, - HINSTANCE, - LPVOID, -] -user32.CreateWindowExW.restype = HWND -user32.DefWindowProcW.argtypes = [HWND, wt.UINT, WPARAM, LPARAM] -user32.DefWindowProcW.restype = LRESULT -user32.UpdateLayeredWindow.argtypes = [ - HWND, - HDC, - ctypes.POINTER(wt.POINT), - ctypes.POINTER(wt.SIZE), - HDC, - ctypes.POINTER(wt.POINT), - wt.COLORREF, - ctypes.POINTER(BLENDFUNCTION), - wt.DWORD, -] -user32.UpdateLayeredWindow.restype = wt.BOOL -user32.GetDC.argtypes = [HWND] -user32.GetDC.restype = HDC -user32.ReleaseDC.argtypes = [HWND, HDC] -user32.ReleaseDC.restype = ctypes.c_int -user32.PostQuitMessage.argtypes = [ctypes.c_int] -user32.PostQuitMessage.restype = None -user32.SetFocus.argtypes = [HWND] -user32.SetFocus.restype = HWND -user32.GetKeyState.argtypes = [ctypes.c_int] -user32.GetKeyState.restype = wt.SHORT -user32.GetSystemMetrics.argtypes = [ctypes.c_int] -user32.GetSystemMetrics.restype = ctypes.c_int -user32.LoadCursorW.argtypes = [HINSTANCE, wt.LPCWSTR] -user32.LoadCursorW.restype = HCURSOR -user32.GetMessageW.argtypes = [ctypes.POINTER(wt.MSG), HWND, wt.UINT, wt.UINT] -user32.GetMessageW.restype = wt.BOOL -user32.TranslateMessage.argtypes = [ctypes.POINTER(wt.MSG)] -user32.TranslateMessage.restype = wt.BOOL -user32.DispatchMessageW.argtypes = [ctypes.POINTER(wt.MSG)] -user32.DispatchMessageW.restype = LRESULT -user32.SetCapture.argtypes = [HWND] -user32.SetCapture.restype = HWND -user32.ReleaseCapture.argtypes = [] -user32.ReleaseCapture.restype = wt.BOOL -user32.GetCursorPos.argtypes = [ctypes.POINTER(wt.POINT)] -user32.GetCursorPos.restype = wt.BOOL -user32.SetCursor.argtypes = [HCURSOR] -user32.SetCursor.restype = HCURSOR -user32.SetWindowPos.argtypes = [HWND, HWND, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, wt.UINT] -user32.SetWindowPos.restype = wt.BOOL - -gdi32.CreateCompatibleDC.argtypes = [HDC] -gdi32.CreateCompatibleDC.restype = HDC -gdi32.SelectObject.argtypes = [HDC, HGDIOBJ] -gdi32.SelectObject.restype = HGDIOBJ -gdi32.DeleteDC.argtypes = [HDC] -gdi32.DeleteDC.restype = wt.BOOL -gdi32.DeleteObject.argtypes = [HGDIOBJ] -gdi32.DeleteObject.restype = wt.BOOL -gdi32.CreateDIBSection.argtypes = [ - HDC, - ctypes.POINTER(BITMAPINFO), - wt.UINT, - ctypes.POINTER(ctypes.c_void_p), - HANDLE, - wt.DWORD, -] -gdi32.CreateDIBSection.restype = HBITMAP - -# --- Constants --- -WS_POPUP = 0x80000000 -WS_VISIBLE = 0x10000000 -WS_EX_TOPMOST = 0x00000008 -WS_EX_LAYERED = 0x00080000 -WS_EX_TOOLWINDOW = 0x00000080 -ULW_ALPHA = 0x00000002 -AC_SRC_OVER = 0x00 -AC_SRC_ALPHA = 0x01 -BI_RGB = 0 -WM_DESTROY = 0x0002 -WM_LBUTTONDOWN = 0x0201 -WM_LBUTTONUP = 0x0202 -WM_MOUSEMOVE = 0x0200 -WM_MOUSEWHEEL = 0x020A -WM_KEYDOWN = 0x0100 -WM_NCHITTEST = 0x0084 -HTCLIENT = 1 -VK_LEFT, VK_UP, VK_RIGHT, VK_DOWN = 0x25, 0x26, 0x27, 0x28 -VK_SHIFT = 0x10 -IDC_ARROW = 32512 -IDC_SIZEALL = 32646 - -HWND_TOPMOST = ctypes.c_void_p(-1) -SWP_NOMOVE = 0x0002 -SWP_NOSIZE = 0x0001 -SWP_NOACTIVATE = 0x0010 - -# --- Logic & Data --- -GRID = 21 -PANEL_W = 600 -ITEM_H = 34 -HEADER_H = 80 - -# Colors -C_GRID_LINE = (80, 80, 80, 60) -C_GRID_FRAME = (217, 143, 57, 240) -C_GRID_FRAME_BG = (0, 0, 0, 150) -C_NODE_ACTIVE = (0, 255, 60, 220) -C_NODE_PATH = (0, 200, 50, 160) -C_ACTION_BG = (50, 60, 80, 220) -C_ITEM_BG = (30, 30, 30, 200) -C_ITEM_BORDER = (80, 80, 80, 255) -C_TEXT = (240, 240, 240, 255) -C_TEXT_DIM = (180, 180, 180, 255) -C_GOLD = (217, 143, 57, 255) - -LOGGER = logging.getLogger(__name__) - - -def _msgbox(title: str, text: str) -> None: - """Show a Windows message box. Safe no-op on non-Windows.""" - with contextlib.suppress(Exception): - if sys.platform == "win32": - user32.MessageBoxW(None, str(text), str(title), 0) - +from typing import TYPE_CHECKING, Any -def get_xy(lparam): - return (lparam & 0xFFFF), ((lparam >> 16) & 0xFFFF) +from src.cam import Cam +from src.config.ui import ResManager +if TYPE_CHECKING: + from collections.abc import Callable, Iterable -def parse_rotation(rot_str: str) -> int: - m = re.search(r"(\d+)", rot_str or "") - deg = int(m.group(1)) if m else 0 - return deg % 360 if deg % 360 in (0, 90, 180, 270) else 0 - - -def nodes_to_grid(nodes_441): - return [[bool(nodes_441[y * GRID + x]) for x in range(GRID)] for y in range(GRID)] - +LOGGER = logging.getLogger(__name__) -def rotate_grid(grid, deg: int): - if deg == 90: - return [list(reversed(col)) for col in zip(*grid, strict=True)] - if deg == 180: - return [list(reversed(r)) for r in reversed(grid)] - if deg == 270: - return [list(col) for col in reversed(list(zip(*grid, strict=True)))] - return grid +GRID = 21 # 21x21 nodes -def _iter_entries(data): - """Yield build-like dicts from JSON that can be either a list[dict] or a dict.""" +# ---------------------------- +# Data loading / normalization +# ---------------------------- +def _iter_entries(data: Any) -> Iterable[dict[str, Any]]: if isinstance(data, dict): yield data - elif isinstance(data, list): + return + if isinstance(data, list): for it in data: if isinstance(it, dict): yield it -def _normalize_steps(raw_list): - """Normalize ParagonBoardsList to a list of steps, each step being a list[board].""" +def _normalize_steps(raw_list: Any) -> list[list[dict[str, Any]]]: if not isinstance(raw_list, list) or not raw_list: return [] - # If first element is a list, assume list-of-steps. if isinstance(raw_list[0], list): return [step for step in raw_list if isinstance(step, list) and step] - # Otherwise assume a single step list-of-boards. return [raw_list] -def _load_builds_from_file(preset_file: str, name_tag: str | None = None, profile: str | None = None): - """Load one JSON file and return a list of builds in overlay format: {name, boards}. +def load_builds_from_path(preset_path: str) -> list[dict[str, Any]]: + p = Path(preset_path) + + msg_not_found = "Preset file/folder not found" + if not p.exists(): + raise ValueError(msg_not_found) + + files: list[Path] + if p.is_dir(): + files = sorted(p.glob("*.json"), key=lambda fp: fp.stat().st_mtime, reverse=True) + msg_no_files = "Folder contains no supported preset files (*.json)" + if not files: + raise ValueError(msg_no_files) + + builds: list[dict[str, Any]] = [] + for fp in files: + try: + builds.extend(_load_builds_from_file(fp, name_tag=fp.stem, profile=fp.stem)) + except Exception: + LOGGER.debug("Skipping invalid paragon preset JSON: %s", fp, exc_info=True) + + msg_no_builds = "No valid builds found in folder" + if not builds: + raise ValueError(msg_no_builds) + return builds + + return _load_builds_from_file(p, profile=p.stem) + - Supports: - - D4LF paragon exports (JSON list with single entry) - - AffixPresets-v2 style (JSON list with many entries) - - A single dict payload - Also expands multi-step ParagonBoardsList into multiple selectable builds. - """ - with Path(preset_file).open(encoding="utf-8") as f: +def _load_builds_from_file( + preset_file: Path, *, name_tag: str | None = None, profile: str | None = None +) -> list[dict[str, Any]]: + with preset_file.open(encoding="utf-8") as f: data = json.load(f) - builds = [] + builds: list[dict[str, Any]] = [] for entry in _iter_entries(data): base_name = entry.get("Name") or entry.get("name") or "Unknown Build" steps = _normalize_steps(entry.get("ParagonBoardsList", [])) if not steps: continue - # If there are multiple steps, expose them as separate selectable builds. - # For planners that provide many incremental steps (e.g., Maxroll), it's more useful to start on the FINAL step. + # Expose steps as separate builds; final step first. for idx in range(len(steps) - 1, -1, -1): boards = steps[idx] - step_no = idx + 1 step_name = base_name if len(steps) > 1: - step_name = f"{base_name} - Step {step_no}" + step_name = f"{base_name} - Step {idx + 1}" if name_tag: step_name = f"{step_name} [{name_tag}]" + builds.append({"name": step_name, "boards": boards, "profile": profile}) if not builds: - msg = f"No valid builds in {preset_file}" - raise ValueError(msg) + msg_no_valid = f"No valid builds in {preset_file}" + raise ValueError(msg_no_valid) return builds -def load_builds_from_path(preset_path: str): - """Load builds from a JSON preset or from a folder of profile/preset files. +def parse_rotation(rot_str: str) -> int: + m = re.search(r"(\d+)", rot_str or "") + deg = int(m.group(1)) if m else 0 + deg = deg % 360 + return deg if deg in (0, 90, 180, 270) else 0 - Supported inputs: - - a single JSON preset file (legacy) - - a single YAML profile file (contains a top-level `Paragon:` payload) - - a directory containing *.yaml/*.yml profiles and/or *.json presets - If the directory is the d4lf profiles folder, we try to only load active profiles (general.profiles). - """ +def nodes_to_grid(nodes_441: list[int] | list[bool]) -> list[list[bool]]: + # 21*21 = 441 + return [[bool(nodes_441[y * GRID + x]) for x in range(GRID)] for y in range(GRID)] - def _load_profile_paragon(fp: Path) -> dict[str, Any] | None: - try: - with fp.open("r", encoding="utf-8") as f: - if RUAMEL_YAML is not None: - y = RUAMEL_YAML(typ="safe") - data = y.load(f) or {} - elif PyYAML is not None: - data = PyYAML.safe_load(f) or {} - else: # pragma: no cover - return None - except Exception: - LOGGER.debug("Skipping invalid YAML profile: %s", fp, exc_info=True) - return None - if not isinstance(data, dict): - return None - par = data.get("Paragon") - if not isinstance(par, dict): - return None - if not par.get("ParagonBoardsList"): - return None - return par - p = Path(preset_path) +def rotate_grid(grid: list[list[bool]], deg: int) -> list[list[bool]]: + if deg == 90: + return [list(reversed(col)) for col in zip(*grid, strict=True)] + if deg == 180: + return [list(reversed(r)) for r in reversed(grid)] + if deg == 270: + return [list(col) for col in reversed(list(zip(*grid, strict=True)))] + return grid - # Determine active profile stems (best effort) - active_stems: set[str] | None = None - try: - if IniConfigLoader is not None: - cfg = IniConfigLoader() - gp = getattr(cfg, "general", None) - profs = getattr(gp, "profiles", None) if gp is not None else None - if isinstance(profs, list) and profs: - active_stems = {Path(x).stem for x in profs} - except Exception: - active_stems = None - if p.is_dir(): - files: list[Path] = [] - files.extend(p.glob("*.yaml")) - files.extend(p.glob("*.yml")) - files.extend(p.glob("*.json")) +# ---------------------------- +# Overlay UI +# ---------------------------- +@dataclass(slots=True) +class OverlayConfig: + cell_size: int = 24 + panel_w: int = 420 + poll_ms: int = 350 # watch Cam/ResManager changes without custom callbacks - files = sorted(files, key=lambda fp: fp.stat().st_mtime, reverse=True) - # If we know which profiles are active, prefer those (for YAML profiles only) - if active_stems: - yaml_files = [fp for fp in files if fp.suffix.lower() in {".yaml", ".yml"}] - json_files = [fp for fp in files if fp.suffix.lower() == ".json"] - filtered_yaml = [fp for fp in yaml_files if fp.stem in active_stems] - files = (filtered_yaml or yaml_files) + json_files +class ParagonOverlay(tk.Toplevel): + """A simple tkinter Canvas overlay for Paragon board visualization.""" - if not files: - msg = "Folder contains no supported preset/profile files" - raise ValueError(msg) + def __init__( + self, + parent: tk.Misc, + builds: list[dict[str, Any]], + *, + cfg: OverlayConfig | None = None, + on_close: Callable[[], None] | None = None, + ) -> None: + super().__init__(parent) - builds: list[dict[str, Any]] = [] + self._cfg = cfg or OverlayConfig() + self._on_close = on_close - for fp in files: - if fp.suffix.lower() == ".json": - try: - builds.extend(_load_builds_from_file(str(fp), name_tag=fp.stem, profile=fp.stem)) - except json.JSONDecodeError, OSError, KeyError, TypeError, ValueError: - LOGGER.debug("Skipping invalid paragon preset JSON: %s", fp, exc_info=True) - continue - - # YAML profile - par = _load_profile_paragon(fp) - if not par: - continue - - base_name = par.get("Name") or fp.stem - steps = _normalize_steps(par.get("ParagonBoardsList", [])) - if not steps: - continue - - # Expose steps as separate selectable builds, final step first - for idx in range(len(steps) - 1, -1, -1): - boards = steps[idx] - step_no = idx + 1 - step_name = base_name - if len(steps) > 1: - step_name = f"{base_name} - Step {step_no}" - step_name = f"{step_name} [{fp.stem}]" - builds.append({"name": step_name, "boards": boards, "profile": fp.stem}) + self.builds = builds + self.current_build_idx = 0 + self.boards = self.builds[0]["boards"] if self.builds else [] + self.selected_board_idx = 0 - if not builds: - msg = "No valid builds found in folder" - raise ValueError(msg) + self._last_res: tuple[int, int] | None = None + self._last_roi: tuple[int, int, int, int] | None = None - return builds + self.title("D4LF Paragon Overlay") + self.attributes("-topmost", True) - # Single file - if not p.exists(): - msg = "Preset file not found" - raise ValueError(msg) - - if p.suffix.lower() in {".yaml", ".yml"}: - par = _load_profile_paragon(p) - if not par: - msg = "No paragon data found in profile" - raise ValueError(msg) - base_name = par.get("Name") or p.stem - steps = _normalize_steps(par.get("ParagonBoardsList", [])) - if not steps: - msg = "No paragon steps found in profile" - raise ValueError(msg) - builds: list[dict[str, Any]] = [] - for idx in range(len(steps) - 1, -1, -1): - boards = steps[idx] - step_no = idx + 1 - step_name = base_name - if len(steps) > 1: - step_name = f"{base_name} - Step {step_no}" - builds.append({"name": step_name, "boards": boards, "profile": p.stem}) - return builds + # "Overlay-like" appearance (best-effort on Windows). + self.configure(bg="#ff00ff") + with suppress(tk.TclError): + self.overrideredirect(True) + with suppress(tk.TclError): + self.wm_attributes("-transparentcolor", "#ff00ff") - return _load_builds_from_file(str(p), profile=p.stem) + self.protocol("WM_DELETE_WINDOW", self.close) + self._build_ui() + self._bind_events() -def get_font(size: int = 14, bold: bool = False): - font_name = "arialbd.ttf" if bold else "arial.ttf" - try: - return ImageFont.truetype(font_name, size) - except OSError: - return ImageFont.load_default() - - -FONT_HEADER = get_font(16, bold=True) -FONT_ITEM = get_font(13, bold=True) -FONT_SMALL = get_font(11, bold=False) - - -def render_grid_window(board, cell_size): - rot_deg = parse_rotation(board.get("Rotation", "0°")) - grid = rotate_grid(nodes_to_grid(board["Nodes"]), rot_deg) - grid_px = GRID * cell_size - - img = Image.new("RGBA", (grid_px + 30, grid_px + 30), (0, 0, 0, 0)) - d = ImageDraw.Draw(img) - gx0, gy0 = 15, 15 - half_cell = cell_size // 2 - - # 1. Background Grid - for i in range(GRID + 1): - p = i * cell_size - d.line([(gx0, gy0 + p), (gx0 + grid_px, gy0 + p)], fill=C_GRID_LINE, width=1) - d.line([(gx0 + p, gy0), (gx0 + p, gy0 + grid_px)], fill=C_GRID_LINE, width=1) - - # 2. Draw Paths - path_width = max(2, cell_size // 6) - for y in range(GRID): - for x in range(GRID): - if grid[y][x]: - cx, cy = gx0 + x * cell_size + half_cell, gy0 + y * cell_size + half_cell - if x + 1 < GRID and grid[y][x + 1]: - nx, ny = gx0 + (x + 1) * cell_size + half_cell, cy - d.line([(cx, cy), (nx, ny)], fill=C_NODE_PATH, width=path_width) - if y + 1 < GRID and grid[y + 1][x]: - nx, ny = cx, gy0 + (y + 1) * cell_size + half_cell - d.line([(cx, cy), (nx, ny)], fill=C_NODE_PATH, width=path_width) - - # 3. Draw Nodes - inset = max(3, cell_size // 4) - for y in range(GRID): - for x in range(GRID): - if grid[y][x]: - px, py = gx0 + x * cell_size, gy0 + y * cell_size - d.rectangle( - (px + inset, py + inset, px + cell_size - inset, py + cell_size - inset), - fill=C_NODE_ACTIVE, - outline=None, - ) - d.rectangle( - (px + inset, py + inset, px + cell_size - inset, py + cell_size - inset), - outline=(200, 255, 200, 100), - width=1, - ) - - # 4. THICK Outer Frame - frame_thick = 5 - d.rectangle( - (gx0 - 1, gy0 - 1, gx0 + grid_px + 1, gy0 + grid_px + 1), outline=C_GRID_FRAME_BG, width=frame_thick + 2 - ) - d.rectangle((gx0, gy0, gx0 + grid_px, gy0 + grid_px), outline=C_GRID_FRAME, width=frame_thick) - - return img - - -def render_list_window(state): - minimized = state.minimized - selecting = state.selecting_build - - # 1. PREPARE TOGGLE BUTTON - btn_rect = [2, 2, 26, 26] - if minimized: - fill_col = (200, 50, 50) # Red - symbol = "\u2716" # X - txt_offset = (6, 5) - else: - fill_col = (50, 200, 50) # Green - symbol = "\u2714" # Check - txt_offset = (6, 5) - - # 2. IF MINIMIZED -> DRAW ONLY BUTTON AND RETURN - if minimized: - img = Image.new("RGBA", (PANEL_W, 30), (0, 0, 0, 1)) - d = ImageDraw.Draw(img) - d.rectangle(btn_rect, fill=fill_col, outline=(200, 200, 200)) - d.text(txt_offset, symbol, fill=(255, 255, 255, 255), font=FONT_ITEM) - return img - - # 3. IF OPEN -> DRAW CONTENT - if selecting: - data, title = state.builds, "Select Build (Click to cancel)" - active_idx = state.current_build_idx - else: - data, title = state.boards, state.build_name + " \u25bc" - active_idx = state.selected - - rows = len(data) - total_h = HEADER_H + (rows * (ITEM_H + 4)) + 10 - - img = Image.new("RGBA", (PANEL_W, total_h), (0, 0, 0, 1)) - d = ImageDraw.Draw(img) - - # Header Background - d.rectangle((0, 0, PANEL_W, HEADER_H), fill=C_ACTION_BG if selecting else C_ITEM_BG) - d.text((35, 10), title, fill=C_TEXT, font=FONT_HEADER) - - # Hint Box - d.rectangle((0, 50, PANEL_W - 5, 85), fill=C_ITEM_BG, outline=C_ITEM_BORDER, width=1) - hint = f"Found {len(data)} builds" if selecting else "click on golden frame= Zoom: Mousewheel | Move: Drag Grid" - d.text((12, 58), hint, fill=C_GOLD, font=FONT_SMALL) - - # EXIT BUTTON (Right side) - exit_rect = [PANEL_W - 35, 55, PANEL_W - 10, 80] - d.rectangle(exit_rect, fill=(180, 0, 0), outline=(200, 200, 200)) - d.text((PANEL_W - 30, 59), "EXIT", fill=(255, 255, 255), font=get_font(9, True)) - - d.line([(0, HEADER_H), (PANEL_W, HEADER_H)], fill=C_GOLD, width=1) - - # List Items - y_start = HEADER_H + 10 - for i, item in enumerate(data): - label = item["name"] if selecting else f"{item.get('Name', '?')} ({item.get('Rotation', '0')})" - if not selecting and item.get("Glyph"): - label += f" ({item.get('Glyph')})" - - y = y_start + i * (ITEM_H + 4) - bg, border, txt = C_ITEM_BG, C_ITEM_BORDER, C_TEXT_DIM - - if i == active_idx: - bg, border, txt = (40, 35, 20, 220), C_GOLD, C_TEXT - elif i == state.hover: - border, txt = (150, 150, 150, 255), C_TEXT - - d.rectangle((0, y, PANEL_W - 5, y + ITEM_H), fill=bg, outline=border, width=1) - d.text((10, y + (ITEM_H - 13) // 2 - 2), label, fill=txt, font=FONT_ITEM) - - # 4. DRAW TOGGLE BUTTON LAST (So it sits ON TOP of Header) - d.rectangle(btn_rect, fill=fill_col, outline=(200, 200, 200)) - d.text(txt_offset, symbol, fill=(255, 255, 255, 255), font=FONT_ITEM) - - return img - - -def pil_to_hbitmap(pil_img): - img = pil_img.convert("RGBA") - w, h = img.size - bmi = BITMAPINFO() - bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER) - bmi.bmiHeader.biWidth, bmi.bmiHeader.biHeight = w, -h - bmi.bmiHeader.biPlanes, bmi.bmiHeader.biBitCount = 1, 32 - bmi.bmiHeader.biCompression = BI_RGB - bits = ctypes.c_void_p() - hdc_screen = user32.GetDC(None) - hbmp = gdi32.CreateDIBSection(hdc_screen, ctypes.byref(bmi), 0, ctypes.byref(bits), None, 0) - user32.ReleaseDC(None, hdc_screen) - ctypes.memmove(bits, img.tobytes("raw", "BGRA"), w * h * 4) - return hbmp - - -def update_window(hwnd, img, x, y): - hbmp = pil_to_hbitmap(img) - hdc_screen = user32.GetDC(None) - hdc_mem = gdi32.CreateCompatibleDC(hdc_screen) - old = gdi32.SelectObject(hdc_mem, hbmp) - pt_dst, sz, pt_src = wt.POINT(x, y), wt.SIZE(img.width, img.height), wt.POINT(0, 0) - blend = BLENDFUNCTION(AC_SRC_OVER, 0, 255, AC_SRC_ALPHA) - user32.UpdateLayeredWindow( - hwnd, - hdc_screen, - ctypes.byref(pt_dst), - ctypes.byref(sz), - hdc_mem, - ctypes.byref(pt_src), - 0, - ctypes.byref(blend), - ULW_ALPHA, - ) - - # Always On Top - user32.SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE) - - gdi32.SelectObject(hdc_mem, old) - gdi32.DeleteObject(hbmp) - gdi32.DeleteDC(hdc_mem) - user32.ReleaseDC(None, hdc_screen) - - -class AppState: - def __init__(self, builds): - self.builds = builds - self.current_build_idx = 0 - self.update_current_build_data() - self.selected = 0 - self.hover = -1 - self.selecting_build = False - self.hwnd_grid = None - self.hwnd_list = None - self.grid_pos = (450, 60) - self.list_pos = (0, 0) - self.cell_size = 28 - self.minimized = False - self.grid_cache = {} - self.dragging = False - self.drag_start = (0, 0) - self.drag_offset = (0, 0) - self.load_config() - - def update_current_build_data(self): - cur = self.builds[self.current_build_idx] - self.build_name, self.boards = cur["name"], cur["boards"] - self.grid_cache = {} - - def load_config(self): - cfg_path = Path(CONFIG_FILE) - if not cfg_path.exists(): - return + self._apply_geometry() + self._refresh_lists() + self.redraw() + + # Poll resolution/ROI changes (ResManager has no callback API). + self.after(self._cfg.poll_ms, self._poll_state) + + # -------- UI layout -------- + def _build_ui(self) -> None: + outer = tk.Frame(self, bg="#ff00ff") + outer.pack(fill="both", expand=True) + + # left panel + self.left = tk.Frame(outer, width=self._cfg.panel_w, bg="#1b1b1b") + self.left.pack(side="left", fill="y") + + header = tk.Frame(self.left, bg="#222") + header.pack(fill="x") + + self.btn_close = tk.Button(header, text="EXIT", command=self.close) + self.btn_close.pack(side="right", padx=6, pady=6) + + self.lbl_title = tk.Label(header, text="Paragon Overlay", fg="#eee", bg="#222") + self.lbl_title.pack(side="left", padx=8) + + self.build_list = tk.Listbox(self.left, activestyle="none") + self.build_list.pack(fill="x", padx=8, pady=(6, 8)) + + self.board_list = tk.Listbox(self.left, activestyle="none") + self.board_list.pack(fill="both", expand=True, padx=8, pady=(0, 8)) + + footer = tk.Frame(self.left, bg="#222") + footer.pack(fill="x") + self.lbl_hint = tk.Label(footer, text="Wheel: zoom | Drag grid: move window", fg="#cfa15b", bg="#222") + self.lbl_hint.pack(side="left", padx=8, pady=6) + # right: canvas + self.right = tk.Frame(outer, bg="#000") + self.right.pack(side="right", fill="both", expand=True) + + self.canvas = tk.Canvas(self.right, highlightthickness=0, bg="#000") + self.canvas.pack(fill="both", expand=True) + + def _bind_events(self) -> None: + self.build_list.bind("<>", self._on_select_build) + self.board_list.bind("<>", self._on_select_board) + + # zoom + self.canvas.bind("", self._on_mousewheel) + self.canvas.bind("", self._on_mousewheel) # linux + self.canvas.bind("", self._on_mousewheel) + + # drag window + self.canvas.bind("", self._on_drag_start) + self.canvas.bind("", self._on_drag_move) + + # -------- polling / state -------- + def _poll_state(self) -> None: try: - with cfg_path.open(encoding="utf-8") as f: - cfg = json.load(f) - except OSError, json.JSONDecodeError, ValueError: + res = self._get_resolution() + roi = self._get_roi() + + changed = (res != self._last_res) or (roi != self._last_roi) + if changed: + self._apply_geometry() + self.redraw() + + self._last_res = res + self._last_roi = roi + except Exception: + LOGGER.debug("Overlay poll failed", exc_info=True) + finally: + self.after(self._cfg.poll_ms, self._poll_state) + + # -------- handlers -------- + def _on_select_build(self, _: Any) -> None: + sel = self._get_listbox_index(self.build_list) + if sel is None: + return + self.current_build_idx = sel + self.boards = self.builds[sel]["boards"] + self.selected_board_idx = 0 + self._refresh_lists() + self.redraw() + + def _on_select_board(self, _: Any) -> None: + sel = self._get_listbox_index(self.board_list) + if sel is None: + return + self.selected_board_idx = sel + self.redraw() + + def _on_mousewheel(self, e: tk.Event) -> None: + delta = 0 + if getattr(e, "delta", 0): + delta = 1 if e.delta > 0 else -1 + elif getattr(e, "num", 0) in (4, 5): + delta = 1 if e.num == 4 else -1 + + if not delta: return - self.list_pos = (0, int(cfg.get("list_pos", (0, 60))[1])) - self.grid_pos = tuple(cfg.get("grid_pos", (0, 60))) - self.cell_size = int(cfg.get("cell_size", 28)) - self.minimized = bool(cfg.get("minimized", False)) - - preferred_profile = cfg.get("paragon_profile") - idx = cfg.get("current_build_idx") - - if preferred_profile: - for bi, b in enumerate(self.builds): - if b.get("profile") == preferred_profile: - self.current_build_idx = bi - self.update_current_build_data() - break - elif isinstance(idx, int) and 0 <= idx < len(self.builds): - self.current_build_idx = idx - self.update_current_build_data() - - # Clamp positions to visible screen area (prevents 'overlay opened but not visible') - with contextlib.suppress(Exception): - sw = user32.GetSystemMetrics(0) - sh = user32.GetSystemMetrics(1) - # list window: x is always 0 - ly = max(0, min(int(self.list_pos[1]), max(0, sh - 80))) - self.list_pos = (0, ly) - gx, gy = self.grid_pos - gx = max(0, min(int(gx), max(0, sw - 80))) - gy = max(0, min(int(gy), max(0, sh - 80))) - self.grid_pos = (gx, gy) - - def save_config(self): - cfg = { - "list_pos": self.list_pos, - "grid_pos": self.grid_pos, - "cell_size": self.cell_size, - "minimized": self.minimized, - "current_build_idx": self.current_build_idx, - "paragon_profile": (self.builds[self.current_build_idx].get("profile") if self.builds else None), - } - cfg_path = Path(CONFIG_FILE) + new_size = max(10, min(80, self._cfg.cell_size + (2 * delta))) + if new_size != self._cfg.cell_size: + self._cfg.cell_size = new_size + self.redraw() + + def _on_drag_start(self, e: tk.Event) -> None: + self._drag_start_xy = (e.x_root, e.y_root) + self._drag_start_geo = (self.winfo_x(), self.winfo_y()) + + def _on_drag_move(self, e: tk.Event) -> None: + sx, sy = self._drag_start_xy + gx, gy = self._drag_start_geo + dx = e.x_root - sx + dy = e.y_root - sy + self.geometry(f"+{gx + dx}+{gy + dy}") + + # -------- helpers -------- + @staticmethod + def _get_listbox_index(lb: tk.Listbox) -> int | None: + cur = lb.curselection() + return int(cur[0]) if cur else None + + def _get_resolution(self) -> tuple[int, int]: + # ResManager.resolution -> (w, h) based on active res key. try: - with cfg_path.open("w", encoding="utf-8") as f: - json.dump(cfg, f) - except OSError: - LOGGER.debug("Failed to save overlay config", exc_info=True) - - -state = None -CONFIG_FILE = "d4_overlay_config.json" - - -def redraw_all(force_grid=False): - l_img = render_list_window(state) - update_window(state.hwnd_list, l_img, 0, state.list_pos[1]) - - if state.minimized or state.selecting_build: - g_img = Image.new("RGBA", (1, 1), (0, 0, 0, 0)) - else: - key = (state.selected, state.cell_size) - if force_grid or key not in state.grid_cache: - state.grid_cache[key] = render_grid_window(state.boards[state.selected], state.cell_size) - g_img = state.grid_cache[key] - update_window(state.hwnd_grid, g_img, state.grid_pos[0], state.grid_pos[1]) - - -@WNDPROCTYPE -def WndProcList(hwnd, msg, wparam, lparam): - if msg == WM_DESTROY: - user32.PostQuitMessage(0) - return 0 - if msg == WM_NCHITTEST: - return HTCLIENT - - if msg == WM_MOUSEMOVE: - if state.minimized: - return 0 - _, y = get_xy(lparam) - if y > HEADER_H: - lst = state.builds if state.selecting_build else state.boards - idx = (y - HEADER_H - 10) // (ITEM_H + 4) - nh = idx if 0 <= idx < len(lst) else -1 - if nh != state.hover: - state.hover = nh - redraw_all() - elif state.hover != -1: - state.hover = -1 - redraw_all() - return 0 - - if msg == WM_LBUTTONDOWN: - user32.SetFocus(hwnd) - x, y = get_xy(lparam) - # Check START/STOP Button - if x < 28 and y < 28: - state.minimized = not state.minimized - state.save_config() - redraw_all() - return 0 - - if state.minimized: - return 0 - - # Check EXIT Button - # Rect: [PANEL_W - 35, 55, PANEL_W - 10, 80] - if PANEL_W - 35 <= x <= PANEL_W - 10 and 55 <= y <= 80: - user32.PostQuitMessage(0) - return 0 - - if y < HEADER_H: - if x > 30: - state.selecting_build = not state.selecting_build - state.hover = -1 - redraw_all() - return 0 - - lst = state.builds if state.selecting_build else state.boards - idx = (y - HEADER_H - 10) // (ITEM_H + 4) - if 0 <= idx < len(lst): - if state.selecting_build: - state.current_build_idx = idx - state.update_current_build_data() - state.selected = 0 - state.selecting_build = False - else: - state.selected = idx - redraw_all() - return 0 - return user32.DefWindowProcW(hwnd, msg, wparam, lparam) - - -@WNDPROCTYPE -def WndProcGrid(hwnd, msg, wparam, lparam): - if msg == WM_NCHITTEST: - return HTCLIENT - - if msg == WM_MOUSEWHEEL: - delta = ctypes.c_short(wparam >> 16).value - change = 2 if delta > 0 else -2 - new_size = max(10, min(150, state.cell_size + change)) - if new_size != state.cell_size: - state.cell_size = new_size - state.save_config() - redraw_all(True) - return 0 - - if msg == WM_LBUTTONDOWN: - user32.SetFocus(hwnd) - user32.SetCapture(hwnd) - state.dragging = True - pt = wt.POINT() - user32.GetCursorPos(ctypes.byref(pt)) - state.drag_start = (pt.x, pt.y) - state.drag_offset = state.grid_pos - user32.SetCursor(user32.LoadCursorW(state.h_inst, wt.LPCWSTR(IDC_SIZEALL))) - return 0 - - if msg == WM_MOUSEMOVE: - if state.dragging: - pt = wt.POINT() - user32.GetCursorPos(ctypes.byref(pt)) - dx = pt.x - state.drag_start[0] - dy = pt.y - state.drag_start[1] - state.grid_pos = (state.drag_offset[0] + dx, state.drag_offset[1] + dy) - redraw_all(False) - return 0 - - if msg == WM_LBUTTONUP: - if state.dragging: - state.dragging = False - user32.ReleaseCapture() - state.save_config() - user32.SetCursor(user32.LoadCursorW(state.h_inst, wt.LPCWSTR(IDC_ARROW))) - return 0 - - if msg == WM_KEYDOWN: - gx, gy = state.grid_pos - step = 10 if (user32.GetKeyState(VK_SHIFT) & 0x8000) else 1 - if wparam == VK_LEFT: - state.grid_pos = (gx - step, gy) - elif wparam == VK_RIGHT: - state.grid_pos = (gx + step, gy) - elif wparam == VK_UP: - state.grid_pos = (gx, gy - step) - elif wparam == VK_DOWN: - state.grid_pos = (gx, gy + step) - if wparam in (VK_LEFT, VK_RIGHT, VK_UP, VK_DOWN): - state.save_config() - redraw_all() - - return user32.DefWindowProcW(hwnd, msg, wparam, lparam) - - -def request_close() -> None: - """Best-effort request to close the overlay windows. - - This is used when the overlay is run in-process (e.g., from a background thread). - """ - st = globals().get("state") - if st is None: - return + w, h = ResManager().resolution[:2] + return (int(w), int(h)) + except Exception: + return (self.winfo_screenwidth(), self.winfo_screenheight()) + + def _get_roi(self) -> tuple[int, int, int, int] | None: + try: + roi = Cam().window_roi + except Exception: + return None + if not roi or len(roi) < 4: + return None + x, y, w, h = roi[:4] + return (int(x), int(y), int(w), int(h)) + + def _apply_geometry(self) -> None: + # Prefer ROI (game window) size/position if present; else use screen resolution. + res_w, res_h = self._get_resolution() + roi = self._get_roi() + + if roi is not None: + roi_x, roi_y, roi_w, roi_h = roi + # Attach overlay near the game window top-left; keep a small offset. + x0, y0 = roi_x + 20, roi_y + 20 + available_w = max(800, roi_w - 40) + available_h = max(600, roi_h - 40) + else: + x0, y0 = 20, 20 + available_w = res_w + available_h = res_h + + grid_w = min(760, max(480, available_w - self._cfg.panel_w - 80)) + grid_h = min(760, max(480, available_h - 120)) + total_w = self._cfg.panel_w + grid_w + total_h = grid_h + + self.geometry(f"{total_w}x{total_h}+{x0}+{y0}") + self.canvas.config(width=grid_w, height=grid_h) + + def _refresh_lists(self) -> None: + self.build_list.delete(0, tk.END) + for b in self.builds: + self.build_list.insert(tk.END, b.get("name", "Unknown Build")) + if self.builds: + self.build_list.selection_set(self.current_build_idx) + + self.board_list.delete(0, tk.END) + for bd in self.boards or []: + name = bd.get("Name", "?") + rot = bd.get("Rotation", "0") + glyph = bd.get("Glyph") + label = f"{name} ({rot})" + if glyph: + label += f" ({glyph})" + self.board_list.insert(tk.END, label) + if self.boards: + self.board_list.selection_set(self.selected_board_idx) + + cur_name = self.builds[self.current_build_idx]["name"] if self.builds else "Paragon Overlay" + self.lbl_title.config(text=cur_name) + + # -------- drawing -------- + def redraw(self) -> None: + self.canvas.delete("all") + if not self.boards: + self.canvas.create_text(20, 20, anchor="nw", fill="#fff", text="No boards loaded") + return - wm_close = 0x0010 - for hwnd in (getattr(st, "hwnd_list", None), getattr(st, "hwnd_grid", None)): - if hwnd: - with contextlib.suppress(Exception): - user32.PostMessageW(hwnd, wm_close, 0, 0) + board = self.boards[self.selected_board_idx] + nodes = board.get("Nodes") or [] + if len(nodes) != GRID * GRID: + self.canvas.create_text(20, 20, anchor="nw", fill="#fff", text="Invalid board node data") + return + rot = parse_rotation(board.get("Rotation", "0°")) + grid = rotate_grid(nodes_to_grid(nodes), rot) + + cs = self._cfg.cell_size + pad = 16 + gx0, gy0 = pad, pad + grid_px = GRID * cs + + # background frame + self.canvas.create_rectangle(gx0 - 6, gy0 - 6, gx0 + grid_px + 6, gy0 + grid_px + 6, outline="#cfa15b", width=3) + + # grid lines + for i in range(GRID + 1): + p = i * cs + self.canvas.create_line(gx0, gy0 + p, gx0 + grid_px, gy0 + p, fill="#444", width=1) + self.canvas.create_line(gx0 + p, gy0, gx0 + p, gy0 + grid_px, fill="#444", width=1) + + # paths + path_w = max(2, cs // 6) + half = cs // 2 + for y in range(GRID): + for x in range(GRID): + if not grid[y][x]: + continue + cx = gx0 + x * cs + half + cy = gy0 + y * cs + half + if x + 1 < GRID and grid[y][x + 1]: + nx = gx0 + (x + 1) * cs + half + self.canvas.create_line(cx, cy, nx, cy, fill="#22aa44", width=path_w) + if y + 1 < GRID and grid[y + 1][x]: + ny = gy0 + (y + 1) * cs + half + self.canvas.create_line(cx, cy, cx, ny, fill="#22aa44", width=path_w) + + # active nodes + inset = max(3, cs // 4) + for y in range(GRID): + for x in range(GRID): + if not grid[y][x]: + continue + x1 = gx0 + x * cs + inset + y1 = gy0 + y * cs + inset + x2 = gx0 + (x + 1) * cs - inset + y2 = gy0 + (y + 1) * cs - inset + self.canvas.create_rectangle(x1, y1, x2, y2, fill="#18dd44", outline="#bbffbb", width=1) + + # -------- lifecycle -------- + def close(self) -> None: + try: + self.destroy() + finally: + if self._on_close: + self._on_close() + + +# ---------------------------- +# Public API (used by app) +# ---------------------------- +def run_paragon_overlay(preset_path: str | None = None, *, parent: tk.Misc | None = None) -> ParagonOverlay | None: + """Start overlay in-process. If parent is None, a Tk root is created.""" + preset = preset_path or (sys.argv[1] if len(sys.argv) > 1 else "") + if not preset: + LOGGER.error("No preset path provided") + return None -def run_paragon_overlay(preset_path: str | None = None) -> None: - global state - preset = preset_path or (sys.argv[1] if len(sys.argv) > 1 else "AffixPresets-v2.json") try: builds = load_builds_from_path(preset) - except Exception as e: - # In packaged mode we often suppress stdout/stderr; show a visible error. + except Exception: LOGGER.exception("Failed to load Paragon preset(s): %s", preset) - _msgbox("D4LF Paragon Overlay", f"Konnte Paragon JSON nicht laden.\n\nQuelle: {preset}\n\nFehler: {e}") - return + return None + + owns_root = False + if parent is None: + root = tk.Tk() + root.withdraw() + parent = root + owns_root = True - state = AppState(builds) - state.h_inst = kernel32.GetModuleHandleW(None) - - wc = WNDCLASSW( - style=0, - lpfnWndProc=WndProcList, - hInstance=state.h_inst, - hCursor=user32.LoadCursorW(state.h_inst, wt.LPCWSTR(IDC_ARROW)), - lpszClassName="D4ListCls", - ) - user32.RegisterClassW(ctypes.byref(wc)) - - wc.lpfnWndProc = WndProcGrid - wc.lpszClassName = "D4GridCls" - user32.RegisterClassW(ctypes.byref(wc)) - - ex_style = WS_EX_TOPMOST | WS_EX_LAYERED | WS_EX_TOOLWINDOW - - # Init default to Top-Left if config not loaded - if state.list_pos == (0, 0) and state.grid_pos == (450, 60): - state.list_pos = (0, 0) - state.grid_pos = (600, 50) - - state.hwnd_list = user32.CreateWindowExW( - ex_style, - "D4ListCls", - "List", - WS_POPUP | WS_VISIBLE, - 0, - state.list_pos[1], - 400, - 600, - None, - None, - state.h_inst, - None, - ) - state.hwnd_grid = user32.CreateWindowExW( - ex_style, - "D4GridCls", - "Grid", - WS_POPUP | WS_VISIBLE, - state.grid_pos[0], - state.grid_pos[1], - 800, - 800, - None, - None, - state.h_inst, - None, - ) - - redraw_all(True) - msg = wt.MSG() - while user32.GetMessageW(ctypes.byref(msg), 0, 0, 0): - user32.TranslateMessage(ctypes.byref(msg)) - user32.DispatchMessageW(ctypes.byref(msg)) + overlay = ParagonOverlay(parent, builds, on_close=(parent.quit if owns_root else None)) + if owns_root: + parent.mainloop() + return overlay + + +def request_close(overlay: ParagonOverlay | None) -> None: + """Best-effort close from elsewhere.""" + if overlay is None: + return + with suppress(Exception): + overlay.after(0, overlay.close) if __name__ == "__main__": From 539c5bffe684ffa77612846d828d605e5def295a Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Mon, 2 Feb 2026 22:03:34 +0100 Subject: [PATCH 14/16] update readme.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 365bf2e5..ec471b3a 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ D4LF can import Paragon boards from supported build planners and show them in-ga - Ensure Diablo IV is running in **borderless windowed** (exclusive fullscreen may block overlays). - Ensure your Paragon folder contains `*.json` files (default: `C:/Users//.d4lf/paragon`). - Check/adjust `advanced_options.toggle_paragon_overlay` (default `f10`) and ensure it is not conflicting with other hotkeys. - - If the overlay is off-screen, delete `d4_overlay_config.json` next to `d4lf.exe` to reset its position. + - If the overlay is off-screen, close D4LF and start it again (the overlay resets to a default position). You can drag the overlay to reposition it. - The GUI crashes immediately upon opening, with no error message given - This almost always means there is an issue in your params.ini. Delete the file and then open the GUI and configure your params.ini through the config tab. Using the GUI for configuration will ensure the file is always accurate. From 1abe48c380359eec8d3356cafc604c66a8d73a30 Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Tue, 3 Feb 2026 15:48:23 +0100 Subject: [PATCH 15/16] YAML paragon data in overlay + docs and defaults I updated the overlay to load Paragon data from the current profiles/*.yaml format (top-level Paragon written by paragon_export.py), while keeping legacy .json support for older exports. Also updated the default source directory and README references from ~/.d4lf/paragon/*.json to ~/.d4lf/profiles/*.yaml to match the new workflow --- README.md | 36 +++--- src/paragon_overlay.py | 261 +++++++++++++++++++++++++---------------- src/scripts/handler.py | 8 +- 3 files changed, 185 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index ec471b3a..b5dc40c8 100644 --- a/README.md +++ b/README.md @@ -67,9 +67,9 @@ D4LF can import Paragon boards from supported build planners and show them in-ga - Paragon overlay does not appear / does nothing - Ensure Diablo IV is running in **borderless windowed** (exclusive fullscreen may block overlays). - - Ensure your Paragon folder contains `*.json` files (default: `C:/Users//.d4lf/paragon`). + - Ensure your profiles folder contains `*.yaml` files with a top-level `Paragon:` section (default: `C:/Users//.d4lf/profiles`). (Legacy `paragon/*.json` is still supported when "Export Paragon JSON" is enabled.) - Check/adjust `advanced_options.toggle_paragon_overlay` (default `f10`) and ensure it is not conflicting with other hotkeys. - - If the overlay is off-screen, close D4LF and start it again (the overlay resets to a default position). You can drag the overlay to reposition it. + - If the overlay is off-screen, delete `d4_overlay_config.json` next to `d4lf.exe` to reset its position. - The GUI crashes immediately upon opening, with no error message given - This almost always means there is an issue in your params.ini. Delete the file and then open the GUI and configure your params.ini through the config tab. Using the GUI for configuration will ensure the file is always accurate. @@ -101,7 +101,7 @@ The config folder in `C:/Users//.d4lf` contains: automatically. - **params.ini**: Different hotkey settings and number of chest stashes that should be looked at. Management of this file should be done through the GUI in the config window. -- **paragon/\*.json**: Paragon builds for the integrated overlay. Generated by the importers when "Export Paragon JSON" is enabled. Default location: `C:/Users//.d4lf/paragon` +- **paragon/\*.json**: (Legacy) Paragon builds for the integrated overlay. Only generated when "Export Paragon JSON" is enabled. Default location: `C:/Users//.d4lf/paragon` ### params.ini @@ -129,21 +129,21 @@ The config folder in `C:/Users//.d4lf` contains: | --------- | --------------------------------- | | inventory | Your hotkey for opening inventory | -| [advanced_options] | Description | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| move_to_inv | Hotkey for moving items from stash to inventory | -| move_to_chest | Hotkey for moving items from inventory to stash | -| run_filter | Hotkey to start/stop filtering items | -| run_filter_force_refresh | Hotkey to start/stop filtering items with a force refresh. All item statuses will be reset | -| run_vision_mode | Hotkey to start/stop vision mode | -| force_refresh_only | Hotkey to reset all item statuses without running a filter after | -| exit_key | Hotkey to exit d4lf.exe | -| toggle_paragon_overlay | Hotkey to open/close the Paragon overlay (default: f10) | -| paragon_overlay_source_dir | Folder containing Paragon JSON files for the overlay. Leave blank to use the default: `~/.d4lf/paragon` | -| log_lvl | Logging level. Can be any of [debug, info, warning, error, critical] | -| process_name | Process name of the D4 app. Defaults to "Diablo IV.exe". In case of using some remote play this might need to be adapted | -| vision_mode_only | If set to true, only the vision mode will be available. All functionality that clicks the screen is disabled. | -| fast_vision_mode_coordinates | If you are using fast vision mode, provide the location on screen where you want the overlay to appear. For example, you could provide (500, 800) | +| [advanced_options] | Description | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| move_to_inv | Hotkey for moving items from stash to inventory | +| move_to_chest | Hotkey for moving items from inventory to stash | +| run_filter | Hotkey to start/stop filtering items | +| run_filter_force_refresh | Hotkey to start/stop filtering items with a force refresh. All item statuses will be reset | +| run_vision_mode | Hotkey to start/stop vision mode | +| force_refresh_only | Hotkey to reset all item statuses without running a filter after | +| exit_key | Hotkey to exit d4lf.exe | +| toggle_paragon_overlay | Hotkey to open/close the Paragon overlay (default: f10) | +| paragon_overlay_source_dir | Folder containing Paragon profiles for the overlay (`*.yaml`/`*.yml` with a top-level `Paragon:` section). Leave blank to use the default: `~/.d4lf/profiles` (legacy `*.json` also supported). | +| log_lvl | Logging level. Can be any of [debug, info, warning, error, critical] | +| process_name | Process name of the D4 app. Defaults to "Diablo IV.exe". In case of using some remote play this might need to be adapted | +| vision_mode_only | If set to true, only the vision mode will be available. All functionality that clicks the screen is disabled. | +| fast_vision_mode_coordinates | If you are using fast vision mode, provide the location on screen where you want the overlay to appear. For example, you could provide (500, 800) | ### GUI diff --git a/src/paragon_overlay.py b/src/paragon_overlay.py index 821c5885..d671ed61 100644 --- a/src/paragon_overlay.py +++ b/src/paragon_overlay.py @@ -1,10 +1,4 @@ -"""Paragon overlay (tkinter). - -Refactor goals (per maintainer review): -- Use tkinter Canvas (no Win32/ctypes/user32/gdi32/PIL overlay). -- Route scaling/resolution through existing Cam + ResManager. -- Keep code small and consistent with the codebase style. -""" +"""Paragon overlay (tkinter).""" from __future__ import annotations @@ -21,12 +15,24 @@ from src.cam import Cam from src.config.ui import ResManager +try: + from ruamel.yaml import YAML as RUAMEL_YAML +except ImportError: # pragma: no cover + RUAMEL_YAML = None + +try: + import yaml as PyYAML +except ImportError: # pragma: no cover + PyYAML = None + + if TYPE_CHECKING: from collections.abc import Callable, Iterable LOGGER = logging.getLogger(__name__) GRID = 21 # 21x21 nodes +NODES_LEN = GRID * GRID # ---------------------------- @@ -50,6 +56,36 @@ def _normalize_steps(raw_list: Any) -> list[list[dict[str, Any]]]: return [raw_list] +def _load_yaml_file(path: Path) -> dict[str, Any] | None: + try: + if RUAMEL_YAML is not None: + y = RUAMEL_YAML(typ="rt") + with path.open("r", encoding="utf-8") as f: + loaded = y.load(f) or {} + return loaded if isinstance(loaded, dict) else None + if PyYAML is not None: + with path.open("r", encoding="utf-8") as f: + loaded = PyYAML.safe_load(f) or {} + return loaded if isinstance(loaded, dict) else None + except Exception: + LOGGER.debug("Failed reading YAML: %s", path, exc_info=True) + return None + + LOGGER.error("No YAML library available (ruamel.yaml or PyYAML)") + return None + + +def _extract_paragon_payloads_from_profile_yaml(profile_yaml: dict[str, Any]) -> Iterable[dict[str, Any]]: + paragon = profile_yaml.get("Paragon") + if isinstance(paragon, dict): + yield paragon + return + if isinstance(paragon, list): + for it in paragon: + if isinstance(it, dict): + yield it + + def load_builds_from_path(preset_path: str) -> list[dict[str, Any]]: p = Path(preset_path) @@ -57,56 +93,104 @@ def load_builds_from_path(preset_path: str) -> list[dict[str, Any]]: if not p.exists(): raise ValueError(msg_not_found) - files: list[Path] - if p.is_dir(): - files = sorted(p.glob("*.json"), key=lambda fp: fp.stat().st_mtime, reverse=True) - msg_no_files = "Folder contains no supported preset files (*.json)" - if not files: - raise ValueError(msg_no_files) + builds: list[dict[str, Any]] = [] - builds: list[dict[str, Any]] = [] - for fp in files: + if p.is_file(): + suffix = p.suffix.lower() + if suffix == ".json": + builds.extend(_load_builds_from_json_file(p, profile=p.stem)) + elif suffix in (".yaml", ".yml"): + builds.extend(_load_builds_from_profile_yaml_file(p, profile=p.stem)) + else: + msg = "Unsupported preset file type" + raise ValueError(msg) + if not builds: + msg = "No valid builds found" + raise ValueError(msg) + return builds + + # Directory mode: + # - legacy exports: *.json in the selected folder + # - current format: profiles/*.yaml (or sibling ../profiles/*.yaml) + json_files = sorted(p.glob("*.json"), key=lambda fp: fp.stat().st_mtime, reverse=True) + if json_files: + for fp in json_files: try: - builds.extend(_load_builds_from_file(fp, name_tag=fp.stem, profile=fp.stem)) + builds.extend(_load_builds_from_json_file(fp, name_tag=fp.stem, profile=fp.stem)) except Exception: LOGGER.debug("Skipping invalid paragon preset JSON: %s", fp, exc_info=True) - msg_no_builds = "No valid builds found in folder" - if not builds: - raise ValueError(msg_no_builds) - return builds + yaml_dirs: list[Path] = [] + if (p / "profiles").is_dir(): + yaml_dirs.append(p / "profiles") + if (p.parent / "profiles").is_dir(): + yaml_dirs.append(p.parent / "profiles") + yaml_dirs.append(p) + + seen: set[Path] = set() + for yd in yaml_dirs: + for fp in sorted(yd.glob("*.ya*"), key=lambda x: x.stat().st_mtime, reverse=True): + if fp in seen: + continue + seen.add(fp) + try: + builds.extend(_load_builds_from_profile_yaml_file(fp, profile=fp.stem)) + except Exception: + LOGGER.debug("Skipping invalid profile YAML: %s", fp, exc_info=True) - return _load_builds_from_file(p, profile=p.stem) + if not builds: + msg = "No valid builds found in folder" + raise ValueError(msg) + return builds -def _load_builds_from_file( - preset_file: Path, *, name_tag: str | None = None, profile: str | None = None +def _load_builds_from_json_file( + preset_file: Path, name_tag: str | None = None, profile: str | None = None ) -> list[dict[str, Any]]: with preset_file.open(encoding="utf-8") as f: data = json.load(f) builds: list[dict[str, Any]] = [] for entry in _iter_entries(data): - base_name = entry.get("Name") or entry.get("name") or "Unknown Build" - steps = _normalize_steps(entry.get("ParagonBoardsList", [])) - if not steps: - continue - - # Expose steps as separate builds; final step first. - for idx in range(len(steps) - 1, -1, -1): - boards = steps[idx] - step_name = base_name - if len(steps) > 1: - step_name = f"{base_name} - Step {idx + 1}" - if name_tag: - step_name = f"{step_name} [{name_tag}]" - - builds.append({"name": step_name, "boards": boards, "profile": profile}) + builds.extend(_builds_from_paragon_entry(entry, name_tag=name_tag, profile=profile)) + if not builds: + msg = "No valid builds in JSON" + raise ValueError(msg) + return builds + +def _load_builds_from_profile_yaml_file(profile_file: Path, profile: str | None = None) -> list[dict[str, Any]]: + loaded = _load_yaml_file(profile_file) + if not loaded: + msg = "Invalid or empty profile YAML" + raise ValueError(msg) + + builds: list[dict[str, Any]] = [] + for payload in _extract_paragon_payloads_from_profile_yaml(loaded): + builds.extend(_builds_from_paragon_entry(payload, name_tag=profile_file.stem, profile=profile)) if not builds: - msg_no_valid = f"No valid builds in {preset_file}" - raise ValueError(msg_no_valid) + msg = "No Paragon payload found in profile YAML" + raise ValueError(msg) + return builds + + +def _builds_from_paragon_entry( + entry: dict[str, Any], *, name_tag: str | None, profile: str | None +) -> list[dict[str, Any]]: + base_name = entry.get("Name") or entry.get("name") or "Unknown Build" + steps = _normalize_steps(entry.get("ParagonBoardsList", [])) + if not steps: + return [] + builds: list[dict[str, Any]] = [] + for idx in range(len(steps) - 1, -1, -1): + boards = steps[idx] + step_name = base_name + if len(steps) > 1: + step_name = f"{base_name} - Step {idx + 1}" + if name_tag: + step_name = f"{step_name} [{name_tag}]" + builds.append({"name": step_name, "boards": boards, "profile": profile}) return builds @@ -118,7 +202,6 @@ def parse_rotation(rot_str: str) -> int: def nodes_to_grid(nodes_441: list[int] | list[bool]) -> list[list[bool]]: - # 21*21 = 441 return [[bool(nodes_441[y * GRID + x]) for x in range(GRID)] for y in range(GRID)] @@ -139,11 +222,11 @@ def rotate_grid(grid: list[list[bool]], deg: int) -> list[list[bool]]: class OverlayConfig: cell_size: int = 24 panel_w: int = 420 - poll_ms: int = 350 # watch Cam/ResManager changes without custom callbacks + poll_ms: int = 500 class ParagonOverlay(tk.Toplevel): - """A simple tkinter Canvas overlay for Paragon board visualization.""" + """Tkinter paragon overlay window.""" def __init__( self, @@ -154,22 +237,24 @@ def __init__( on_close: Callable[[], None] | None = None, ) -> None: super().__init__(parent) - self._cfg = cfg or OverlayConfig() self._on_close = on_close + self._cam = Cam() + self._res = ResManager() + self.builds = builds self.current_build_idx = 0 self.boards = self.builds[0]["boards"] if self.builds else [] self.selected_board_idx = 0 - self._last_res: tuple[int, int] | None = None self._last_roi: tuple[int, int, int, int] | None = None + self._last_res: tuple[int, int] | None = None self.title("D4LF Paragon Overlay") self.attributes("-topmost", True) - # "Overlay-like" appearance (best-effort on Windows). + # Overlay-like appearance, best effort. self.configure(bg="#ff00ff") with suppress(tk.TclError): self.overrideredirect(True) @@ -184,16 +269,13 @@ def __init__( self._apply_geometry() self._refresh_lists() self.redraw() - - # Poll resolution/ROI changes (ResManager has no callback API). - self.after(self._cfg.poll_ms, self._poll_state) + self.after(self._cfg.poll_ms, self._poll_window_state) # -------- UI layout -------- def _build_ui(self) -> None: outer = tk.Frame(self, bg="#ff00ff") outer.pack(fill="both", expand=True) - # left panel self.left = tk.Frame(outer, width=self._cfg.panel_w, bg="#1b1b1b") self.left.pack(side="left", fill="y") @@ -217,7 +299,6 @@ def _build_ui(self) -> None: self.lbl_hint = tk.Label(footer, text="Wheel: zoom | Drag grid: move window", fg="#cfa15b", bg="#222") self.lbl_hint.pack(side="left", padx=8, pady=6) - # right: canvas self.right = tk.Frame(outer, bg="#000") self.right.pack(side="right", fill="both", expand=True) @@ -228,32 +309,26 @@ def _bind_events(self) -> None: self.build_list.bind("<>", self._on_select_build) self.board_list.bind("<>", self._on_select_board) - # zoom self.canvas.bind("", self._on_mousewheel) - self.canvas.bind("", self._on_mousewheel) # linux + self.canvas.bind("", self._on_mousewheel) self.canvas.bind("", self._on_mousewheel) - # drag window self.canvas.bind("", self._on_drag_start) self.canvas.bind("", self._on_drag_move) - # -------- polling / state -------- - def _poll_state(self) -> None: + # -------- polling for ROI/resolution changes -------- + def _poll_window_state(self) -> None: try: + roi = self._get_cam_roi() res = self._get_resolution() - roi = self._get_roi() - changed = (res != self._last_res) or (roi != self._last_roi) - if changed: + if roi != self._last_roi or res != self._last_res: + self._last_roi = roi + self._last_res = res self._apply_geometry() self.redraw() - - self._last_res = res - self._last_roi = roi - except Exception: - LOGGER.debug("Overlay poll failed", exc_info=True) finally: - self.after(self._cfg.poll_ms, self._poll_state) + self.after(self._cfg.poll_ms, self._poll_window_state) # -------- handlers -------- def _on_select_build(self, _: Any) -> None: @@ -306,45 +381,36 @@ def _get_listbox_index(lb: tk.Listbox) -> int | None: return int(cur[0]) if cur else None def _get_resolution(self) -> tuple[int, int]: - # ResManager.resolution -> (w, h) based on active res key. - try: - w, h = ResManager().resolution[:2] + with suppress(Exception): + w, h = tuple(self._res.resolution)[:2] return (int(w), int(h)) - except Exception: - return (self.winfo_screenwidth(), self.winfo_screenheight()) + return (self.winfo_screenwidth(), self.winfo_screenheight()) - def _get_roi(self) -> tuple[int, int, int, int] | None: + def _get_cam_roi(self) -> tuple[int, int, int, int] | None: + roi = getattr(self._cam, "window_roi", None) + if not roi: + return None try: - roi = Cam().window_roi + x, y, w, h = roi + return (int(x), int(y), int(w), int(h)) except Exception: return None - if not roi or len(roi) < 4: - return None - x, y, w, h = roi[:4] - return (int(x), int(y), int(w), int(h)) def _apply_geometry(self) -> None: - # Prefer ROI (game window) size/position if present; else use screen resolution. - res_w, res_h = self._get_resolution() - roi = self._get_roi() + w, h = self._get_resolution() + roi = self._get_cam_roi() - if roi is not None: - roi_x, roi_y, roi_w, roi_h = roi - # Attach overlay near the game window top-left; keep a small offset. - x0, y0 = roi_x + 20, roi_y + 20 - available_w = max(800, roi_w - 40) - available_h = max(600, roi_h - 40) - else: - x0, y0 = 20, 20 - available_w = res_w - available_h = res_h - - grid_w = min(760, max(480, available_w - self._cfg.panel_w - 80)) - grid_h = min(760, max(480, available_h - 120)) + grid_w = min(760, max(480, w - self._cfg.panel_w - 80)) + grid_h = min(760, max(480, h - 120)) total_w = self._cfg.panel_w + grid_w total_h = grid_h - self.geometry(f"{total_w}x{total_h}+{x0}+{y0}") + if roi is not None: + x, y, _, _ = roi + self.geometry(f"{total_w}x{total_h}+{x + 20}+{y + 20}") + else: + self.geometry(f"{total_w}x{total_h}+20+20") + self.canvas.config(width=grid_w, height=grid_h) def _refresh_lists(self) -> None: @@ -378,7 +444,7 @@ def redraw(self) -> None: board = self.boards[self.selected_board_idx] nodes = board.get("Nodes") or [] - if len(nodes) != GRID * GRID: + if len(nodes) != NODES_LEN: self.canvas.create_text(20, 20, anchor="nw", fill="#fff", text="Invalid board node data") return @@ -390,16 +456,13 @@ def redraw(self) -> None: gx0, gy0 = pad, pad grid_px = GRID * cs - # background frame self.canvas.create_rectangle(gx0 - 6, gy0 - 6, gx0 + grid_px + 6, gy0 + grid_px + 6, outline="#cfa15b", width=3) - # grid lines for i in range(GRID + 1): p = i * cs self.canvas.create_line(gx0, gy0 + p, gx0 + grid_px, gy0 + p, fill="#444", width=1) self.canvas.create_line(gx0 + p, gy0, gx0 + p, gy0 + grid_px, fill="#444", width=1) - # paths path_w = max(2, cs // 6) half = cs // 2 for y in range(GRID): @@ -415,7 +478,6 @@ def redraw(self) -> None: ny = gy0 + (y + 1) * cs + half self.canvas.create_line(cx, cy, cx, ny, fill="#22aa44", width=path_w) - # active nodes inset = max(3, cs // 4) for y in range(GRID): for x in range(GRID): @@ -440,7 +502,7 @@ def close(self) -> None: # Public API (used by app) # ---------------------------- def run_paragon_overlay(preset_path: str | None = None, *, parent: tk.Misc | None = None) -> ParagonOverlay | None: - """Start overlay in-process. If parent is None, a Tk root is created.""" + """Start overlay in-process.""" preset = preset_path or (sys.argv[1] if len(sys.argv) > 1 else "") if not preset: LOGGER.error("No preset path provided") @@ -460,6 +522,7 @@ def run_paragon_overlay(preset_path: str | None = None, *, parent: tk.Misc | Non owns_root = True overlay = ParagonOverlay(parent, builds, on_close=(parent.quit if owns_root else None)) + if owns_root: parent.mainloop() return overlay diff --git a/src/scripts/handler.py b/src/scripts/handler.py index 66eab05c..fe27c608 100644 --- a/src/scripts/handler.py +++ b/src/scripts/handler.py @@ -59,14 +59,16 @@ def toggle_paragon_overlay(self): config = IniConfigLoader() overlay_dir_str = getattr(config.advanced_options, "paragon_overlay_source_dir", "") or "" overlay_dir = ( - Path(overlay_dir_str).expanduser() if str(overlay_dir_str).strip() else (config.user_dir / "paragon") + Path(overlay_dir_str).expanduser() if str(overlay_dir_str).strip() else (config.user_dir / "profiles") ) overlay_dir.mkdir(parents=True, exist_ok=True) + yaml_files = list(Path(overlay_dir).glob("*.yaml")) + list(Path(overlay_dir).glob("*.yml")) json_files = list(Path(overlay_dir).glob("*.json")) - if not json_files: + if not (yaml_files or json_files): LOGGER.warning( - f"No Paragon JSON files found in {overlay_dir}. Import a build first or place *.json files there." + "No Paragon profiles found in %s. Import a build first or place *.yaml/*.yml profiles there (legacy *.json also supported).", + overlay_dir, ) # Disable vision mode while the overlay is active; restore it when the overlay closes. From 4940bdd1e23025efe71cfbda33b027f89c64145e Mon Sep 17 00:00:00 2001 From: chrizzocb Date: Tue, 3 Feb 2026 16:14:31 +0100 Subject: [PATCH 16/16] Readme fixes +renaming in import profile gui Update importer checkbox label and description Align README wording with new Paragon workflow --- README.md | 38 +++++++++++++++++++------------------- src/gui/importer_window.py | 4 ++-- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index b5dc40c8..506b0410 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ D4LF can import Paragon boards from supported build planners and show them in-ga **How to use** 1. Import your build from a supported planner (Mobalytics / Maxroll / D4Builds). -1. Enable **Export Paragon JSON** in the importer (optional) and choose a Paragon folder (or leave the default). +1. Enable **Import Paragon** in the importer (optional). Paragon data will be stored in your profile YAMLs in the profiles folder (default: `~/.d4lf/profiles`). 1. Toggle the Paragon overlay using the hotkey (default **F10**, configurable in *Advanced options*). **Tips** @@ -67,7 +67,7 @@ D4LF can import Paragon boards from supported build planners and show them in-ga - Paragon overlay does not appear / does nothing - Ensure Diablo IV is running in **borderless windowed** (exclusive fullscreen may block overlays). - - Ensure your profiles folder contains `*.yaml` files with a top-level `Paragon:` section (default: `C:/Users//.d4lf/profiles`). (Legacy `paragon/*.json` is still supported when "Export Paragon JSON" is enabled.) + - Ensure your profiles folder contains `*.yaml`/`*.yml` profile files with a top-level `Paragon:` section (default: `C:/Users//.d4lf/profiles`). - Check/adjust `advanced_options.toggle_paragon_overlay` (default `f10`) and ensure it is not conflicting with other hotkeys. - If the overlay is off-screen, delete `d4_overlay_config.json` next to `d4lf.exe` to reset its position. - The GUI crashes immediately upon opening, with no error message given @@ -101,7 +101,7 @@ The config folder in `C:/Users//.d4lf` contains: automatically. - **params.ini**: Different hotkey settings and number of chest stashes that should be looked at. Management of this file should be done through the GUI in the config window. -- **paragon/\*.json**: (Legacy) Paragon builds for the integrated overlay. Only generated when "Export Paragon JSON" is enabled. Default location: `C:/Users//.d4lf/paragon` +- **profiles/\*.yaml**: Profiles including embedded Paragon data for the integrated overlay (top-level `Paragon:`). Generated/updated by the importer when "Import Paragon" is enabled. Default location: `C:/Users//.d4lf/profiles` ### params.ini @@ -129,21 +129,21 @@ The config folder in `C:/Users//.d4lf` contains: | --------- | --------------------------------- | | inventory | Your hotkey for opening inventory | -| [advanced_options] | Description | -| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| move_to_inv | Hotkey for moving items from stash to inventory | -| move_to_chest | Hotkey for moving items from inventory to stash | -| run_filter | Hotkey to start/stop filtering items | -| run_filter_force_refresh | Hotkey to start/stop filtering items with a force refresh. All item statuses will be reset | -| run_vision_mode | Hotkey to start/stop vision mode | -| force_refresh_only | Hotkey to reset all item statuses without running a filter after | -| exit_key | Hotkey to exit d4lf.exe | -| toggle_paragon_overlay | Hotkey to open/close the Paragon overlay (default: f10) | -| paragon_overlay_source_dir | Folder containing Paragon profiles for the overlay (`*.yaml`/`*.yml` with a top-level `Paragon:` section). Leave blank to use the default: `~/.d4lf/profiles` (legacy `*.json` also supported). | -| log_lvl | Logging level. Can be any of [debug, info, warning, error, critical] | -| process_name | Process name of the D4 app. Defaults to "Diablo IV.exe". In case of using some remote play this might need to be adapted | -| vision_mode_only | If set to true, only the vision mode will be available. All functionality that clicks the screen is disabled. | -| fast_vision_mode_coordinates | If you are using fast vision mode, provide the location on screen where you want the overlay to appear. For example, you could provide (500, 800) | +| [advanced_options] | Description | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| move_to_inv | Hotkey for moving items from stash to inventory | +| move_to_chest | Hotkey for moving items from inventory to stash | +| run_filter | Hotkey to start/stop filtering items | +| run_filter_force_refresh | Hotkey to start/stop filtering items with a force refresh. All item statuses will be reset | +| run_vision_mode | Hotkey to start/stop vision mode | +| force_refresh_only | Hotkey to reset all item statuses without running a filter after | +| exit_key | Hotkey to exit d4lf.exe | +| toggle_paragon_overlay | Hotkey to open/close the Paragon overlay (default: f10) | +| paragon_overlay_source_dir | Folder containing profile YAML files with embedded Paragon data (top-level `Paragon:`) for the overlay. Leave blank to use the default: `~/.d4lf/profiles` | +| log_lvl | Logging level. Can be any of [debug, info, warning, error, critical] | +| process_name | Process name of the D4 app. Defaults to "Diablo IV.exe". In case of using some remote play this might need to be adapted | +| vision_mode_only | If set to true, only the vision mode will be available. All functionality that clicks the screen is disabled. | +| fast_vision_mode_coordinates | If you are using fast vision mode, provide the location on screen where you want the overlay to appear. For example, you could provide (500, 800) | ### GUI @@ -157,7 +157,7 @@ automatically picked up and no restart is necessary. Current functionality: -- Import builds from maxroll/d4builds/mobalytics (optionally export Paragon JSON) +- Import builds from maxroll/d4builds/mobalytics (optionally import Paragon data) - Toggle the integrated Paragon overlay (default hotkey: F10) and configure its JSON folder via "Paragon Folder" - Complete management of your settings through the config tab - A beta version of a manual profile editor/creator diff --git a/src/gui/importer_window.py b/src/gui/importer_window.py index 1a346e91..563c1768 100644 --- a/src/gui/importer_window.py +++ b/src/gui/importer_window.py @@ -108,9 +108,9 @@ def __init__(self, parent=None): ) self.export_paragon_checkbox = self._generate_checkbox( - "Export Paragon JSON", + "Import Paragon", "export_paragon", - "Export paragon boards to a JSON file for the integrated Paragon overlay. Output: /paragon", + "Import Paragon boards into your profile for the integrated Paragon overlay.", "false", )